Skip to content

feat(scan): harden the publish scan with 20 detectors, a rule corpus, and a labeled fixture gate - #183

Open
A1igator wants to merge 7 commits into
mainfrom
A1igator/scan-hardening-corpus
Open

feat(scan): harden the publish scan with 20 detectors, a rule corpus, and a labeled fixture gate#183
A1igator wants to merge 7 commits into
mainfrom
A1igator/scan-hardening-corpus

Conversation

@A1igator

Copy link
Copy Markdown
Contributor

Scope

Hardens the deterministic publish scan with 20 new detectors and a labeled fixture corpus that measures per-detector precision and recall in CI, so a detector edit shows its false-positive cost before it merges.

  • src/lib/scan-rules.json: the detector set moves out of code into data (id, tier, pattern, description, per-rule attribution). src/lib/scan.ts compiles it and throws on an unknown handler rather than silently dropping a block-tier rule. The tenjin server-side ingest gate vendors this same file.
  • src/lib/scan.ts: block tier gains BIP-39 seed phrases, otpauth:// TOTP URIs, framing-free OpenSSH keys, and 12 provider token shapes; warn tier gains RFC1918/loopback endpoints, collaboration links, cloud resource ids, .env dump blocks, and a Shannon-entropy catch-all. Placeholder suppression drops docs-shaped matches (sk-xxxx, <YOUR_KEY>, user@example.com) including for block-tier detectors, which is the one bypass risk here; it is narrowly scoped to x runs, angle/brace templates, your… labels, and the RFC 2606 reserved domains, and pinned by tests.
  • src/lib/scan-corpus.json + src/lib/scan.corpus.test.ts: 121 labeled samples (positives and benign lookalikes for every detector, plus an adversarial transcript-shaped sample) hold precision and recall at 1.0, enforce the redaction invariant, and pin a ReDoS budget against transcript-scale input.

Notes

The ReDoS audit found and fixed three quadratic patterns: email, internal-hostname, and db-connection-uri each had an unbounded run that backtracked against a following obligation. On a 200KB single line (the shape a JSONL transcript record takes) the worst detector now costs 39ms; before, two of them took 25 and 31 seconds.

The rule port was diffed against secretlint's preset-recommend. Eight shapes present there and absent from the gitleaks subset are now included and credited in NOTICE.md, along with the BIP-39 wordlist. Nothing derives from TruffleHog (AGPL).

Credential literals in the corpus are stored split into thirds and rejoined at test time, so the committed bytes carry no contiguous token shape. GitHub push protection rejected the first version of this branch, which is the correct behaviour and the reason for the split.

src/skills-text.test.ts had a guard tying the publish skill's warn-triage lists to the detector set. It fired on this change; the skill's two lists now name the five new warn detectors, and the guard reads scan-rules.json directly instead of scraping source and inferring each tier from proximity.

Testing

  • pnpm lint, pnpm typecheck, pnpm format:check: clean.
  • pnpm test: 2179 passed, 10 skipped, 69 files.
  • pnpm build and bash scripts/pack-smoke.sh: pass (the corpus JSON is bundled into dist, not shipped loose).

Closes #45. Refs #182.

… and a labeled fixture gate

Move the detector set out of code into src/lib/scan-rules.json as data (id,
tier, pattern, description, attribution) so the tenjin server-side ingest gate
can vendor the same corpus. scan.ts compiles that data and implements the
handlers and algorithmic detectors it names, refusing to start on an unknown
handler rather than silently dropping a block-tier rule.

Twenty new detectors. Block: BIP-39 seed phrases, otpauth:// TOTP URIs,
framing-free OpenSSH private keys, and Supabase/Twilio/SendGrid/Hugging Face/
Vercel/Notion/Linear/Figma/GitLab/Docker/Cloudflare/Databricks token shapes.
Warn: RFC1918 and loopback endpoints, collaboration workspace links, cloud
resource ids, pasted .env blocks, and a Shannon-entropy catch-all. Placeholder
suppression drops docs-shaped matches before they reach the findings list.

A labeled fixture corpus holds per-detector precision and recall at 1.0 in CI,
enforces the redaction invariant, and pins a ReDoS budget against
transcript-scale input, which caught three quadratic patterns (email,
internal-hostname, db-connection-uri).

Closes #45. Refs #182.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator A1igator added the priority: critical Blocks other PRs; merge-priority label Aug 18, 2026
@A1igator

Copy link
Copy Markdown
Contributor Author

The failing skill-drift (mirror is in sync) check is the known shared cause owned by #151: the mirror resync fix (97f8650) has not reached main yet, so every fresh branch inherits the drift. Not this PR's diff; no action here. It clears when #151 (or a split-out sync:skill fix) lands on main. All local gates on this PR are green (lint, typecheck, format, 2179 tests, build, pack-smoke).

@A1igator

Copy link
Copy Markdown
Contributor Author

Merged origin/main (d37524d, the #186 mirror resync) forward into this branch to clear the shared skill-drift red. Clean merge, no conflicts: this diff is scan rule data, fixtures, and their tests, and #180's hook work does not touch them. Re-ran lint, typecheck, format:check, and the full suite on the merge commit: 2232 passed, 10 skipped.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@vraspar vraspar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: strong corpus and ReDoS work; the placeholder suppression bypasses the block tier on realistic input

Reviewed against docs/CONVENTIONS.md and the adversarial money/secrets model, verified at 8347a08, run at transcript scale.

What's solid:

  • The ReDoS budget test is real and meaningful: it exercises the exact quadratic shapes (uri separators, email locals, nested paths, base64 blobs) on a 200KB single line with a 1000ms budget. I reproduced the guarded email pattern at ~38ms on a 140K-token line, while an unbounded variant already hits ~102ms on a 4K-token line (quadratic, seconds at scale), so the budget reddens on the old pattern with a wide margin.
  • The corpus negatives are genuine hard lookalikes, not softballs: the 11-word BIP-39 boundary, getUserProfileByAccountIdentifier2 vs entropy, an all-lowercase git SHA, AKIALOOKSLIKE, sk-user-profile-..., SKU12345. Precision/recall 1.0 over 121 samples is earned on this set.
  • Licensing is honest: gitleaks (15 shapes), secretlint preset-recommend (exactly the 8 non-gitleaks shapes, each with a matching source field), BIP-39 wordlist credited to scure-bip39, and TruffleHog (AGPL) explicitly disclaimed. NOTICE matches the rule data.
  • The compile step throws on an unknown tier / algorithm / skip / excerpt handler rather than silently dropping a block rule (scan.ts compileLineDetectors / compileExcerpt).
  • The skills-text guard now reads scan-rules.json directly instead of scraping source and inferring tier by proximity — a strictly better coupling.

Major

  1. security: the docs-placeholder suppression bypasses the block tier on realistic secrets: isDocsPlaceholder runs DOCS_PLACEHOLDER.test(m[0]) — a SUBSTRING test over the whole match — ahead of every detector including block-tier ones (

    if (!isDocsPlaceholder(m[0]) && detector.skip?.(m) !== true) {
    ,
    const DOCS_PLACEHOLDER =
    /x{6,}|<[^<>]*>|\{\{[^{}]*\}\}|\byour[_-]?(?:key|token|secret|password)\b/i;
    function isDocsPlaceholder(match: string): boolean {
    return DOCS_PLACEHOLDER.test(match);
    }
    ). Because the pattern is x{6,}|<[^<>]*>|\{\{[^{}]*\}\}|... unanchored, any placeholder-shaped SUBSTRING inside a match suppresses the finding. I ran these against the real scan and every one produced ZERO block findings while the clean control blocked correctly:

    • postgres://appuser:Str0ng<Pw>Value@prod-db.acme.com:5432/main — password with angle brackets, no block.
    • mysql://root:Pa{{ss}}w0rd99@10.2.3.4:3306/app — password with braces, no block.
    • postgres://appuser:realpwxxxxxx99@prod-db.acme.com/main — password with a 6-x run, no block.
    • Authorization: Bearer ghp_realtokenxxxxxx0123456789abcdef — bearer token with an embedded 6-x run, no block.

    The db-connection-uri case is the sharp one: passwords legitimately contain <, >, {, }, and a connection string is one of the most common ways a live secret lands in a transcript — exactly the input the session-observer gate must hold on. This defeats the file's own stated guarantee that block findings "refuse a publish in every mode and are never --yes-clearable". Root cause: this is the same substring-vs-whole-value class the PR already fixed for isPlaceholder/PLACEHOLDER_WORD (whole-value anchored, scan.ts#L380-L395, pinned at scan.test.ts#L227-L238) but did NOT fix for isDocsPlaceholder. Fix: don't let the docs-placeholder test suppress a block-tier finding, or apply it to the captured secret value anchored (as the per-detector skip handlers already do) rather than as a substring of the whole match. The existing coverage misses this because the placeholder-suppression tests only assert an all-x or all-Z body, never a real block token/URI carrying an embedded placeholder substring (

    describe('scan — placeholder suppression', () => {
    it('drops docs-shaped placeholder matches even for block-tier detectors', () => {
    expect(checks(`the docs show ghp_${'x'.repeat(36)} as the shape`)).not.toContain(
    'github-token',
    );
    expect(checks('API_KEY=<YOUR_KEY>')).not.toContain('secret-assignment');
    expect(checks('set the header to A1b2C3xxxxxx4D5e6F7g8H9i0J1k2L3m4N5o')).not.toContain(
    'high-entropy-string',
    );
    });
    it('drops emails on the RFC 2606 reserved domains, but keeps real ones', () => {
    expect(checks('sample payloads use user@example.com throughout')).not.toContain('email');
    expect(checks('escalate to alice@corp.example when paging')).toContain('email');
    });
    it('does not treat a boring key body as a placeholder (no block bypass)', () => {
    // Only `x` runs and template braces are placeholders; a repeated-letter body
    // is still a live key shape.
    expect(checks(`t=ghp_${'Z'.repeat(36)}`)).toContain('github-token');
    expect(checks(`0x${HEX64}`)).toContain('raw-private-key');
    });
    });
    ) — add the embedded-substring case as a regression pin with the fix.

Minor

  1. backend/agent-usability: safety-model and changeset overstate the block guarantee: docs/safety-model.md now says the scan blocks "connection URIs with an embedded password ... in every mode" (

    Publishing and editing put content on a public marketplace under your identity. The local scan blocks obvious secrets and private-key material in every mode: provider token shapes, private keys in and out of PEM framing, connection URIs with an embedded password, BIP-39 recovery phrases, and TOTP provisioning URIs. Its rules live as data in `src/lib/scan-rules.json`. Other sensitive context can only be warned about, so the agent still has to use judgment before publishing.
    ), and the changeset frames block-tier placeholder suppression as a clean feature. Both are downstream of the Major: until the substring bypass is closed, the doc claims a non-bypassable block the code does not deliver for bracket/brace/x-run passwords. Fixing the Major makes the docs true; no separate doc change needed if it lands.

  2. hygiene: source comments cite ephemeral review rounds: scan.ts#L299 and scan.ts#L343 carry (review r5) provenance in code comments (the invariant they explain is load-bearing; the round citation is not). Per the anti-slop provenance rule, keep the "why" and drop the round tag. (Test-name (review rN) tags are fine — they document which regression each test pins.)

Nits (1), none blocking
  • scan.ts comment density is ~28% (198/696 non-blank), above the 12–15% target. Most of it is genuinely load-bearing "why" for security regexes (ReDoS bounds, charset exclusions, span-alignment fixes), so this is a light note, not a demand — a pass could thin the narration around the straightforward handlers.
Verified, not issues
  • Token-charset detectors (aws, github, stripe, jwt, npm, etc.) are NOT bypassable via <...>/{{...}} because their charsets exclude those characters; the only vector into them is a 6-x run, which a real working key cannot be made to contain on demand and hits by chance at negligible probability. The db-connection-uri and bearer detectors are the real exposure (broad value charsets), which is why the Major centers on them.
  • ReDoS: guarded email/db-uri/internal-hostname patterns all bounded; budget test runs at 200KB single-line scale and passes fast; reproduced the guarded-vs-unbounded gap directly.
  • Redaction invariant: block excerpts masked (pem armor the one header-only exception), corpus test asserts no excerpt carries the labeled secret; the email-inside-db-uri suppression prevents a password leaking through the email excerpt.
  • BIP-39: whitespace-only 12-word run; 11-word and punctuation-broken prose are negatives. Residual false-positive risk on transcript-scale prose or a crypto tutorial that lists 12+ wordlist words is an owner-accepted, documented tradeoff (scan.ts comment) and fail-safe (blocks publish, never leaks) — not a defect.
  • Pin: a13e98e is an ancestor of head; scan-rules.json byte-identical since the pin, so tenjin#723's vendored copy is current.
  • Local run: scan.test.ts + scan.corpus.test.ts + skills-text.test.ts green (168 tests); CI green at head including pack-smoke and skill-drift.

Verdict: comments-only. One Major — the placeholder suppression is a substring test that silently converts a block-tier finding into nothing for connection URIs whose password contains <, >, {, }, or a 6-x run, defeating the non-bypassable-block guarantee on exactly the transcript-scale input this corpus is being built to gate; the same bug class the PR fixed elsewhere. Two Minors (a doc that overstates until the Major lands, review-round provenance in two source comments) and one density nit. Corpus, ReDoS, and licensing work are solid.

The docs-placeholder rule is a substring test over the whole match and ran
ahead of every detector, so any secret merely CONTAINING a placeholder shape
was suppressed instead of blocked. Real passwords carry `<`, `>`, `{`, `}` and
letter runs, and a connection string is one of the commonest ways a live
credential reaches a transcript, so this defeated the non-bypassable-block
guarantee on exactly the input the observer gate exists to hold.

Suppression may now downgrade a warn, never a block. Block-tier suppression
stays per-detector and anchored to the captured secret value, which is the
whole-value form the rest of the file already uses. Adversarial fixtures pin
all four reported bypasses plus a `your…`-label variant, in scan.test.ts and in
the corpus; they are red without the one-line tier guard.

An all-x body in a block-tier shape now blocks rather than suppressing, which
is fail-safe: the corpus sample moves from negative to positive.

Also drops two review-round tags from source comments, corrects the changeset's
claim that suppression covered the block tier, and tightens the module header.

Refs #183 review 4965955596.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Thanks — the Major is correct and it was the sharp one. Reproduced your four inputs verbatim against the scan before touching anything: all four returned 0 block findings, the clean control returned 1. Fixed in 3e19464.

DOWNSTREAM: tenjin#723 vendored this corpus byte-identical and needs a re-vendor. scan-corpus.json changed in this commit (5 new adversarial samples + 1 relabel). scan-rules.json did NOT change, so if #723 vendors only the rules it is still current; if it vendors the corpus too, it needs the sync. Flagging loudly so the ordering does not get lost.

Major — placeholder suppression bypassed the block tier. Fixed as you framed it: the substring test may now only ever suppress a warn.

  • isDocsPlaceholder is gone; isSuppressedAsDocs(detector, m) gates on detector.severity === 'warn'. The block tier keeps only its per-detector skips, which were already whole-value anchored on the captured secret (exampleDbPasswordisPlaceholder(m[3]), placeholderGroup1isPlaceholder(m[1])) — the same anchored form you noted the PR had gotten right elsewhere.
  • I took the "don't let it suppress a block" branch rather than the "anchor it to the captured value" branch. Anchoring would also close your four cases, but it leaves the invariant as something a future reader has to re-derive per detector. A tier rule is cheaper to hold than an argument about charsets, and the cost of dropping it is one fail-safe false positive on documentation.
  • That cost is real and I took it deliberately: an all-x body inside a block-tier shape (ghp_ + 36 x) now blocks. The corpus sample moved from github-token/neg-placeholder to github-token/pos-placeholder-body. Fail-safe direction — it refuses a publish, it does not leak — and the operator edits their example.
  • Pins added, red without the fix (verified by reverting the one-line guard: 3 tests fail, all 97 pass with it). scan.test.ts gains a scan — the block tier is non-bypassable block with your four inputs plus a your…-label variant (redis://cache:yourkeyR3al99Value@…) and the ghp_+x case, asserting a block fires and that its excerpt is still [redacted-masked. The corpus gains the same five as labeled positives, so precision/recall now covers this class too (126 samples).
  • Your point about the exposure being db-connection-uri and bearer specifically is right — the charset-limited detectors were only reachable via a chance x run. The tier rule closes both anyway.

Minor 1 — safety-model / changeset overstated the guarantee. Agreed, and as you said the doc becomes true once the Major lands, so docs/safety-model.md is unchanged. The changeset did need editing: it framed block-tier placeholder suppression as a feature. It now says warn-tier only and states the anchoring rule.

Minor 2 — review-round tags in source comments. Fixed. Dropped (review r5) at both sites, kept the invariant each explains (the locale-sensitive toLowerCase span misalignment, and the d-flag splice vs. first-substring masking). Test-name tags left alone per your note.

Nit — comment density. Partially taken. I tightened the module header, which was the one place genuinely duplicating what NOTICE.md and the data file's source fields already say, and folded the invariants into a labelled list (the new TIER ORDERING invariant is now stated there rather than only at the call site). Density moved 29.4% → 29.2%, so: mostly not taken, on purpose. The remainder is ReDoS bounds, charset exclusions, span-alignment fixes, and documented accepted-gaps — the "why" that a regex cannot carry and that this review cycle just demonstrated is expensive to rediscover. I would rather carry it than hit the ratio.

Gates on 3e19464: pnpm lint, pnpm typecheck, pnpm format:check clean; full pnpm test 2234 passed, 10 skipped (69 files passed / 1 skipped).

…log carries

An advisory replay of the deterministic tier over 389 published posts
(tenjin#723) found the block tier's only hits were three false positives, all
public hex constants: a committee hash with a word between label and value, an
EIP `source_id =` identifier, and the ERC-20 Transfer event topic0 in a code
sample. Roughly 0.8% of the real catalog would have been refused at publish.

The demotion to the warn hex32-value now recognizes an id-class label set
(salt, id, topic, root, digest, commitment alongside hash/tx/blockhash), a
label sitting up to two short tokens before the value, and a data list of
universal public constants. Every constant is computed, not transcribed: the
topic0s are keccak256 of the event signature and the EIP-1967 slots are
keccak256(label) - 1.

The floor holds. Demotion is to warn, never silence, so a real key mislabeled
`hash` still surfaces. An unlabeled bare 64-hex, a label four tokens out, and a
word merely ending in a label all still block, each pinned.

Bounding the lookback to 256 characters also closes a fourth quadratic: the
label test re-sliced the whole line per match, so a line dense with 64-hex
values took 18.6s. It now takes 63ms, with a ReDoS fixture pinning it.

Refs #183, tenjin#723 comment 5335652890.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Implemented the detector fix proposed in the pre-flip replay (tenjin#723, comment 5335652890 — 389 published posts, 3 block-tier hits, all false positives). Commit 1f066b5.

DOWNSTREAM: tenjin#723 needs re-vendor round 2 — and this one DOES touch the rules. Unlike the last round, scan-rules.json changed: a new top-level publicHexConstants array plus the raw-private-key description. scan-corpus.json also changed (6 new fixtures). Both files need the sync; the engine-pin drift check should force it.

What changed, against your three triaged FPs:

your finding why it missed now
committee hash, hash was 0x… demotion required the label adjacent label may sit up to two short tokens before the value
EIP source_id = 0x… id-class labels absent label set gains salt, id, topic\d*, root, digest, commitment
ERC-20 Transfer topic0 no label at all publicHexConstants data list, consulted by the hex64 detector

The floor holds, exactly as you scoped it. Demotion is to the warn hex32-value, never silence — a real key mislabeled hash still renders and still needs an ack under enforce. Pinned negatives: an unlabeled bare 64-hex blocks; the hash of the private key is 0x… blocks (four tokens is out of window — two is the bound that keeps it honest); the grid 0x… and a valid 0x… block (words merely ending in a label are not labels); PRIVATE_KEY=0x… blocks. Your 0xddf252ad…523b3ef is in the constants list and confirmed my computed value.

Constants are computed, not transcribed. Generated with viem: topic0s are keccak256(utf8(signature)), the EIP-1967 slots are keccak256(label) - 1. Twelve entries — ERC-20/721 Transfer + Approval, ERC-721/1155 ApprovalForAll, ERC-1155 TransferSingle/TransferBatch, Ownable OwnershipTransferred, WETH Deposit/Withdrawal, both EIP-1967 slots, keccak256(""), and the zero hash. Data, not regex, per the ask.

Coverage: the three catalog shapes plus a CREATE2 salt: case are corpus fixtures expecting hex32-value (warn), and the two floor cases are corpus fixtures expecting raw-private-key (block). Red without the fix, verified by reverting the widened regex and the constants lookup: 5 tests fail, 102 pass with it. The scan — the block tier is non-bypassable suite from the previous round stays green throughout — a placeholder-lookalike REAL secret still blocks.

Bonus, and it would have bitten you at scale: adding a dense-64-hex ReDoS fixture exposed a fourth quadratic. The label test re-sliced the whole line per match, so a line carrying 3,000 hex values took 18.6s. Bounding the lookback to 256 chars makes it 63ms (295x), with the fixture pinning it. This predates the widening — the narrower regex just hid it — so it was latent in what you replayed.

Gates on 1f066b5: pnpm lint, pnpm typecheck, pnpm format:check clean; full pnpm test 2239 passed, 10 skipped.

On the operator call in your comment: with this landed there is no ~0.8% FP left to accept, so holding for the widening should no longer cost anything.

The live hosted skill moved again tonight (the #178 multi-searchId wire
form), so the drift gate reddened on this branch, which merged main
before the move. Mirror-only: scripts/sync-skill.mjs writes
skills/tenjin/SKILL.md and nothing else, so the scan rule data, the
corpus, and the tenjin-publish detector lists are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@A1igator

Copy link
Copy Markdown
Contributor Author

Resynced the vendored hosted-skill mirror in b09a57f — the live tenjin.blog/skills.md moved again tonight (the #178 multi-searchId wire form, same drift main resynced at 576bd69), and this branch merged main before the move. Mirror-only: scripts/sync-skill.mjs writes skills/tenjin/SKILL.md and nothing else, so it never collided with the skills/tenjin-publish/SKILL.md warn-triage additions, and the scan rule data and corpus are untouched. Reproduced the gate steps locally (node scripts/sync-skill.mjs then git diff --exit-code -- skills/): clean. Full pnpm test 2239 passed, 10 skipped; lint, typecheck, format:check clean.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Post-b09a57f gap from the tenjin#723 catalog replay: the widened hex32 demotion still misses one real published post whose shape is the committee hash was `0xd4e6…` — a backtick sits between the label window and the value, and neither the intervening-token separator class nor the trailing class in HASH_LABEL_RE ([\s:=/,._-]) includes backticks or quotes, so markdown-formatted hash mentions stay block. The b09a57f fixture for this shape (hex32-value/pos-label-with-intervening-word) omitted the inline-code formatting the actual post carries. Proposal: add ` and straight quotes to both classes and extend that fixture to the backticked form (floor cases unaffected — the label set and two-token bound are unchanged). The tenjin port (BackTrackCo/tenjin#723) tracks this file by sha256 pin and will follow once it lands. Replay numbers: 389 posts, block hits 3 → 1 at b09a57f; this is the survivor.

… and value

The tenjin#723 replay re-run at b09a57f cut block-tier hits from 3 to 1. The
survivor is a real published post writing its hash as inline code — hash was
`0x…` — where a backtick sits between the label window and the value, and
neither separator class in HASH_LABEL_RE carried backticks or quotes. The
b09a57f fixture for that shape had dropped the formatting the post carries, so
the corpus agreed with the bug; it now keeps the backticks.

Only the two SEPARATOR classes gain ` ' " and the curly quotes. The label set
and the two-token bound are untouched, which is what keeps the floor: no amount
of punctuation tolerance can invent a label that is not in the set, so
PRIVATE_KEY=`0x…` in backticks still blocks, as does `the wallet key is "0x…"`.
Both are pinned, alongside the existing out-of-window and word-ending-in-a-label
cases.

Refs #183, tenjin#723 comment 5335789613.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Closed the last survivor from the replay re-run (comment 5335789613) in 68e6d2e. scan-rules.json changed, so advance the #723 sha256 pin to 68e6d2e.

The fix is separators only. Both classes in HASH_LABEL_RE gain `, ', " and the curly quotes. The label set and the two-token bound are untouched — which is exactly what keeps the floor, because no amount of punctuation tolerance can invent a label that is not in the closed set.

You were also right that the fixture was complicit: hex32-value/pos-label-with-intervening-word had dropped the inline-code formatting the real post carries, so the corpus agreed with the bug. It now keeps the backticks, and there is a second markdown-shaped positive ("id": "0x…").

Floor cases added, per your note that they should be unaffected — pinned rather than assumed:

  • PRIVATE_KEY=`0x…` → still blocks (raw-private-key + secret-assignment), as a corpus fixture and a unit test.
  • the wallet key is "0x…" → still blocks: quotes are separators, but key is not a label.
  • The existing out-of-window (the hash of the private key is 0x…), word-ending-in-a-label (grid, valid), and bare-unlabeled cases are unchanged and green.

Red without the fix, verified by reverting just the two character classes: 3 tests fail (the new backtick/quote test plus both corpus precision assertions), 103 pass with it. The scan — the block tier is non-bypassable suite stayed green throughout.

Gates on 68e6d2e: pnpm lint, pnpm typecheck, pnpm format:check clean; full pnpm test 2240 passed, 10 skipped. That should take your replay to 0 block-tier hits over the 389 — worth a confirming re-run before the flip.

@vraspar vraspar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: the block-tier bypass is fixed and pinned on the embedded-substring cases; the catalog-driven demotion widening holds its floor, with one label-breadth decision worth a look

Reviewed at 68e6d2e, delta over 8347a08. Three fix commits (placeholder-tier confinement, then two 64-hex-demotion widenings from the tenjin#723 pre-flip replay) plus a mirror resync. Re-verified by reproducing the round-1 bypass inputs and a fresh adversarial set against the built scan.

Resolved since round 1

  1. [Major] Placeholder suppression confined to the warn tier. isDocsPlaceholder is gone; isSuppressedAsDocs(detector, m) gates the substring test on detector.severity === 'warn', so the block tier keeps only its per-detector skips, which are whole-value-anchored on the captured secret. Re-ran the four round-1 inputs plus the all-x body against the fixed scan: the angle-bracket password, brace password, 6-x-run password, embedded-x bearer token, and ghp_ + 36 x all now BLOCK (5/5), and each block excerpt is redacted (no leak). Warn-tier suppression survives — the corpus precision/recall stays 1.0, so the FP-reduction purpose is intact — and the all-x-in-a-block-shape sample was relabeled neg-placeholderpos-placeholder-body (fail-safe: refuses a publish, does not leak). The regression pin scan — the block tier is non-bypassable covers the embedded-substring cases explicitly, not just all-x, and 3 tests go red when the one-line guard is reverted.
  2. [Minor] docs and changeset now state the truth. safety-model.md and the changeset now say "Warn tier only: the block tier stays non-bypassable, and its own suppressions are anchored to the captured value," and spell out that a password containing </>/{/}/an x run, an unlabeled bare 64-hex, and a secret-named assignment all still block.
  3. [Minor] Review-round provenance removed. Both (review r5) code comments are gone (zero remain in scan.ts).

New — the two catalog-replay commits (1f066b5, 68e6d2e)

Both are false-positive fixes derived from real published content: the tenjin#723 pre-flip replay over 389 published posts found 3 block-tier hits, all genuine FPs on public hex constants (an ERC-20 Transfer topic0, an EIP source_id, a hash was 0x… mention), then 1 survivor (a backticked `0x…`), then 0. The fixes widen the 64-hex→warn hex32-value demotion: the label set gains salt/id/topic\d*/root/digest/commitment, a two-short-token window between label and value, a publicHexConstants data list (12 keccak values, computed via viem not transcribed), and markdown/quote punctuation as separators. Verified the floor holds against the real scan: bare 0x…, PRIVATE_KEY=0x… (+ backtick form), "privateKey": "0x…", and the wallet key is "0x…" all still BLOCK, while legitimate public values (a tx hash, the ERC-20 topic0 constant) demote to warn. Demotion is to warn, never silence.

Decision (non-blocking, owner's call)

  1. The demotion label set now includes broad, innocuous words — id most of all — so a 64-hex mislabeled with one rides down to warn. Confirmed against the real scan: id = 0x<64-hex privkey>, salt: 0x<privkey>, digest 0x<privkey>, my backup id is 0x<privkey>, and key_id: 0x<privkey> all demote from raw-private-key (block) to hex32-value (warn). This is the same ambiguity-class tradeoff accepted in round 1 — a private key and a 32-byte public value are byte-identical on Base, so the scan can only guess from context — now with a wider label set. It is not a silent-leak bug: demotion surfaces the finding and requires an ack under enforce, the common accidental shapes still block, and the widening is evidence-driven from real FPs. The one thing worth a deliberate look is that id (and key_id, which the _-boundary catches) is common and innocuous enough that an accidental key-under-an-id-field leak — {"id": "0x…"}, key_id: 0x… — demotes to warn, and under the DEFAULT advisory mode a warn publishes with only a logged finding. Options to tighten: drop id specifically, or withhold the demotion when the value also sits in a secret-named / *_key context. Not blocking on it — the ambiguity is fundamental and round 1 accepted the principle — but it is the residue of the widening, so here is the concrete behavior.

Corpus / pin lockstep with tenjin#723

scan-rules.json changed in this delta (the new publicHexConstants array), and scan.ts changed (the Major fix plus the demotion widening). Note for #723: its drift check pins only the JSON data files, so advancing its sha256 pin to 68e6d2e syncs the data, but #723's hand-ported scan.ts must independently receive BOTH the isSuppressedAsDocs tier fix and the HASH_LABEL_RE/publicHexConstants demotion logic — exactly the "code outside the drift check diverges silently" gap from round 1. Verification of #723's copy happens in that PR's re-review.

Verdict

Approve. The Major is fixed at the tier boundary and pinned red-without-fix on the embedded-substring cases; both Minors are closed; the demotion widening is evidence-driven with the floor preserved and demotion never silencing. The one decision (label breadth, esp. id) is a considered tradeoff the owner may want to revisit, not a defect. Ran scan + corpus + skills-text suites (176 passing); CI green at head.

vraspar
vraspar previously approved these changes Aug 19, 2026
Comment thread skills/tenjin/SKILL.md
server-side only and NEVER returned in any response; set-once, so a later `PUT` may
set it while still unset (draft or already published) but never change it.
- `searchId` (uuid, or an array) is optional supply-loop attribution: pass the
`searchId` of an agent search that MISSED (above) when you publish the piece that

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rewrite it so it's not that long, seems like slight bloat and information can be condensed same with tenjin publish

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on both, but they land in two different repos, so this is half done here and half routed.

tenjin-publish (repo-owned, done in 4231f5a). The first triage list said "in a piece" six times to make one point; it now names the detectors and states the condition once. The second list's trailing sentence folded into its lead. 20 lines to 15, with every detector name intact so the skills-text.test.ts coverage guard still reads them (73 tests green). I also fixed a claim that was already false while it was shorter: it said rights and employer-internal content "have no detector at all", which was untrue of paid-content-marker and confidential-marker, and the blocking tier now mentions seed phrases.

This file (skills/tenjin/SKILL.md:219) I cannot touch. It is the drift-enforced vendored mirror of tenjin.blog/skills.mdscripts/sync-skill.mjs overwrites it verbatim and skill-drift.yml runs git diff --exit-code -- skills/, so any edit here reds CI and is reverted by the next resync. Line 219 sits inside the searchId bullet, which is exactly the block that drifted in tonight, so the bloat is upstream content.

The edit belongs in the tenjin repo, at lib/agent-docs.ts lines 1842-1846 on origin/main (inside renderSkillMd, a template literal served per request by app/skills.md/route.ts — no .md file involved). Proposed condensation, 5 lines to 3:

- `searchId` (uuid or array) is optional attribution: name the agent search that
  MISSED (above) and that this piece answers. Ids accumulate — a later `PUT` adds
  and never removes, at most 10 per piece. Stored server-side, never returned.

Two notes for whoever picks that up: renderLlmsFullTxt carries a second, longer copy of the same rule around line 866 that wants the same pass, and lib/agent-docs.test.ts pins /claims\s+accumulate/i, /10\s+per\s+piece/i and /array/i on the MCP tool descriptions (not on this prose bullet), so the wording above stays inside those constraints. Once it ships, pnpm sync:skill pulls it down here automatically.

Gates on 4231f5a: lint, typecheck, format:check clean; full pnpm test 2240 passed, 10 skipped; drift gate reproduced green with the mirror untouched.

Drops the per-detector genre examples from the first triage list, which
repeated "in a <kind> piece" six times to say one thing, and folds the
second list's trailing sentence into its lead. Every detector name the
skills-text coverage guard reads is unchanged.

Also corrects the intro while it is shorter: it claimed rights and
employer-internal content "have no detector at all", which was already
untrue of paid-content-marker and confidential-marker, and it now names
seed phrases in the blocking tier.

Refs #183 inline comment 3809528654.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator A1igator added priority: high High priority and removed priority: critical Blocks other PRs; merge-priority labels Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: high High priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish safety follow-up: RFC1918 / collaboration-URL / cloud-ARN warn checks

2 participants