Skip to content

Redaction: one shared list of token shapes, prefixed dashboard keys, no own redactors (#889) [PROTECTED: owner merges] - #921

Merged
jayden1711 merged 14 commits into
mainfrom
issue-889
Oct 21, 2026
Merged

jayden1711 merged 14 commits into
mainfrom
issue-889

Conversation

@jayden1711

@jayden1711 jayden1711 commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Intent: one shared redactor knows every credential shape the engine handles, and nothing else in the engine redacts tokens on its own. A Discord bot token passed through redactString unredacted (the owner's scoreboard, #879 round 2), because its pattern lived only in src/watchdog/incidents.ts; Tailscale keys had the same split in src/tailscale-setup.ts. Round 2 added the owner's two decisions: dashboard keys get a prefix so they are always redacted, and the three modules that kept their own token rules (gh-login, the bench, the root updater) go through the shared redactor, with no exception list. Round 3 fixes what the second adversarial review and the round-2 final check found. That covers the escape-glue class, private keys failing closed, Basic credentials, and a regression in the updater's broad masking, now fixed by design with union-of-spans masking. Round 6 does the same for the default mode: every rule reads the original text, so no rule's match can hide another's (the delta checks' regression against main).

Risk class: protected. It changes src/** (engine), src/events/redact.ts (locked: "sign-in, credentials or secrets" in src/review.ts), src/merge-policy-weakening.ts (a guard file), and scripts/machine/<cli>-update.cjs (the root updater), and redaction is in the guard set.

Closes #889

Round 8 (head 1356f047)

What changed (the round-7 second review, issuecomment-6320305706; the final check passed, issuecomment-6320430782):

  1. Long-scheme URL passwords (a regression, measured). Both URL rules now read the scheme's last 1 to 32 characters ([a-z0-9+.-]{1,32}://, case-insensitive, from any start). They are the default password in a URL rule and the broad user info in a URL rule.
    • Round 4's [a-z][a-z0-9+.-]{0,31} needed a letter within 32 characters of ://. So x + 40 digits, git + 33 - and svn+ + 32 . + +0 hid the password from default, broad, the updater and secretKinds, where main's rule found it.
    • The new rule is still linear: each start reads at most 33 characters.
    • redact-default-union gains a long-scheme shape.
  2. The Tailscale login link (a regression, measured). src/tailscale-setup.ts replaces the link before redactString, as main did. A token glued before it (tskey-…https://login.tailscale.com/a/<id>, the same with ghp_ or wld_) took in the https once redacted, and the link was shown.
  3. Helper tables marked.
    • GLUED_PREFIXES, KEY_QUALIFIERS and CREDENTIAL_ENDINGS carry // check-table: never-shrink. So do the glue sequences, now a marked array, GLUE_PARTS (with GLUE built from it). The generator carries the marks into the updater's copy.

    • SHAPES gains a plain sk- key (sk-proj-…). The glued-shape loop over SHAPES in redact-records-keys now drives a plain sk- key glued to a name; dropping 'sk-' from GLUED_PREFIXES would fail it (inferred). The mark holds that edit as well (measured, below).

    • The review's 7 one-line narrowings, fed in memory to weakeningReasons at this head (measured):

      Edit Result
      GLUED_PREFIXES drops 'sk-' held
      KEY_QUALIFIERS drops api held
      CREDENTIAL_ENDINGS drops accesskey held
      DASHBOARD_KEY_BODY {32,} → {33,} not held
      basicShaped 16 → 24 not held
      credentialShaped 32 → 40 hex not held
      CUT_MARGIN 16 KB → 1 KB not held
      (extra) a GLUE_PARTS entry dropped held
      (control) a PATTERNS line narrowed held

      The four scalar and predicate helpers are named under "Someone with push access" as held only by the file's lock and the tests. Add-only narrowing is weakening hold: add-only narrowing of a marked lint or guard passes (proposal; owner decision) #992 and is left out.

  4. Short Basic values. basicShaped also takes a value that decodes (atob) to printable ASCII with a : in it. That covers base64 of a short user:password with no digit, +, / or = (user:pass, admin:hunter, bot:Secretpw, deploy:abcdefghijk). Prose doesn't decode that way: "Authentication" decodes to bytes that aren't printable text, and "Authorization" doesn't decode at all (measured). "Basic Authentication", "Basic Authorization header" and "the basic credentials flow" are left as they were (tested).
  5. Merged origin/main cec056a5 (tool guard: protected-path checks decide on the file a write really reaches; redact secrets in keys [PROTECTED: owner merges] #976's redactKeys, Setup: the machine helper's sudoers rule follows the engine, rewritten on its own only when it narrows; --replace-sudoers for the rest (#898) [PROTECTED: owner merges] #902). There were no conflicts. The updater's copy is regenerated (node scripts/gen-redact-vendor.mjs --write), and redact-vendored passes. test/redact-default-union.test.ts's main baseline is untouched.
    • Registry probe (measured): 107 registrations at this head, 0 lost against origin/main cec056a5 (82) and against fa1318fd (95).
  6. Body:
    • Under Compromised component, the lint test is now described by its // lint-test mark, not LINT_TESTS.
    • Round 7 test literal change: test/redact-declined.test.ts's dotted prefixes token=a. and sess_0123456789. became id=a. and ['sess', '0123456789.'].join('_'). gitleaks's generic-api-key rule had flagged the commit, and the push gate refused it. The cases still cover a separator before the dotted name, and the same sess_0123456789. text at run time.
    • The URL-rule claims ("same or a broader rule", "Root updater") are corrected.

The routes the review found open on main are #996 and are not folded in here.

Fail-without (measured): I wrote new test/redact-round8.test.ts with Write into a fresh worktree at fa1318fd (sha256 identical). 4 of 4 fail there:

  • the long-scheme URL passwords in default, broad, the updater and secretKinds;
  • the Tailscale link glued after tskey-, ghp_ and wld_;
  • the short Basic values;
  • the missing marks and the glued plain sk- key.

Checks (measured, at 1356f047):

  • 64 named test files (every one that mentions redaction, except coordinator.test) plus the changelog tests: 928 tests, 927 pass, 0 fail, 1 skipped.
  • typecheck clean; check:denylist clean (1,492 files); the push gate clean (14 commits; gitleaks clean).
  • False positives against fa1318fd: 1,289 files and 274,866 lines of the repo's own text, line by line and as whole files, default and broad. One line changes: the round-8 test's own long-scheme fixture.

Size (measured, against origin/main cec056a5): tarball +10,941 (1,511,841 → 1,522,782), unpacked +33,339. Within the budget (2,050,000).

Round 7 (head fa1318fd)

What changed (the round-6 second review, issuecomment-6311072962, and the round-6 delta, issuecomment-6311512168):

  • Unsigned and +//-led JWTs (required 1, measured regression against main), fixed by design:
    • The cause, at 1eef3625: an unsigned JWT (alg: none, an empty third part), or one whose signature starts with + or /, written after a dotted name (a.<jwt>., sess_0123456789.<jwt>., api.example-service.<jwt>.) was shown in default mode, broad mode and redactCut, and secretKinds returned [].
    • Three things combined: the run's tail needed non-empty parts, jwtIn needed a third part, and when the function declined the match, the 64-character restart stepped past the JWT's own start.
    • The JWT rule declines nothing now. It is a plain replacement ($1$2[REDACTED JWT]) that starts only at a part holding eyJ: a lookahead looks for eyJ inside the part and can't leave it, so each part is read once. It steps over the header and payload whole (a lookahead and a backreference), and takes any further dotted parts, empty ones too. jwtIn is gone.
    • The class, for every rule: a match a rule's function declines now restarts the search at its next character (spansOf), so no start inside it is skipped. test/redact-declined.test.ts covers it:
      • it lists every function-backed rule (Stripe, JSON key, Basic, the orphan END) and fails when a new one isn't listed;
      • it holds each to a reference that tries every start;
      • it times 1 MB of each rule's declined text, default and broad.
  • Basic (item 4): the value now ends where its base64 run and padding end, whatever is glued after it. Before, Basic <value>==<more> was found only in a later pass, after another rule's marker made the boundary; broad mode, which had masked that rule's text and with it the word Basic, showed the value. 0 such cases since (measured below).
  • The passes fail closed (delta item 2): fixedPoint runs each mode over its own output until it changes nothing, at most MAX_PASSES (8) times. A text still changing after that becomes [REDACTED: too large to scan]. Before, each mode stopped after 3 passes and returned that output, whatever a 4th pass would have found. No input measured needs a 3rd pass (below).
  • Merged origin/main 40ef312a (it holds Merge policy: a check table or lint test registers itself where it's declared; only removing or loosening one waits (#869) [PROTECTED: owner merges] #886). src/merge-policy-weakening.ts is now main's byte for byte (sha256 identical, measured), resolved with Edit as the owner approved. Each table Redaction: one shared list of token shapes, prefixed dashboard keys, no own redactors (#889) [PROTECTED: owner merges] #921 registered is now a mark in its own file, none dropped:
    • src/events/redact.ts: PATTERNS, BROAD, CREDENTIAL_WORDS, BROAD_RULES.
    • The updater's copy: the transpile drops comments, so the generator (scripts/lib/redact-vendor.mjs) puts each of the module's marks back before the same table in the copy.
    • CUT_SHORT, SHAPES and BAD_SOURCES are never-shrink. GOOD_SOURCES is never-grow. test/redaction-one-module.test.ts carries the lint-test mark.
    • test/redact-default-union.test.ts: MAIN_PATTERNS, CREDENTIAL_WORDS, KEY_QUALIFIERS, CREDENTIAL_ENDINGS (delta item 3).
    • Measured with the registry probe (central lists plus marks, issue-807-r4/registry-probe.mjs): 95 registrations at this head, 0 lost against origin/main (78), 1eef3625 (87) or the first round-7 commit (89).
  • redact-default-union adds an unsigned JWT and a +-led one to its shapes, and x. and a.b. to its separators.

Fail-without (measured). Two new files were written with Write into a fresh worktree at 1eef3625 (sha256 identical to the head's). 4 of 6 fail there.

  • redact-declined:
    • The unsigned/+-led JWT cases fail in all four paths.
    • The function-backed rule list fails (at 1eef3625 the JWT rule had a function).
    • The 1 MB timing guard passes there too.
  • redact-fixed-point:
    • fixedPoint/MAX_PASSES are missing, so the bound and the "more than 3 passes" tests fail.
    • The idempotence guard passes there too.

Re-measured (measured):

Script at 1eef3625 at this head
Review's a1-jwt-unsigned (252 cases) default 180, broad 150, redactCut 180 shown; secretKinds finds 0 0 / 0 / 0 shown; secretKinds finds 252
jwtfuzz, extended with empty and +//-led signatures, 100k 7,990 shown; secretKinds misses 9,661 0 / 0 (broad shows what default hides: 0)
Delta's unsigned.mjs (7 cases) 5 shown all hidden, default and broad
Delta's compose, 300k 0 narrowing, 0 not idempotent
Delta's idem, 300k 0 not idempotent, default and broad
Review's a2-chains, 20k / tight 30k broad shows what default hides: 1 / 5 (Basic; measured with round 7's JWT fix but before its Basic fix) head shows what main hides: 0 / 0; broad shows what default hides: 0 / 0; secretKinds loses a main kind: 0 / 0

Time (measured). All of it is linear. Default mode costs a constant factor more than main's sequential replace.

  • The review's private-use-plus-escapes input, default mode: 468–510 ms at 1 MB in my runs, 535–621 ms in the review's. At 0.5 / 1 / 2 MB: 203 / 437 / 925 ms.
  • Default mode on ESC[ at 1 MB: 288–697 ms, depending on the run and the machine's load (the delta's figures).
  • perrule1mb: no single rule takes over 500 ms. Broad mode reaches 1,199 ms at 1 MB (AKIA).
  • A 50 MB run of escape sequences with no token prefix (ESC[, or \n as two characters) becomes [REDACTED: too large to scan] after 16–20 s. Main returns it unchanged in 0.2 s (the delta's escbig.mjs). It fails closed, but it is a new placeholder trigger. At 5, 10 and 20 MB the text is returned unchanged.
  • The private-key rule is linear at this head: 8 MB of repeated BEGIN markers in 186–593 ms (the delta's pem.mjs). Main's rule is quadratic there. Receipts: signed, chained merge receipts anyone can verify offline (#717 slice 1) [PROTECTED: owner merges] #918 relies on this.

Tests and checks (measured, at fa1318fd):

  • 61 named test files (every one that mentions redaction, except coordinator.test), plus the 3 changelog tests: 922 tests, 921 pass, 0 fail, 1 skipped.
  • coordinator.test.js times out at 200–250 s in my scratch environment, and it does the same on main d2a549f4 and at 1eef3625 (measured). So it is not from this change. CI runs it.
  • typecheck clean; check:denylist clean (1,453 files); check:push clean (12 commits); gitleaks on the new commits clean.
  • The updater's copy is regenerated and current (redact-vendored passes).

Size (measured, against origin/main 40ef312a): tarball +10,695 (1,497,408 → 1,508,103), unpacked +32,254. It is within the budget (2,050,000).

Round 6 (head 1eef3625)

What changed (the round-3+4 delta check, issuecomment-6309685989, and the round-5 delta check, issuecomment-6309967139):

  • Default mode is order-free (required change 1). redactString with no options (events, comments, the digest, the dashboard) used to apply the shapes one after another. A match could take in the text the next rule starts from, so the next rule never saw it: wld_<key>-redis://u:<pw>@…, <jwt>.Bearer <tok>, xoxb-…-lin_api_<key>, tskey-…-Bearer <tok>.
    • Now every shape runs on the text as it was, and again from each glue point. Each run of overlapping spans is replaced once. Then the whole thing runs again on the result, up to 3 passes, like broad mode.
    • A span that covers its whole run keeps its rule's marker; when several do, the first rule's marker wins. A run of spans that only overlap becomes [REDACTED].
    • The one visible change is to markers: a bearer value that is an sk- key now shows as Bearer [REDACTED], where it was Bearer sk-[REDACTED]. That is the only line in the repo's own text whose output changed (below).
  • The orphan-END rule (the start of the text up to an END line) now decides in a function: an END line counts only when no BEGIN opened it. The rules now read the text with every closed key still in it, so a closed key's END must not count as an orphan.
  • JWT (round-5 delta item 1): the rule tries every dotted part of a run as the header. Round 5 tried only the first part: abcdefghijk.<jwt>, api.example-service.<jwt> and sess_0123456789.<jwt> were shown, though main hides each.
    • A lookahead and a backreference step over the first two parts whole. So a plain run of millions of characters (no prefix, no dot) is passed in one step: no RangeError, and the text is left as it was, as on main.
    • Before this, round 5 turned a plain run of about 5.3 MB or more into the placeholder.
  • secretKinds (round-5 delta item 3) catches V8's RangeError and returns the kind text too large to scan. So a chat attachment like that is refused with 409 until the person attaches it anyway. Before, it threw; on main it threw too, for a prefixed run.
  • The duplicate doc comment in src/dashboard.ts is gone. test/redact-default-union.test.ts's fixtures (MAIN_PATTERNS, CREDENTIAL_WORDS) are registered never-shrink.

Fail-without (measured). I wrote two new files with Write into a fresh worktree at a5c86ec2 (sha256 identical to the head's). 6 of 6 fail there.

  • test/redact-default-union.test.ts keeps main's PATTERNS (d2a549f) verbatim as a fixture, applied as main applied them. Its 3 tests fail at a5c86ec2:
    • Every pair of 18 full-length shapes, joined by each of 16 separators, bare and inside text: 40 or more narrowings at a5c86ec2 (the list is capped at 40).
    • The delta checks' 15 cases (the 8 composition cases, 2 dotted-name JWTs, and the round-5 delta's 5 JWT cases): all 15 shown.
    • 40,000 generated fragments and 40,000 generated whole tokens: 20 or more narrowings (capped).
  • test/redact-unscannable.test.ts, 3 tests:
    • secretKinds throws RangeError;
    • the attachment throws RangeError instead of 409;
    • a 6 MB plain base64url run comes back as the placeholder, not as itself.

The delta checks' fuzz, re-run (measured). I used copies of fuzz.mjs, gen.mjs and jwtfuzz.mjs, with only the worktree paths changed. Main is at d2a549f4; each cell is narrowing hits.

Generator at a5c86ec2 at this head
fuzz frag default, 1.5M 813 0
fuzz real default, 1.5M 2,750 0
fuzz frag+ default / real+ default, 1.5M each 0 / 0
gen sep, 300k 2,338 0
gen glue, 300k 0
jwtfuzz, 100k (head default shows what main hides) 7,684 0
jwtfuzz, 100k (broad shows what default hides) 0
updater frag / real, 1.5M each 0 / 0
bench frag / real, 1.5M each 0 / 0
gh-login frag / real, 1.5M each 0 / 13
gh-login with main's rule and no cut (gh-login-rule), frag / real 0 / 0

The a5c86ec2 column reproduces the delta checks' counts exactly.

gh-login's 13 (measured, then inferred): not a narrowing.

  • Main and this head both redact gh's last line first, then cut it to 200 characters (safeLine; main: line.replace(TOKENISH, '[hidden]').slice(0, 200)).
  • In each of the 13, the "secret" shown is a fuzz word that no rule on main matches, such as Mn3Bv4Cx5Zl6Kj7H standing alone. Main's line was longer, because it masked less, so the word fell past character 200. This head masks more, so the line is shorter and the word lands inside the 200.
  • With main's rule and no cut (gh-login-rule), the head shows 0 secrets main's rule hid. So nothing to fix.

Linear time on 1 MB inputs (measured). I ran the delta check's perrule1mb.mjs at this head: 90 hostile inputs of 1 MB, 37 rules, each with plain replace and with the 64-character restart.

  • No single rule takes over 500 ms.
  • The whole redactor in default mode, identifier mode and redactCut: none over 500 ms.
  • Broad mode: 7 over 500 ms, the slowest AKIA at 1,128 ms (the round-4 delta measured eyJ at 1.78 s).
  • Scaling at 0.5 / 1 / 2 MB is about 2x per doubling for each of the 14 slowest inputs. Default AKIA: 176 / 370 / 719 ms. Broad eyJ: 603 / 1,412 / 2,540 ms. Default abcdefghijk. (a long dotted run through the new JWT walk): 44 / 80 / 175 ms.
  • Default mode is slower than round 4 by a constant factor: about 4x on an AKIA run, 96 → 370 ms at 1 MB. It reads every rule on the original text and from each glue point, where round 4 read only from the glue points on its output.

Tests and checks (measured):

  • 59 named test files, every one that mentions redaction (among them chat-attachments, merge-policy and the two new files): 1,004 tests, 999 pass, 0 fail. The other 5 are neither (skipped or todo; not inspected).
  • The changelog tests pass 39 of 39.
  • typecheck clean; check:denylist clean (1,422 files); check:push clean (10 commits).
  • The updater's copy is regenerated and current.
  • git merge-tree with origin/main 9f9533aa: clean.

False positives against round 5 (a5c86ec2) (measured):

  • Markdown: 0 of 7,361 lines newly changed.
  • Code: 1 of 273,745 lines, test/setup-steps.test.ts:77, a stand-in. It is hidden both ways; only its marker changes, from Bearer sk-[REDACTED] to Bearer [REDACTED].
  • Whole files: 1 of 1,241 differs, the same setup-steps line (3 characters fewer).

Size (measured, against main d2a549f4): tarball +10,721 (1,481,905 → 1,492,626), unpacked +32,996.

Round 5 (head a5c86ec2)

What changed (the fourth adversarial review, issuecomment-6309389199):

  • A cut site never shows the raw end of its read window (A1, A2). New readWindow(s, end) ends a window at its last whitespace, or drops it when it has none.
    • redactCut uses it for its max + 16 KB window. That covers decisionDiff, a PR section and a chat attachment.
    • The dashboard's readPart uses it for a log record's 6,096 characters.
    • Before, redaction earlier in a window could shrink the text by more than the margin: an END line with no BEGIN, or a closed key over 16 KB. Then the window's raw end, a token cut too short for its rule, landed inside what is shown. No token, URL user info or credential value holds whitespace, so ending at one drops the cut run whole. A PEM key cut this way still fails closed by its BEGIN.
  • JWT headers of any length (A3): a JWT is read from the start of its header's run of base64url characters, with no bound, and a function finds eyJ in that run. So a long header, such as an x5c chain, glued after a letter, a digit, - or %3D, is found. Each start is a run's start, so this is linear.
  • A text that can't be scanned fails closed (A6, the follow-up):
    • redactString catches V8's RangeError on a run of millions of characters and returns [REDACTED: too large to scan], keeping the line count for redactRecords.
    • Before, it threw: on main too, through lastLines and the updater's redactDeep.

Fail-without (measured). New test/redact-window-edge.test.ts, written with Write into a fresh worktree at 88ec30b7 (sha256 identical to the head's): 5 of 5 fail.

  • redactCut: 18 of 18 cases leak. That is a dashboard key, a ghp_ token and a URL password, after an orphan END and after a closed key over 16 KB, at max 2,000, 20,000 and 512 KB.
  • The journal and file log views: 12 of 12 cases leak.
  • readWindow is missing.
  • The JWT: headers of 600 and 3,000 after x, 7, -, %3D, default and broad: 16 leaks.
  • A 6 MB run throws RangeError.

After (measured):

  • The new file passes 5 of 5.
  • The review's edge.mjs, pointed at this head (a scratch copy): 0 of 30 leak (5 shapes × 2 shrinkers × 3 sizes).
  • 481 of 481 pass across 43 named test files, and the changelog tests 39 of 39.
  • typecheck clean; check:denylist 1,420 files clean; check:push clean; the updater's copy regenerated and current.
  • The fragment fuzz against main's three module rules: 300,000 inputs per piece set, 0 narrowing hits.

False positives, re-measured (measured).

  • Against round 4 (88ec30b7):
    • markdown: 0 of 7,361 lines newly changed;
    • code text: 0 of 273,401 lines;
    • whole files: 0 of 1,239 differ;
    • broad mode on the markdown: unchanged.
  • Against main: the markdown is unchanged. The 12 whole files that differ are the same stand-in fixture files as before.

Size (measured, against main d2a549f4): tarball +9,441 (1,481,905 → 1,491,346), unpacked +28,692.

Round 4 (head 88ec30b7)

What changed (the third adversarial review, issuecomment-6308839848):

  • Escape glue as a class, by design (R1–R4, R12 partly). Every shape with a distinctive prefix is matched with no left boundary, in default and broad mode, so nothing glued before it can hide it: not a name, an escape in any encoding, a digit or a letter. That covers gh[pousr]_, github_pat_, sk-ant-, xox[abprs]-, xoxe, xapp-, lin_api_/lin_oauth_, AKIA/ASIA, AIza, eyJ, wld_ and tskey-. For eyJ the header is bounded to 512 characters, so each start reads a bounded stretch. Only the shapes with no distinctive prefix keep a boundary: a Discord bot token, Bearer, Basic, Stripe, 0x, a plain sk- and a JSON value. For those, the glue points now take:
    • ESC in every encoding: the byte, the JSON escape (a backslash and u001b), \x1b, \033, \e and ^[;
    • each ESC sequence form: CSI with parameters, nF such as (B, and one character such as 7, 8 or c;
    • the 8-bit CSI 0x9B;
    • JSON and C backslash escapes.
  • Bearer and Basic match in any case in default mode (/i; R9).
  • The default URL-password rule is linear (R6, R8): it takes a scheme of up to 32 characters from any start. It was \b and of any length, which was quadratic. test/redact.test.ts adds long dotted runs (ab., ab-, ab+, a dotted run ending in ://) to its 100 KB inputs, and its KNOWN_SLOW exemption for that rule is emptied, so the rule is now timed like the rest.
  • Each cut site redacts only its window (R6): new redactCut(s, max) redacts the first max + 16 KB and cuts to max.
    • Sites: decisionDiff (512 KB of up to 8 MB), a PR section (20,000), a chat attachment (room), and the dashboard's log records. Each journal entry or file line is read only to 2,000 + 4,096 characters before redactRecords, and clip uses the same window.
    • A key across a window's end still fails closed, through the BEGIN-without-END rule (tested).
  • outside() is linear (R7): spans are merged and sorted, then the masks are subtracted in one walk.

Fail-without (measured). New test/redact-escapes-windows.test.ts, written with Write into a fresh worktree at d0f4ab94 (sha256 identical to the head's):

  • 3 fail: R1–R4 escapes on 5 paths; distinctive prefixes after 7, x, Z, _, %5F, &#95; or m; Bearer/Basic in any case.
  • 2 did not finish in 280 s and were killed (timeout exit 124): the linear-time test (400 KB dotted runs and mask words, default and broad), and the window test (PR section, chat attachment, the journal and file views with 300 entries of 48 KB, and a 1 MB diff through decisionDiff).
  • 1 passes at d0f4ab94 too: a key cut at a window edge. It guards the new windowing.

After (measured). On 88ec30b7, the new file runs in 2.5 s. The review's own scripts (scratchpad adv-921), run against this head:

  • attacks.mjs: every escape form is ok on all 5 paths. What still leaks is listed under "What remains".
  • diffperf.mjs: 33 ms at 512 KB and 545 ms at 8 MB for the whole diff; the site now reads only its window.
  • logperf.mjs: 300 entries of 48 KB take 281 ms (was 7.5 s for 10).
  • perf.mjs at 400 KB: the slowest case is 134 ms.

Tests:

  • 476 of 476 across the 42 named files: the 41 from round 3 plus redact-escapes-windows.
  • The changelog tests: 39 of 39.
  • typecheck clean; check:denylist 1,419 files clean; check:push clean; the updater's copy regenerated and current.
  • The final check's fragment fuzz against main's three module rules: 600,000 inputs per piece set, 0 narrowing hits.

False positives, re-measured (measured):

  • Against round 3 (d0f4ab94): 0 lines newly changed in the markdown (56 files, 7,361 lines); 0 in all code text (1,212 files, 273,215 lines); 0 of 1,238 files differ as one text; broad mode on the markdown is unchanged (16 lines, 0 differ). So the anchor-free prefixes changed nothing in the repo's own text.
  • Against main (d2a549f4): 0 lines newly changed in the markdown. Code text changes only at stand-in keys in tests and CI fixtures, plus this PR's own comment. That is the same set as rounds 1 to 3: 12 files, all of them fixtures.

Corrected claims (principle 5):

  • Bearer: before this round, "Bearer matches any case" held only for three spellings in default mode. It is now true.
  • Escapes: the claim is restated as the scope above.
  • Private keys: now "PEM and PGP" (formats below).
  • Restart: "within 64 characters" is now stated.
  • Linear on hostile input: now measured, for the rule set and each window.

Round 3 (head d0f4ab94)

What changed

  • The broad mode masks the union of every rule's spans (maskUnion in redact.ts). This is what the final check required: the updater's redact() hid less than main's on three classes. Before, gh-login, the bench and the updater applied rules in turn, so one rule's marker could hide what the next would have found. Now:
    • Every rule (PATTERNS, BROAD) runs on the text as it was, and each match's secret span is kept.
    • Two more reads add spans:
      • one with a boundary marked at each glue point (see below);
      • one with a boundary where a span begins or ends between two word characters. This emulates the boundary a module's own rule, applied in turn, saw after an earlier replacement.
    • Each read restarts 64 characters before a match's end, so a value that holds the next secret's name within its last 64 characters doesn't hide that next match. A name further back (for example after a separator of more than 64 spaces) is missed, as main's in-turn rules missed it too (R11, measured).
    • The union is masked once, and the result is read again until it settles (at most 3 passes). A mask already in the text adds nothing.
  • Rules added or changed to close the regression classes:
    • The updater's own token rule on main is now one of the broad rules, so its spans and boundaries count.
    • The URL user-info rule takes a scheme of up to 32 characters from any start (main's started at any \b, any length). It finds -https://…, +git+https://… and .https://…, and stays linear.
    • A JWT, in default and broad mode, takes its further dotted parts, so a 5-part JWE or <jwt>.<suffix> is masked whole.
  • Glue class (A9, A10): a token right after an ANSI escape sequence (ESC[1m) or a backslash escape in raw JSON or C-style text (\n, \t, \", \uXXXX) is found. In default mode, a second pass runs with a boundary mark (a private-use character the text doesn't hold, removed afterwards) after each escape and after _ before a known prefix. In broad mode the same points feed the union. The first pass is unchanged.
  • Private keys fail closed (A1, A3, A4, A1c):
    • A BEGIN line with no END after it redacts to the end of the text.
    • An END line with no BEGIN before it redacts from the start of the text, for example the updater's 30-line journal tail of a 50-line key.
    • PGP's PRIVATE KEY BLOCK is covered.
    • The dashboard views, the updater and the bench get this through the shared module.
  • HTTP Basic credentials (A5): Basic <base64> is redacted when the value is credential-looking (a digit, +, / or =, or a long mixed-case run of a base64 length), plain or in a JSON header. Prose such as "Basic Authentication" or "basic configuration" stays.
  • Smaller ones:
    • A dashboard key after - or a letter is found: the pattern has no start anchor now.
    • A token followed by _ is found in default mode (ghp_<36>_backup): the GitHub, Slack, Linear, AWS and 0x patterns end at "no letter or digit next", not at \b.
    • src/decision-detail.ts (both sites) and src/chat-attachments.ts redact before they cut, as clip() does.
    • doctor checks the hub's dashboard key too: its own state dir's, and the viewer user's when readable.
  • The bench: format characters (zero-width, bidi) are dropped before redacting, so they can't split a token. Control characters are turned into spaces after, so the redactor sees an ANSI escape and finds the token glued to it. Its email rule reads a [redacted] mask inside an address.

Fail-without (measured). The 2 new files (test/redact-fail-closed.test.ts, test/redact-broad-union.test.ts) were written with Write into a fresh worktree at round-2 head 8633356d, sha256 identical to the head's. 11 of 14 fail.

  • Fail: the updater generated comparison; the final check's fragment fuzz; the three regression classes; the glue test on every path; key without END; key without BEGIN; PGP/OpenSSH/encrypted keys; Basic; dashboard key after - and ghp_…_x; cut-then-redact sites; doctor's hub key.
  • Pass at 8633356 (expected):
    • the gh-login and bench generated comparisons, whose round-2 narrowings came only from tokens glued without a separator, which the fragment fuzz catches;
    • the linear-time guard.

Regression check against main's own module rules (measured). test/redact-broad-union.test.ts keeps main's (41f6dd42) updater redact(), gh-login TOKENISH and bench TOKEN_SHAPE/email rules verbatim as fixtures.

  • On every input, no secret that the old module hid may be shown by the new one.
  • In CI: 6,000 generated inputs per module plus 12,000 fragment-fuzz inputs.
  • Locally I ran more: 60,000 per module on two other seeds (REDACT_FUZZ_N, REDACT_FUZZ_SEED). I also ran the final check's own fragment fuzz (both piece sets, 1.5M inputs each) against the three modules: 0 narrowing hits. At 8633356d the same fuzz gives 448 and 37 hits.

Tests after (measured):

  • 470 of 470 across 41 named test files: the 38 listed under "How it was verified" plus redact-fail-closed, redact-broad-union, chat-attachments and decision-detail. Head merged with origin/main d2a549f4.
  • typecheck: clean. check:denylist: 1418 files clean. check:push and check:dco: clean.
  • The updater's copy was regenerated with node scripts/gen-redact-vendor.mjs --write and is current.

False positives (measured, round 3 vs round 2):

  • Default mode, every line of the repo's markdown (53 files, 7,325 lines): 0 newly changed.
  • Every line of src, web/src, scripts, test, .worklane and .github (1,201 files, 270,666 lines): 0 newly changed.
  • Each of those files as one text (1,227 files): 0 differ.
  • Broad mode on the markdown (49 files, 6,619 lines): the same 16 lines change as in round 2, and 0 differ.

Corrected claims (principle 5).

  • Round 2's "the same or broader masking" for the updater was false at 8633356d (the final check measured it), and is restated below as measured.
  • Round 2's changelog line "a private key logged a line at a time is masked whole" held only when its END was in the window; the fragment now describes the fail-closed rule and its one gap.

What changed (rounds 1 and 2, still in force)

The one list (src/events/redact.ts)

  • PATTERNS holds a Discord bot token, a Tailscale key, a dashboard key (wld_ + 32 or more base64url characters) and, since round 3, a Basic credential, beside every shape main had. Bearer matches any case.
  • BROAD (with broad: true, and the caller's mask word) holds what gh-login, the bench and the updater masked on their own:
    • a GitHub token from 8 characters;
    • any sk-ant-… or xox?-… run;
    • the updater's own token rule;
    • a JWT after a hyphen;
    • a short bearer token;
    • a URL's user info;
    • a value after a secret's name.
  • redactRecords(records) redacts journal entries or log lines as one text, returning the same records.

Dashboard keys (owner's decision 1)

  • dashboardToken writes wld_ + randomBytes(24) base64url for a new key. The prefix is fixed, not taken from BRAND, so a rename can't change keys already issued (inferred).
  • An existing key keeps working and is never rewritten.
  • doctor warns on an older key (local, each instance's and the hub's) and says how to rotate it. It never prints the key.

No exception list (owner's decision 2)

  • src/gh-login.ts safeLine, scripts/bench/cli.ts scrub and scripts/machine/<cli>-update.cjs redact() call the shared redactor's broad mode, with [hidden], [redacted] and [redacted] as their mask words.
  • The updater carries a generated copy of redact.ts, transpiled, between marker lines. scripts/gen-redact-vendor.mjs writes it. test/redact-vendored.test.ts holds it to the module byte for byte, checks its output against the module for every stand-in, and checks that it loads only node: modules.
  • KNOWN_OWN_REDACTORS and the whole-file exemption are deleted: the lint asserts that no module outside redact.ts has its own token redactor.

The dashboard's service log

  • The journal and the log file are each redacted as one text, with 100 records above the shown ones.
  • clip() redacts before cutting at 2,000 characters.

Other

For the weakening gate (listed on purpose)

  • Removed (owner's decisions; they tighten):
    • round 1's "not covered, by decision" test, replaced by the dashboard-key tests;
    • KNOWN_OWN_REDACTORS and its CHECK_TABLES entry.
  • Changed lines in the never-shrink PATTERNS table (round 3; each broadens):
    • the dashboard key's start anchor is dropped;
    • the GitHub, AWS, Slack (three), Linear and 0x patterns end at "no letter or digit next" instead of \b;
    • the JWT takes further dotted parts;
    • the private-key rule becomes two rules (BEGIN to END or to the end; start to an orphan END) and takes PGP blocks.
    • Changed line in BROAD: the URL rule's start, which is broader.
  • Changed lines in PATTERNS and BROAD (round 4; each broadens or keeps the match):
    • the left boundary is dropped on the distinctive-prefix shapes (GitHub ×2, sk-ant-, Google, AWS, Slack ×3, Linear; BROAD's GitHub, Slack and JWT rules). The plain sk- rule is kept as it was beside a new sk-ant- rule, and a second, anchor-free eyJ rule is added beside the old one;
    • Bearer and Basic take /i;
    • the URL-password rule's scheme is bounded to 32 characters from any start (any match the old rule made, this one makes on the same user info).
  • Changed entries in PATTERNS (round 5): two entries became one. Round 4's two JWT entries are now one. They were main's anchored eyJ rule and an anchor-free eyJ rule bounded to a 512-character header. The one rule reads a run of base64url parts and decides in a function.
    • The table has one entry fewer, but no shape is dropped.
    • Round 5's version missed <dotted part of 11+>.<jwt>, which main's anchored rule hid (the round-5 delta's item 1). Round 6 fixes it.
  • Changed entries in PATTERNS (round 6):
    • JWT: the regex steps over its first two parts whole (a lookahead and a backreference) instead of {11,} and {8,}. jwtIn checks the lengths and tries every dotted part as the header. Correction: round 6 claimed this found at least what main's rule found, and it did not. An unsigned or +//-led JWT after a dotted name was shown (the round-6 review's A1).
  • Changed entries in PATTERNS (round 7):
    • JWT: a plain replacement again (no function). It starts at a part holding eyJ (a lookahead in the part) and takes further dotted parts, empty ones too. On the inputs tried it finds at least what main's rule found (measured): the extended jwtfuzz gives 0 in 100k, the review's 252 unsigned cases give 0, and the composed-input test passes with unsigned shapes.
    • Basic: the (?![A-Za-z0-9+/=]) check after the value is dropped. That broadens it: a value glued to more base64 is now found.
  • The restart after a declined match (round 7; broadens): it is the match's next character, not 64 characters before its end.
  • The pass limit (round 7; tightens): 3 passes became a fixed point bounded at 8, failing closed.
    • Orphan END: the replacement is now a function that leaves the text as it was when every END has its BEGIN. Measured to hide no less: redact-fail-closed, redact-records-keys and redact-window-edge pass, as do the fuzz's BEGIN/END pieces. It is needed because the rules now read the text with each closed key still in it.
  • The default mode's order (round 6; broadens): the shapes are no longer applied in turn. They are read on the original text, and their spans are replaced as a union (replaceUnion). The glue-point second pass (glued) is folded into the same read (readAt).
  • test/redact.test.ts (round 4; tightens): new hostile inputs (long dotted runs), and the KNOWN_SLOW exemption emptied (it held "password in a URL").
  • Changed expected values: test/tailscale-setup.test.ts:161 ([a key] → tskey-[REDACTED]) and test/dashboard-logs.test.ts:22 (50 → 150). The final check found neither weaker.

How it was verified (earlier rounds, measured)

  • Round 1: 15 of 17 new tests failed on main a5df2254.
  • Round 2: 27 of 30 failed on main 41f6dd42, and 12 of 14 at 2c0142e1. The final check reproduced both counts.
  • CI on 8633356d was green (the final check cites run 39541657856). Round 3's CI is running on d0f4ab94.
  • The 38 named files run each round: redact-shapes, redact-records-keys, redaction-one-module, redact-vendored, redact, gh-login, update-redact, dashboard-logs, merge-policy, tailscale-setup, watchdog, superfan, discord-channels, discord-setup, dashboard-cache-clock, slot-dir-writes, brand, review, engine-guard-locks, dashboard-key-in-url, dashboard-key-guard, dashboard-auth, health, agent-settings, changelog, changelog-fragments, update-migrate, update-canary, update-stuck, update-drain, update-helpers, update-lookback, update-release-base-order, update-release-to-main, update-check-sources, bench-cli and child-env-sites.
  • All runs used a scratch HOME, state dir and TMPDIR, with recording stand-ins (exit 1) first on PATH, getent among them.

Linear time (measured):

  • test/redact.test.ts times every pattern on 100 KB of hostile input, the broad ones included.
  • redact-fail-closed adds runs aimed at the glue points, BEGIN/END runs, Basic and wld_, through the whole redactor in both modes, each under 2 s.
  • redact-records-keys times redactRecords on 20,000 records.

Adversarial pass

Rounds 1 and 2 (measured, fixed): the Discord token after x_, the PEM in the log file and above the window, the lint's alias and s['replace'] misses, URL-pattern false positives in the lint, the broad rules eating a PEM's BEGIN line, a bearer token after _, $ in a mask, and drift in the updater copy.

Round 3 (measured):

  • The union approach first missed overlapping matches of one rule:
    • apikey: '…=x-api-key = 'S' and Bearer x+Bearer y: a match's value held the next secret's name. Fixed: each read restarts 64 characters before the match's end.
    • Boundaries a marker had made (xoxa-…_db_password=) were missed. Fixed: a read with boundaries where spans end between word characters, and main's updater token rule added as a broad rule.
    • The first version of that read put a boundary between a name and its =, breaking the match. Fixed: only word|word cut points, read apart from the glue points.
    • The bench's email rule lost an address whose domain was masked. Fixed: the rule reads a mask inside an address.
    • A JWE's tail after a JWT whose first match took 3 parts. Fixed: a JWT takes its further dotted parts.
    • An email-first order broke a token. Tried, measured worse, reverted.
  • gitleaks flagged two test literals (an AWS-shaped id and a PEM block) in my first round-3 commit before it was pushed. Both are now put together at run time, and that unpushed commit was amended (see slips).

What remains (for follow-ups; all on main too, none a regression; measured unless marked)

  • Private keys in other formats (R10): an ssh.com SSH2 key (four dashes), a lower-case PEM BEGIN line, a PuTTY .ppk (Private-Lines). Only PEM (upper-case, five dashes) and PGP blocks are recognised.
  • A private key with only body lines in view: neither BEGIN nor END is in the text or window, so the base64 body is not recognised. This includes the updater's 30-line tail of a key still being written (inferred), and a dashboard log key over 100 + N records (A2).
  • A log record longer than 6,096 characters whose private key's END lies past the part that is read. It fails closed: the BEGIN redacts to the end of the read window, which can also hide the later records of that view (inferred).
  • Encodings inside a prefix (R12): a percent-encoded or HTML-entity _ (ghp%5F…, ghp&#95;…). A percent-encoded or HTML-entity character before a token is fine only for the shapes with a distinctive prefix (A4).
  • A prefix-less shape glued after a letter, a digit or a percent-encoded separator (A4, A5): a Stripe key, 0x, a plain sk- or a Discord token after %3D/%20 or a letter or digit, and Bearer or Basic after a digit or %20. They need a boundary, and those characters aren't one.
  • Short Basic values that are still shown (measured with r8/basic-nonascii.mjs; no regression, since main has no Basic rule):
    • A user:password with a non-ASCII character, when it has no digit, +, / or = and is under 16 characters (dFrDtlRLOkVv and the like). basicPair takes only printable ASCII. Of 144,445 random short pairs with an accented letter, 2,994 with 8 or more base64 characters were shown.
    • A pair of 5 bytes or fewer (u:pw → dTpwdw==, ASCII too). The rule needs 8 base64 characters before the padding.
  • Encodings and splits of a whole token: a URL-encoded Slack webhook, double-escaped JSON, a token split by a newline. A zero-width split is closed only in the bench, which drops format characters first.
  • A value further than 64 characters after its secret name inside another rule's match, in broad mode (R11).
  • Other:
    • a token used as an object key in an event payload;
    • {"db_password":"Summer2026!x"}: not credential-shaped, a design choice (A11);
    • an older, unprefixed dashboard key standing alone (doctor warns; rotate it);
    • upper-cased TSKEY-.
  • A token prefix followed by a run of millions of characters (ghp_ and 6 MB, measured) makes some rules overflow V8's stack, on main too. The text is then replaced whole by the placeholder (redactString), or named text too large to scan (secretKinds). It fails closed, but nothing of that text is shown.

What this can never do

  • Show, store or post a token of a listed shape, written whole (its prefix not percent- or entity-encoded, the token not split by a newline or zero-width character), through a path that calls redactString/redact/redactRecords/redactCut. Where it can appear:
    • A shape with a distinctive prefix is found after any character. That includes a JWT's eyJ, with a header of any length and a signature that is empty or starts with + or /, after a dotted name or glued inside a part. Measured (round 7):

      • the window-edge test covers x, 7, -, %3D, _ and a space;
      • redact-declined covers 8 dotted prefixes × 6 lengths × 8 tails through default, broad, redactCut and secretKinds;
      • the extended jwtfuzz finds 0 in 100k.

      Round 6's "any dotted part" was not true for an unsigned JWT; it is now, on these inputs.

    • What one shape's match takes in doesn't hide another shape: every rule reads the original text (round 6).

    • The others are found standing alone, or right after whitespace or punctuation, a name glued with _, an escape sequence (ESC as the byte, as the JSON escape (a backslash and u001b), \x1b, \033, \e or ^[, in any sequence form, or the 8-bit CSI) or a JSON/C backslash escape. They are not found right after a letter, a digit or a percent-encoded separator (see "What remains").

    • At a cut site, a token at the end of the read window is dropped with its whole run, never shown cut short, whatever redaction earlier in the window removed (round 5).

    • A text a rule can't scan is replaced whole by [REDACTED: too large to scan], and not thrown on. That covers a token prefix and then millions of characters (measured at 6 MB), and a run of about 50 MB of escape sequences with no prefix (measured by the round-6 delta: 16–20 s, where main returns it unchanged). So does a text whose passes don't settle within 8. secretKinds names a text it can't scan text too large to scan. A plain base64url run with no prefix is scanned and left as it was, as on main (measured at 6 MB).

    • Paths: the event log, issue comments, the digest, ops posts, chat notices, the dashboard's service log and diff view, PR sections, chat attachments, gh's output on the dashboard, the bench's printed lines, and the root updater's 0644 files and journal tails.

  • Show the body of a PEM or PGP private key whose BEGIN or END line is in the same text or window.
  • Spend more than its window on a huge input at a cut site: each redacts what it keeps plus a margin (measured above).
  • Redact less than main, on the inputs measured. This is a claim about the generators and cases below, not a proof. Round 6's version of this claim was false for unsigned JWTs after a dotted name, which none of its generators made; round 7 fixed that and added them:
    • Default mode: none of these shows a secret main's default mode hid (round 6, measured). They are every pair of 18 full-length shapes joined by 16 separators (redact-default-union), the delta checks' fragment fuzz (4 sets, 1.5M each), the separator and glue generators (300k each), and jwtfuzz (100k).
    • The three modules that had their own rules (the updater, gh-login and the bench): none of the same generators shows a secret their main rules hid (measured).
    • Each default shape main redacted is redacted by the same or a broader rule. Since round 6 the rules no longer run in turn, so that also holds for them together, on the inputs above. Correction (round 8): this was false for the URL-password rule from round 4 to round 7. Its bounded scheme needed a letter within 32 characters of ://, and main's took a scheme of any length. Since round 8 it reads the scheme's last 1 to 32 characters with no letter required, so it makes every match main's rule made (inferred: each of those has a scheme character right before ://; measured on the review's three cases).
  • Lock anyone out: an existing dashboard key keeps working until rotated.
  • Let a module add its own token regex unnoticed: the lint fails on any one, with no exception list.

Compromised component

  • Agent user: can no longer get a listed token into events, comments or logs in clear in the forms listed under "What this can never do". That includes a Basic credential (since round 8 a short printable-ASCII one too, base64 of a short user:password; still not one glued after a letter or digit, a pair of 5 bytes or fewer, or a short pair with a non-ASCII character, see "What remains"), and a PEM/PGP key with only one of its BEGIN/END lines. It can no longer hide a token from its rule by writing another shape right before it (wld_<key>-redis://u:<pw>@, <jwt>.Bearer <tok>); that is closed for default mode in round 6. It also can't do it with name.<jwt>, unsigned or +//-led JWTs included; that was closed only in round 7, since round 6 still showed a.<unsigned jwt>.. It can no longer stall the dashboard through a cut site with a huge input, make a cut site show a token cut by its window (by putting an orphan END line or a long closed key above it), or make the redactor throw with a run of millions of characters. It can still use "What remains", as on main: other key formats, body-only key lines, encoded prefixes, a prefix-less shape (Stripe, 0x, plain sk-, Discord, Bearer/Basic) glued after a letter, a digit or a percent-encoded separator, a URL-encoded webhook, double-escaped JSON, newline splits and object keys.
  • Coordinator: composes the digest and merge posts with scrub(redactPii(…)), on the same list. No new capability.
  • Dashboard:
    • It reads 100 more journal entries per log view, for redaction only.
    • It writes a new key only when none exists.
    • It gains no route.
  • Root updater: masks the union of every rule's spans, including its own rule from main. On the generators measured it shows nothing main's rules hid. Correction (round 8): until round 8 it showed a URL password after a long scheme whose last 32 characters hold no letter; that is fixed, and the round-8 test runs those cases through the updater. It loads only node: modules.
  • Someone with push access:
  • A hostile input author: every rule is timed on 100 KB hostile inputs (redact.test, now with no exemption), and the default and broad redactors on 400 KB dotted runs, eyJ chains and mask words (redact-escapes-windows). Each mode passes over its own output until nothing changes, at most 8 times (no input measured needs a 3rd), and each pass is 3 reads (broad) or 2 (default): a constant factor. On 90 hostile 1 MB inputs, every rule stays under 500 ms. The default redactor takes 288–697 ms on ESC[ and 535–621 ms on dense private-use characters with escapes (the reviews' figures; 437–510 ms in mine). The broad redactor takes up to 1.2 s. Each doubles with the size (measured, rounds 6 and 7). A 50 MB escape run becomes the placeholder after 16–20 s. Each cut site reads its window only. "Linear" here means: measured on those inputs, plus the bounded-stretch argument in each rule's comment (inferred).

Package size

Measured with npm pack --dry-run --json at origin/main and on this head:

  • Round 8, against origin/main cec056a5: tarball 1,511,841 → 1,522,782 (+10,941); unpacked 5,064,586 → 5,097,925 (+33,339); 495 files on both sides.
  • Round 7, against 40ef312a: tarball +10,695, unpacked +32,254. Round 6, against d2a549f4: tarball +10,721, unpacked +32,996 (it was +9,441 at round 5 and +8,808 at round 4).

Per file (round 8, against cec056a5; merge-policy-weakening.js is main's, so no delta there):

  • dist/src/events/redact.js +28,093
  • dist/src/dashboard.js +2,290
  • dist/src/doctor.js +2,280
  • dist/src/decision-detail.js +309
  • dist/src/tailscale-setup.js +275
  • dist/src/chat-attachments.js +178
  • dist/src/gh-login.js +111
  • dist/src/watchdog/post.js +26
  • dist/src/watchdog/incidents.js −223

Round 3 alone (against 8633356d) was +3,627; round 4 adds +1,197 over round 3's +7,611 against this main. Within the budget (2,050,000); no raise.

Notes for review

New dependencies

None. The generator uses typescript, already a devDependency, at development time only; the updater doesn't load it.

…luded (#889)

A Discord bot token passed through redactString unredacted: its pattern lived only in
src/watchdog/incidents.ts, so the event log, comments, chat notices and the dashboard's
service log kept it. Tailscale keys had the same split in src/tailscale-setup.ts.

Both patterns move into PATTERNS in src/events/redact.ts, the one list of credential shapes;
incidents.ts, the watchdog's posts and the Tailscale step lines call redactString. The
dashboard's service-log file is redacted as one text (with a margin above the kept lines)
before it is split, so a private key over several lines is found whole. A bare 32-character
key with no prefix stays uncovered by decision: a commit hash, a digest or an engine id has
the same shape.

test/redact-shapes.test.ts drives a stand-in for every shape (GitHub, Anthropic, Discord,
Slack, Linear, Tailscale and the rest) through redactString and each output path: events,
comments (file and GitHub backlogs), the superfan digest, notifications (incident post, merge
post, chat notice) and the dashboard's service log (journal and file). The backstop,
test/redaction-one-module.test.ts, fails on a replace/split in src/, web/src/ or scripts/
whose regex takes in a token stand-in, outside src/events/redact.ts; three own redactors
broader than the shared shapes are listed in KNOWN_OWN_REDACTORS (never-grow). PATTERNS,
the stand-ins and the lint's fixtures are registered in the weakening hold.

Test (fails without): test/redact-shapes.test.ts and test/redaction-one-module.test.ts, 13 of
15 failing on origin/main (Discord and Tailscale in every path, the PEM in the log file, and
the lint naming incidents.ts and tailscale-setup.ts).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
The release note for #889's redaction change, as a fragment under the guardrail heading. Also
rewraps the one-module lint's header comment (no code change).

The first commit's body counts 13 of 15 tests failing on origin/main; with the two tests added
in the adversarial pass (a Discord token after an underscore, a private key above the kept log
lines) it is 15 of 17, as the PR body says.

Test (fails without): none new; changelog.test.ts and changelog-fragments*.test.ts pass with it.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>

# Conflicts:
#	src/merge-policy-weakening.ts
@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (round 1/2)

Independent final check of head 2c0142e1 against #889. Labels: measured means from a command I ran (or CI output cited here), inferred means reasoned from the code without running it, guess means a guess.

Acceptance criteria (#889)

Criterion Result Evidence
A test feeds every token shape, Discord included, through redactString, and none survives Met measured: redact-shapes.test.ts passes at head. The 36 stand-ins cover every secret pattern in PATTERNS, and the coverage test enforces that.
Fail-without on main for the Discord case Met measured: see the fail-without section below
incidents.ts output unchanged for its existing cases Met measured: watchdog.test.ts and superfan.test.ts pass unchanged at head. inferred: the new `(^
A backstop test fails on a module outside redact.ts that has its own token regex Partly met measured: the lint passes and its fixtures pass. Its exception list has problems: see required changes 2 and 3.

What I measured

Tests at head. I ran redact-shapes, redaction-one-module, redact, tailscale-setup, watchdog, superfan, dashboard-logs and gh-login: 91/91 pass. merge-policy and brand give 47/47 (measured). All runs used a scratch HOME, state dir and TMPDIR, with recording stand-ins (exit 1) first on PATH.

Fail-without. I made a fresh worktree at a5df2254 and wrote the three new test files into it with Write (sha256 identical to head). tsc reported one error: test/redact-shapes.test.ts(38,64): Property 'secret' does not exist, which is expected because the field is new; JS was still emitted. Result: 15 of 17 fail (measured). The key failing lines:

  • Discord, 2 stand-ins × 2 contexts, survives in 7 paths. Tailscale, 3 × 2, survives in 10 paths.
  • 'src/watchdog/incidents.ts:134: .replace(Discord bot token)' and 'src/tailscale-setup.ts:116: .replace(Tailscale key)'.
  • The PEM body survives in the log-file view.
  • The 2 that pass are the not-covered decision and the lint's fixture self-test. Both are expected to pass.

My own variants. I wrote a script with stand-ins only. It runs each stand-in through redactString, events, a file-backlog comment, the digest, an incident post, a merge post, a chat notice, the dashboard journal and the dashboard file. Each stand-in went in plain, upper-cased, lower-cased, URL-encoded, in JSON, in double-escaped JSON, split by a newline, split by a zero-width character, and wrapped in 22 different separators.

  • Discord and Tailscale: redacted in every path for every realistic variant. The only misses were the newline and zero-width splits, plus upper-cased TSKEY-, which is not a real format (measured).

  • Gaps that exist on main too. None is a regression (inferred: redact.ts's other patterns are unchanged).

    • Every \b-anchored shape after x_ (GitHub, sk-, Slack, Linear, Stripe, AWS, Google, JWT, 0x hex).
    • A URL-encoded Slack webhook.
    • Lowercase bearer <token>. The root updater catches this one; the shared module doesn't.
    • A JSON credential value inside double-escaped JSON.
    • Any token split by a newline or a zero-width character.

    These are follow-up material, not blockers here.

  • The dashboard journal view still leaks a PEM (measured). See required change 1.

False positives. I ran every line of docs/**, changelog.d/**, README, CHANGELOG, CONTRIBUTING and SECURITY through main's and the branch's redactString. That is 51 files and 7,295 lines, a superset of the PR's 46 because more fragments exist now. 0 lines newly changed: main changes 1 line and the branch changes the same 1. 0 whole files differ (measured).

Other checks:

  • npm run typecheck: clean (measured).
  • npm run check:denylist: 1389 files clean (measured).
  • Package size: npm pack --dry-run --json gives a 1,468,823-byte tarball, 4,923,371 bytes unpacked, 490 files. That matches the PR body, and the budget is 2,050,000 (measured).

Required changes

  1. Fix the whole class in readServiceLog, not only the file branch.

    • The PR fixed the multi-line PEM for the log-file view. The journal view, which is the default under systemd, still leaks it. journald stores a service's stdout one line per entry, and each entry is redacted on its own. Measured: a PEM fed as one journal entry per line leaks its body, for every separator variant.
    • Separately, clip() cuts at 2,000 characters and only then redacts. Measured: a one-line, JSON-escaped PEM of 3,264 characters in one journal entry leaks its body, while redactString alone on the same text does not leak it.
    • The fix: redact consecutive journal messages as one text, the way the file branch now does, and redact before cutting. Add a test for each case (one PEM split across entries, one long PEM in a single entry). Both leaks exist on main too (inferred: clip is unchanged), but they are the same class this PR set out to fix, in the same function.
  2. KNOWN_OWN_REDACTORS: the stated reason is wrong, and two of the three can be made at least as broad as the shared module. Each entry says it is "broader than the shared shapes". Measured, feeding the 36 stand-ins through each one:

    • scripts/bench/cli.ts scrub misses 18/36: Discord, Tailscale, Linear, Slack app-level/webhook/rotation-refresh, Stripe, Google, ASIA, PEM, Bearer, 0x, JSON value.
    • scripts/machine/<cli>-update.cjs redact() misses 18/36: Discord, Tailscale, Linear, Slack app-level/webhook/rotation-refresh, Stripe, Google, JWT, AKIA/ASIA, 0x, JSON value.
    • src/gh-login.ts safeLine misses 29/36.

    They are broader only on short GitHub, sk-ant- and xox- tokens, plus lowercase bearer and *_TOKEN= in the updater. What to do:

    • In src/gh-login.ts and scripts/bench/cli.ts, apply redactString after their own rule. That keeps the short-token coverage, narrows nothing, and the existing [hidden] assertions still hold (inferred).
    • For the updater, which loads no engine module, correct the why to say what is true: it is narrower on 18 shapes, and it writes a 0644 log. Then put the exception to the owner (see below).
    • Correct the same claim in the PR body. Principle 5: a false claim gets corrected.
  3. Narrow the exception to the shapes each file has today. The lint exempts a whole file: known.has(path) in test/redaction-one-module.test.ts. So a new own redactor for any other shape added to one of these three files passes unnoticed (inferred from the code). Key each entry by path and kinds, which the lint already reports as line: .replace(kinds). Principle 9: an exception is a loosening, so keep it as narrow as the finding.

  4. Merge origin/main. The PR is CONFLICTING (measured: mergeStateStatus: DIRTY). git merge-tree --write-tree 41f6dd42 2c0142e1 conflicts in src/merge-policy-weakening.ts: main added scripts/bench/pins.ts SELECTION_RULES on the line next to this PR's two entries. Keep both. The full-suite CI has never run on any head of this PR: the only check run on 2c0142e1 (and cdad897b) is gitleaks: scan, which passed, and there is no pull_request run for issue-889 (measured). CI must be green at the merged head.

Checked and fine

For the owner (new trade-offs, not decided by the guide)

  • The bare dashboard key, "not covered, by decision".
    • The PR's reason doesn't fit the key. The key is randomBytes(24).toString('base64url') (src/dashboard.ts:1053), 32 mixed-case characters. Commit hashes, digests and randomBytes(16) ids are lowercase hex, so they don't share its shape (inferred from the code).
    • The engine never prints it: commands show only the key file's path, URLs that carry it are refused without being logged, it is sent as a Bearer header, which is redacted, and the agent hook denies the file (inferred).
    • The remaining exposure is a person pasting the key as plain text (in chat, intake, an issue, or a key: … line). From there it is stored and posted in clear.
    • A rule for "32 base64url characters, mixed case with a digit" changed 0 lines across the 51 markdown files (measured).
    • Decide: accept, or cover it.
  • The root updater's own redactor stays an exception. It is narrower than the shared module on 18 of 36 shapes, Discord and Tailscale among them, and it writes the 0644 update log and the journal tails. It can't load the engine's module. Decide: accept it as a named exception, or have it carry a copy of the shapes, checked against PATTERNS by a test.

…acted as one text (#889)

Round 2, on the owner's two decisions and the final check (#921):

- Dashboard keys are now wld_ and 32 random characters, in PATTERNS, so a key is redacted
  wherever it stands; a 32-character hex hash, a sha or a UUID is not. An older key still
  opens the dashboard and is not rewritten; doctor warns on each one it can read and says how
  to rotate it.
- KNOWN_OWN_REDACTORS is gone. gh-login, the bench and the root updater redact through the
  shared redactor; what they masked on their own (a token cut short, a clone URL's user info, a
  value after a secret's name, a short bearer token) is BROAD in redact.ts, applied with
  broad: true and each module's own mask word. The updater stays one standalone file with a
  generated copy of redact.ts (scripts/gen-redact-vendor.mjs), held to the module byte for
  byte by test/redact-vendored.test.ts.
- The dashboard's journal view redacts its entries as one text (redactRecords, with a margin
  of 100 entries), and clip() redacts before it cuts at 2,000 characters.
- Shapes glued to a name after `_` and a lowercase bearer are found.

Test (fails without): test/redact-records-keys.test.ts and test/redact-vendored.test.ts, 12 of
14 failing at 2c0142e; with redact-shapes and redaction-one-module, 27 of 30 failing on
origin/main 41f6dd4.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711 jayden1711 changed the title Redaction: one shared list of token shapes, Discord and Tailscale included (#889) [PROTECTED: owner merges] Redaction: one shared list of token shapes, prefixed dashboard keys, no own redactors (#889) [PROTECTED: owner merges] Oct 21, 2026
@jayden1711

Copy link
Copy Markdown
Owner Author

Second adversarial review (critical set): CHANGES NEEDED

Head checked: 8633356d. Worktree build of the head; every attack used the repo's stand-ins (test/token-standins.ts run()/SHAPES) or fake values, in memory or in a scratch temp dir, with a scratch HOME/state dir and recording stand-ins first on PATH. The PR's 4 new test files pass on this head: 30 of 30 (measured).

Paths driven (measured unless marked): redactString default (events, incidents, posts, the dashboard views per entry), redactRecords, readServiceLog (journal branch with a fake reader that returns the last n entries; file branch on a temp file), gh-login safeLine, the bench's scrub, and the updater's redact() (the generated copy, loaded with require).

Attack table

# Attack (claim aimed at) Result Label
A9 A token right after an ANSI colour code (ESC [1m then the token), as journald keeps a service's coloured output ("glued names"; "no secret reaches the journal view") Leaks for every token stand-in but the PEM, on every path: default, records, gh-login, bench, updater. The Anthropic keys are caught only in broad mode. Through readServiceLog's journal branch, a coloured ghp_ token is shown in clear. Cause: every pattern starts at \b or after a non-alphanumeric character, and the m that ends the escape is a letter (redact.ts:60-100, BROAD at 111-119). The bench turns control characters into spaces only after redacting (scripts/bench/cli.ts:115), so the token is printed. measured
A10 A token after a JSON escape in raw JSON text ("first line\n<token>"): single escaping, not the disclosed double escaping Leaks for the same shapes on the same paths, for the same reason: \n ends in the letter n. measured
A1 A private key with no END line (cut by a clip before redaction, or still being written), or with no BEGIN line Body leaks on default, records, bench and updater. The PEM rule needs both lines (redact.ts:84). Clip-then-redact sites that can cut a key this way: src/decision-detail.ts:146 and :355, src/chat-attachments.ts:373 (inferred from code). measured (redactor), inferred (sites)
A3 The dashboard log views while a key is still being written (BEGIN and body logged, no END yet) Leaks: journal branch 20 of 20 body lines shown; file branch leaks too. So the claim "a private key logged one entry per line is masked whole" holds only when its END is in the read window. measured
A4 The updater's journal tail (journalctl -n 30, worklane-update.cjs:2112) with a 4096-bit key (about 50 lines) whose BEGIN is above the 30 lines Body lines leak into the 0644 log: redact() on such a tail leaves them. The window is journalctl's (inferred). Same on main. measured (redact), inferred (window)
A1c A PGP private key block (BEGIN PGP PRIVATE KEY BLOCK) Leaks on every path but gh-login: the rule wants PRIVATE KEY----- right after the type. measured
A5 Authorization: Basic <base64(x-access-token:ghs_...)> (git's extraheader form; src/dashboard-auth.ts:346 sends client id:secret this way), plain and as a JSON header Leaks on every path, broad included. The JSON-key rule doesn't take a value with a space, and no rule knows the Basic scheme. measured
A6 A dashboard key after a hyphen or a letter (dashboard-key-wld_..., tokenwld_...) Leaks on every path (the key's start anchor excludes -, and the underscore pass covers only _). Low likelihood. measured
A6c An older key with no prefix Leaks, as disclosed; doctor warns. Doctor checks the local and each instance's key, not the hub's (dashboard-hub/dashboard-token, src/doctor.ts:262). measured / inferred (hub)
A7 A GitHub token followed by _ (ghp_<36>_backup) Leaks in default mode (trailing \b, redact.ts:61); broad mode catches it. Low. measured
A2 A key longer than KEY_MARGIN whose END is in the shown window Up to about 100 lines: ok. 200-line key (16384-bit RSA, exotic): 49 body lines shown. measured
A8 A token straddling the 2,000-character clip of a journal entry ok: redacted before the cut. measured
A11 {"db_password":"Summer2026!x"} (not credential-shaped) Leaks in every mode: the broad "value after a secret name" rule wants [=:] right after the name, and the closing quote is in the way. Pre-existing design choice; listed for completeness. measured
A12 URL credentials with percent-encoding (user:pa%40ss...@host) ok on every path. measured
V Drift between the updater's copy and redact.ts No route found. The test re-transpiles redact.ts at test time and compares byte for byte. It also compares outputs over every stand-in. Comment-only edits don't change the block, which is harmless. measured (tests pass), inferred
R Regression against main (no-regression rule) None found. Each module's old start anchors were the same or narrower: main's per-module patterns also used \b, and the bench's and updater's sk-ant- had no anchor and still don't. inferred (from the diff)

A9, A10, A1 and A3 are not caught by the PR's tests: no test puts a stand-in after an escape sequence or a JSON escape, or feeds a key without its END (measured: the tests pass at this head, and these attacks leak).

None of A9/A10/A1/A3/A4/A5 is a regression (inferred). They are working routes against the guardrail, though, and none is in the PR's "Remaining gaps". The disclosed follow-ups are listed there as stated: a URL-encoded Slack webhook, double-escaped JSON, and a token split by a newline or a zero-width character (measured, in the PR body).

Claims checked

  • "What this can never do: show ... a token of any shape in the list through a path that calls redactString/redact/redactRecords": false for A9/A10 (every listed shape) and A1/A3 (private key).
  • Changelog: "a private key logged a line at a time is masked whole": true only when its END is in the window (A3).
  • "Compromised component, agent user: it can still use the gaps above, as before": the gaps list is incomplete (A9, A10, A1, A5).
  • "Redact less than before for any existing case": holds (R).
  • "Lock anyone out": an older key keeps working (A6c, measured by the PR's test, which passes).

Required changes

  1. Fix the escape-glue class (A9, A10). Fixing glue only after _ covers one instance of it. For the class, either:

    • add more cut points to the second pass (afterUnderscore): the end of each ANSI escape sequence (ESC [ ... final byte) and each backslash escape (\n, \r, \t, \", \\, \/, \uXXXX);
    • or neutralise ANSI sequences before matching.

    Add a test that drives every SHAPES token through every path after an ANSI colour code and after a JSON \n.

  2. Make the private-key rule fail closed (A1, A3, A4, A1c). A BEGIN line with no END redacts to the end of the text. An END line with no BEGIN redacts from the start of the text (or the window) to it. Take PGP's PRIVATE KEY BLOCK too. That also makes KEY_MARGIN a backstop rather than the only defence (A2). Add tests: a key with no END through each path, including the journal and file views.

  3. Disclose, and fix or file:

    • Basic-auth credentials (A5), with a rule for the Basic scheme followed by a base64 run;
    • a dashboard key after - or a letter (A6);
    • a ghp_ token followed by _ in default mode (A7);
    • clip-then-redact sites (decision-detail.ts:146,355, chat-attachments.ts:373);
    • the hub's key not covered by the doctor check.

    Correct "What this can never do" and the gaps list so they match.

Settled by the decision guide: 1 and 2 follow principles 1 and 4 (fail closed; fix the class), so no owner decision is needed. Nothing here is a new trade-off.

@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (round 2/2)

This is the round-2 delta final check of head 8633356d against #889, the owner's two decisions, and the round-1 findings (issuecomment-6307452496). Labels: measured means from a command I ran, or CI output cited here. Inferred means reasoned from the code, not run. Guess means a guess. Every local run used a scratch HOME, state dir and TMPDIR, with recording stand-ins (exit 1) first on PATH.

There is one required change, and it is real. The root updater's new redact() hides less than main's did on three input classes (measured). Everything else in the round is done and checks out.

Round-1 findings

# Finding Result Evidence
1a Journal view: a PEM split one line per entry Fixed measured: 0/25 body lines leak at head vs 25/25 on main. That holds for plain entries, CRLF endings, a logger prefix on every line, blank entries in between, and BEGIN above a 20-line window. redactRecords keeps the shown count: 300 entries with 50 asked shows 50, entries 250 to 299.
1b clip() cut before redacting Fixed measured: one 3,000-character entry, JSON-escaped or with real newlines, leaks 0 at head and all of it on main. clip now redacts and then cuts.
2 KNOWN_OWN_REDACTORS' false "broader" claim Fixed by the owner's decision 2 measured: gh-login, the bench and the updater all go through redactString(…, { broad: true }), and each masks all 36 stand-ins in both contexts (redact-records-keys passes).
3 Whole-file exemption Gone measured from the diff: the list and known.has(path) are deleted, and the lint now asserts found == [] with only src/events/redact.ts left out. It passes at head. inferred: the updater's generated copy calls its patterns through parameters, so the lint can't see them. The copy is held byte for byte to the module instead (see below).
4 Merge main, full CI Done measured: merge-tree with origin/main 41f6dd42 is clean, and every check on 8633356d passes (see CI).

Fail-without (measured)

I made a fresh worktree at 41f6dd42 and wrote the 5 new files into it with Write. Their sha256 is identical to the head's. tsc reported 6 errors, all for the new exports and fields (redactRecords, dashboardKeyCheck, group), and the JS was still emitted.

  • redact-records-keys + redact-vendored: 12 of 14 fail. Key lines:
    • test 1, the journal view: ["starting","-----BEGIN RSA PRIVATE KEY-----","A1b2C3d4…","-----END RSA PRIVATE KEY-----","done"]
    • test 2, the clip: the body is shown after config: \"-----BEGIN…
    • test 8: a new key is FNVxjzhW…, with no wld_
    • the updater's redact() gives [redacted] where the shared redactor gives gh*_[REDACTED]
    • The 2 that pass are "broad rules off by default" and "an older key keeps working". Both are expected to pass on main.
  • redact-shapes + redaction-one-module: 15 of 16 fail. The lint's fixture self-test passes, as expected.
  • Total: 27 of 30 fail, which matches the PR body. At head: 155/155 pass across redact-shapes, redact-records-keys, redact-vendored, redaction-one-module, redact, tailscale-setup, dashboard-logs, gh-login, watchdog, superfan, update-redact, merge-policy and brand.

The broad rules: does any caller mask less? Yes, the root updater

I compared each module at 41f6dd42 (gh-login safeLine, the bench's scrub, the updater's redact()) with the same module at head.

  • The 36 stand-ins plus 55 of my own: 36 stand-ins in 16 contexts each (plain, TOKEN=, after - . _ + /, quoted, JSON, glued to a name), plus 55 cut-short and edge variants. That is 2,569 module × input pairs, with 3 narrowings, all in the updater (measured).
  • A fuzz: 1.5M random strings built from token fragments and separators gave 4,303 narrowing hits. A second fuzz of 1.5M built from whole, realistic tokens gave 514 (measured). Almost all of them are in the updater. They reduce to three classes:
    • (a) User info in a URL whose scheme directly follows -, . or + (measured). Main's updater masked these; the head shows the token:

      • error: -https://<tok>@github.com/o/r.git
      • x +git+https://<tok>@github.com/o/r.git
      • see .https://<tok>@github.com

      The cause: the broad "user info in a URL" rule's start changed from \b to (^|[^a-z0-9+.-]). inferred: that change was made for linear time.

    • (b) A bearer value that starts with a JWT shape and goes on (measured). Main's updater masked the whole value; the head masks three parts and shows the rest:

      • Authorization: Bearer <5-part JWE>, in either case, becomes Bearer [redacted].<ciphertext>.<tag>
      • Bearer <jwt>.<suffix>
      • Bearer eyJ….….…+/…==

      The cause: in broad mode, "JWT after a hyphen" runs before the bearer rule, and the bearer rule can't match [redacted].….

    • (c) Rule-order interactions (measured, contrived input). A cut-short rule (sk-ant-…+, xox[abprs]-…+, a short gh*_) eats the marker of the next rule, and the next secret survives:

      • xoxb-Bearer <secret>
      • apikeysk-ant-secret:<secret>
      • sk-ant-BEARER <secret>

      Main's updater hid each of these.

    • gh-login and the bench showed a few hits too, only on tokens glued together with no separator. inferred: some of the gh-login hits come from the 200-character cut: a shorter masked prefix lets more of the tail fit.

  • broad: false where main masked broadly: none (measured by grep). All three modules pass broad: true, including the updater's journal tails and redactDeep. tailscale-setup and incidents.ts use the default, which now holds their old shapes: the Tailscale rule is identical, and the Discord rule's start is broader.
  • The PR body claims under "What this can never do": "Redact less than before for any existing case: never… the same or broader (measured above)". The "Root updater" bullet says "the same or broader masking". Both are false for (a) to (c).

The vendored copy (measured, with fixtures; no repo file edited)

  • Drift fails the test. The check fails for each of these:

    • a pattern changed or removed in redact.ts;
    • one byte changed in the copy;
    • a line added to the copy;
    • a trailing space in the copy.

    A comment-only change to redact.ts still passes, because comments are stripped. That is correct, since the code doesn't change.

  • Marker lines in the content fail closed. The check fails after --write when:

    • the END marker is in a redact.ts string;
    • the BEGIN marker is in a redact.ts string;
    • the END marker sits above the block;
    • there is a second BEGIN.

    A second END after the block passes, and that is harmless: the block is unchanged. The withBlock replacement uses a function, so a $ in the content can't break it.

  • The updater loads only node: modules: node:fs, node:path, node:child_process, node:crypto and node:url. The block requires or imports nothing. The one dynamic import( (line 2487) is there on main too (count 1 on both sides).

False positives (measured)

  • Commit shas, sha256 digests, UUIDs and a 32-character base64 id are untouched: standalone, after _ and after -.
  • docs/**, changelog.d/** and the top-level markdown: 53 files, 7,325 lines, 0 lines newly changed.
  • src, scripts and web/src: 659 files, 140,453 lines. 1 newly changed line, the tskey- placeholder in scripts/setup/machine-helper.sh, from round 1's Tailscale rule.
  • The _<prefix> glued pass is not too broad. It applies the full shapes only, so test_ghp_…, get_AKIA_prefix_check, field_0x_value_name, some_eyJ_thing_here, is_lin_api_… and x_sk_live_mode_… stay.
    • It does redact snake_case names that hold an existing shape after the _: use_sk-learn-model-configuration-file, parse_github_pat_<40+ chars>, old_wld_<32+ chars>. The same text standing alone was already redacted on main (sk-…, github_pat_…).
    • Lowercase bearer takes a following 20+ character path or identifier (the bearer docs/authentication/overview.md).
    • All of this is acceptable under redact.ts's fail-closed policy (inferred), and none of it occurs in the repo (measured above).

The two changed assertions and the deleted test

  • dashboard-logs.test.ts:22 (50 → 150): not weaker. It is still an exact deepEqual on what is read, now 50 plus the 100-entry margin, and the entries assertion is unchanged and exact. measured: the shown count stays at lines.

  • tailscale-setup.test.ts:161: unchanged since round 1. It is an exact whole-line equal with no key characters in it. Not weaker (inferred, as in round 1).

  • The deleted "not covered, by decision" test was this PR's own round-1 test, never on main. It asserted that a bare key was not redacted. Its replacement is stricter (measured: the tests pass):

    • a real new key is redacted in 11 output paths;
    • a 32-character hex value, a sha, a sha256 and a UUID are not;
    • an older key still opens the dashboard and its file is not rewritten;
    • doctor warns and never prints the key.

    Only its "a bare key in a Bearer header is redacted" assertion is not repeated as such. The 40-character bearer stand-in covers that rule (inferred).

Other checks

  • src/merge-policy-weakening.ts vs main: +12 −0 (measured). Main's SELECTION_RULES is kept. KNOWN_OWN_REDACTORS was only in this PR's round 1, so nothing main had registered is removed. The additions are never-shrink on:

    • PATTERNS and BROAD in redact.ts;
    • the updater copy's PATTERNS and CREDENTIAL_WORDS;
    • CUT_SHORT, SHAPES and BAD_SOURCES.

    Plus never-grow on GOOD_SOURCES, and a LINT_TESTS entry.

  • Typecheck, denylist, size (measured):

    • npm run typecheck: clean.
    • check:denylist: 1,403 files clean.
    • npm pack --dry-run --json: tarball 1,470,176 → 1,474,105 (+3,929), unpacked 4,926,629 → 4,938,390. Within the 2,050,000 / 6,400,000 budget, and it matches the PR body.
  • git merge-tree (measured):

  • CI on 8633356d: all green (measured: gh pr checks 921). The pull_request run 39541657856 has headSha 8633356d and success. That covers 8 shards on Node 22/24, the real-systemd jobs, dco, denylist, scan, scope and new-deps. mergeStateStatus: CLEAN.

  • Commits:

    • Every non-merge commit is signed off, with no attribution trailers (measured).
    • Round 1's merge commit cdad897b has no sign-off. The repo's own DCO check skips merges (scripts/check-dco.mjs runs git log --no-merges), so that is not a finding.
  • Protection: still marked protected (title, needs-owner, **Risk class:** protected), with real "What this can never do" and "Compromised component" sections (measured). The new generator (scripts/lib/redact-vendor.mjs, scripts/gen-redact-vendor.mjs) writes root-updater code. A change to it can only land with a regenerated updater, which is itself protected (inferred).

  • Rule A: the new tests use tmpdir() scratch dirs, an in-memory event log and a fake fetch. doctor(root) runs under isolate.js's moved HOME and state (inferred from the code, and run here under a scratch HOME).

  • Owner decisions:

    • Decision 1 is met (measured): wld_ keys, old keys keep working, doctor warns. inferred, minor: doctor reads the local key and each instance's key. A hub key in the viewer user's home is checked only when it is readable.
    • Decision 2 is met: no exception list, the three modules go through the shared redactor, and the updater carries a test-checked generated copy.

Required changes

  1. Close the updater's narrowings (a) to (c), so its redact() hides everything main's did. This follows principles 1 and 3: nothing main closed may reopen, and none of it is a new trade-off. Fix the class, not the three instances. Two ways to do that, the builder's choice:

    • Order: in broad mode, apply the rules so none can consume text another rule needs. One way is to mask the union of the spans that every rule finds on the original text. That is order-free by design, and it guarantees that each rule masks at least what it did alone. The other is to apply the bearer and secret-name rules before the cut-short and JWT rules, and keep the cut-short rules from eating a following bearer, token or secret word.
    • The URL start: restore main's \b start, keeping linear time with a bounded scheme. For example, \b([a-z][a-z0-9+.-]{0,31}:\/\/)[^\s/@]+@: each :// is reached from at most 32 starts (inferred).

    Add a test with each of these inputs. Each must fail at 8633356d and pass after the fix:

    • -https://<tok>@host, +git+https://<tok>@host, .https://<tok>@host;
    • Authorization: Bearer <5-part JWE>, Bearer <jwt>.<suffix>, Bearer eyJ….….…+/…==;
    • xoxb-Bearer <s>, apikeysk-ant-secret:<s>.

    Better still, add a test that compares the updater's redact() with main's five rules, kept as a fixture in the test, on generated inputs. Then the class stays closed.

  2. Correct the PR body (principle 5). "Redact less than before for any existing case: never… the same or broader" and the Root updater's "same or broader masking" are false at this head. After fix 1, restate them as measured, citing the new test.

Not required (follow-ups)

  • An explicit test that an older, unprefixed key in a Bearer header is still redacted, since such keys stay in use until they are rotated.
  • Tokens glued to each other with no separator (gh-login and bench fuzz hits) are contrived. Union-of-spans masking (fix 1) would close those too (inferred).

…keys fail closed, glue class (#889)

Round 3, on the second adversarial review and the round-2 final check (#921):

- The broad mode (the root updater, gh-login, the bench) no longer applies rules in turn: every
  rule runs on the text as it was, again with boundaries at the glue points and where a span
  ends between word characters, and the union is masked once, read again on the result until
  it settles. The updater's own token rule on main is one of the rules; the URL user-info rule
  takes a scheme of up to 32 characters from any start; a JWT takes its further dotted parts.
  On main's three module rules (fixtures in the test), generated input shows no secret more
  often than before (the regression the final check measured).
- Glue class: a token right after an ANSI escape or a JSON backslash escape is found, by a
  second read with a boundary marked after each (default mode too).
- Private keys fail closed: no END redacts to the end of the text, no BEGIN from the start;
  PGP's PRIVATE KEY BLOCK too.
- HTTP Basic credentials are redacted; a dashboard key after `-` or a letter and a token
  followed by `_` are found.
- decision-detail and chat-attachments redact before they cut; doctor checks the hub's key.
- The bench drops format characters before redacting and keeps its control characters for
  after (the redactor finds ANSI glue); its email rule reads a mask inside an address.

Test (fails without): test/redact-fail-closed.test.ts and test/redact-broad-union.test.ts,
11 of 14 failing at 8633356.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Second adversarial review (critical set), round 3: CHANGES NEEDED

Head checked: d0f4ab94. This was a detached scratch worktree. I wrote my attack list before reading the earlier verdicts. Every payload used made-up tokens built at run time and ran only against the built modules in memory, with a scratch HOME and state dir and recording stand-ins first on PATH. I posted no working payload; the routes are described in words.

Two classes break the guardrail:

  • Escape glue. The glue fix covers one instance, not the whole class. A token right after other common escape forms still leaks on every path.
  • Stall. Each new "redact before cut" site now redacts the whole input. One default pattern that main already had is quadratic, so an agent-written diff, a log line or a chat attachment can stall the dashboard or the coordinator for minutes to hours. Main capped that input first (512 KB, 2,000 or 60,000 characters).

Attack table

# Attack (claim aimed at) Result Label
R1 A token right after an ANSI colour code inside raw JSON text: the escape written as the six-character JSON escape for ESC, then the CSI's bracket, parameters and final letter (claim: "found right after an ANSI escape or a JSON escape") Leaks on all 5 paths (default, broad, updater, gh-login, bench), with or without parameters ([1m, [32;1m). GLUE (src/events/redact.ts:248) makes a cut after the JSON escape, but the CSI that follows is recognised only after a real ESC character. So the cut lands before the bracket, and the token is still glued to the final letter. This is the most likely form, for example a JSON log of coloured output or stream-json lines through the bench. measured
R2 A token right after an ESC sequence that isn't a CSI: save or restore cursor (ESC then 7 or 8, used by progress bars), charset select (ESC then (B, part of tput sgr0 on common terminfo entries), reset (ESC then c) Leaks on all 5 paths. GLUE's second form [@-Z\\-_] leaves out digits, (, ), #, % and lower case, so the token stays glued to a word character. measured
R3 A token after the 8-bit CSI (byte 0x9B), then parameters and a final letter Leaks on all 5 paths. measured
R4 A token after a literal escape written out in text: C-style hex or octal ESC, or caret notation, then a CSI Leaks on all 5 paths. Less likely: a script in a diff, or cat -v output. measured
R5 OSC title ended by BEL; OSC 8 link ended by ST ok on all paths. measured
R6 Stall: the default "password in a URL" rule (redact.ts:106, \b[a-z][a-z0-9+.-]*://, unbounded scheme) on a long run of letters and dots It is quadratic. The rule alone takes 49.6 s on 512 KB. An in-memory mutant with the scheme bounded to 32 characters takes 15 ms and still masks a URL password. Through the PR's call sites: decisionDiff (decision-detail.ts:357, now redacts all of stdout, up to maxBuffer 8 MB, then cuts to 512 KB) takes 10.4 s at 256 KB and 40.1 s at 512 KB. 8 MB extrapolates to about 3 hours. readServiceLog journal branch (stand-in reader, no journalctl run): 10 entries of 48 KB (journald's default LineMax) take 7.5 s, so 300 entries (200 + KEY_MARGIN) come to about 4 min. attachmentPrompt (chat-attachments.ts:374) now redacts the whole text file (default cap 10 MB). Main cut first: 512 KB, 2,000 characters per entry, 60,000 characters per file. So the PR raises the worst case at all three sites, and redaction is synchronous, which blocks the dashboard's event loop (its stop controls among them, principle 2). The same rule is on main and already costs about 40 to 50 s at main's 512 KB diff cap. measured (rule, decisionDiff at 256 KB and 512 KB, log view at 10 entries); inferred (8 MB, 300 entries, attachment)
R7 Stall: broad mode's outside() (redact.ts:476) loops over every mask word for every span, O(spans × masks), on text holding many mask words and many secret names Quadratic. With the [hidden] mask: 0.23 s at 100 KB, 0.98 s at 200 KB, 3.7 s at 400 KB. The same shape shows with [redacted]. Broad callers get shorter text (gh-login's last line, the bench's lines, the updater's 30-line tail), so this is lower risk. measured (timings); inferred (caller sizes)
R8 A dotted eyJ… chain in broad mode 7.2 s at 400 KB, quadratic. The cause is R6's rule: broad mode runs every rule on the original text, so the JWT rule's mask no longer shields the URL rule. measured
R9 Mixed-case scheme (bEarer …, also in a JSON Authorization header) (PR body: "Bearer matches any case") Leaks in default mode; broad mode is ok (/i). The default rule takes three spellings only (redact.ts:100), so the claim is false as written. measured
R10 Private keys in other formats: an ssh.com SSH2 key (four dashes, ---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----), a lower-case PEM BEGIN line, a PuTTY .ppk (Private-Lines) The body leaks on default, broad, updater and bench. The rule needs five dashes and upper case. Likely the same on main (inferred). It goes against "Show the body of a private key whose BEGIN or END line is in the same text", which doesn't say it means PEM and PGP only. measured (leak); inferred (main)
R11 The 64-character restart: a value after a secret's name, where that value holds a second name whose separator is more than 64 spaces long In broad mode the second value (not token-shaped) leaks. Main's in-turn replace missed it too (inferred), so it isn't a regression. The claim "a value that holds the next secret's name doesn't hide that next match" holds only within 64 characters. measured
R12 Percent-encoded _ in a prefix; an HTML entity for _; a URL-encoded newline (%0A) right before a token; a digit or letter glued before a token These leak on all paths. Not "written whole" or not claimed, so they are listed as residuals and not required. The %0A case is the same glue class as R1 in URL encoding. measured
R13 A token in a URL fragment; short Basic credentials; a PEM with CRLF lines; a key cut at the clip (redact before cut) ok. measured
R14 doctor prints a dashboard key It never does: dashboardKeyCheck reports the file path and the format only. inferred (code)

PR's own tests

  • redact-fail-closed and redact pass at this head: CI is green, and locally 19 of 20 passed. The one failure is the hub-key doctor test calling my git stand-in, an environment artefact. (measured)
  • They don't catch R1 to R4:
    • The glue test wraps only ESC[…m and the JSON escapes \n, \t and \".
  • They don't catch R6, R7 or R8:
    • The linear-time inputs have no long dotted run.
    • No test times a redact-before-cut call site on a large input.

Claims checked

  • "found right after an ANSI escape or a JSON escape": false for R1 to R3. R1 is a JSON escape followed by an ANSI escape.
  • "Bearer matches any case": false in default mode (R9).
  • "Show the body of a private key whose BEGIN or END line is in the same text": true for PEM and PGP; false for the formats in R10.
  • "the new rules and passes are linear on hostile input" (Compromised component, hostile input author): false. R7 is new and quadratic. R6 is older, but the PR now feeds it unbounded input at three sites.
  • "Redact less than main": not contradicted by anything I found (R11 is the same as main).
  • "Lock anyone out" and "Let a module add its own token regex unnoticed": not attacked further.

Required changes

  1. Fix the glue class by design, not by enumerating escapes:

    • Let the distinctive-prefix patterns start anywhere, as the dashboard-key rule already does: gh[pousr]_, github_pat_, sk-ant-, the xox/xapp/xoxe family, lin_api_/lin_oauth_, AKIA/ASIA, AIza, eyJ and so on. The match then no longer depends on what precedes it.
    • If any prefix stays anchored, GLUE must recognise ESC in every encoding: the raw byte, the JSON escape, C hex or octal, caret notation and 0x9B. It must take each one with every sequence form (CSI with parameters, nF such as (B, Fp such as 7/8, Fs such as c), and cut after the whole sequence.
    • Add R1 to R4 to the glue test.
  2. Make the URL-credential rule linear: bound the scheme to 32 characters and drop \b, as the broad URL rule already does. Add long dotted runs (letters and ., - or +) to the linear-time inputs in test/redact.test.ts.

  3. Bound each redact-before-cut site to a window: redact the cut plus a margin, not the whole input:

    • decisionDiff: MAX plus a margin, not 8 MB;
    • attachmentPrompt: room plus a margin;
    • clip: 2,000 plus a margin;
    • the log views: each entry, before redactRecords.

    A key cut at the window's end still fails closed through the BEGIN-without-END rule. Add a timing test per site.

  4. Make outside() linear: the masks are sorted, so walk them with one pointer or stop at the first x >= b. Add a timing case with many mask words and secret names.

  5. Correct the claims:

    • Bearer and Basic: either /i in default mode, or say which three spellings are taken;
    • the escape scope;
    • private-key formats: PEM and PGP, or add the SSH2 and PuTTY forms as a follow-up;
    • the 64-character bound on the restart.

Not required (residuals, follow-ups): R12, R10 if the claim is narrowed, R11.

Settled by the decision guide: principle 1 (fail closed: anchor-free prefixes), principle 3 (the worst-case cost at three sites was bounded on main) and principle 4 (anchor-free prefixes make the glue class impossible, where a list of escapes would be a lint-like enumeration). No new trade-off for the owner.

jayden1711 added a commit that referenced this pull request Oct 21, 2026
 cases are normal; an exact-entry suffix host

node:test reports a todo whose body passes as `ok # TODO`, never a failure: once #887 and #894 merged, their cases
passed on main as todos, so a regression of either would have stayed green, and nothing made anyone flip them.

- The #887 and #894 cases are normal cases now (both pass on main after the merge).
- The remaining holes are known-hole cases (knownHole): strict expected failures that pass while every attack still
  gets through and fail the suite, saying "this hole is closed: turn this into a normal case", once any is refused.
  The fix PR (#927, #928, #921, #893) goes red in its own CI until it flips its case; main never does. A self-test
  covers the helper: open, closed, partly closed, bad bookkeeping, and a body that can't run.
- tool.domain-allowlist gains two hosts that end with an exact allowlist entry (no dot before it, and after another
  label): the evaluator's E04 mutant (`h === a` read as `h.endsWith(a)`) survived the whole file before.

Test (fails without): E04 now fails tool.domain-allowlist (28 pass, 1 fail); without the two lines it left the file
green (evaluator, measured). At PR #921's head the #889 known-hole case fails with the flip message.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…ed, linear URL rule (#889)

Round 4, on the third adversarial review (#921):

- Every shape with a distinctive prefix (gh*_, github_pat_, sk-ant-, xox?-, xapp-, lin_*,
  AKIA/ASIA, AIza, eyJ with a bounded header, wld_, tskey-) is matched with no left boundary,
  in default and broad mode, so nothing glued before it hides it. Shapes with no distinctive
  prefix keep a boundary, and the glue points now take ESC in every encoding (the byte,
  \u001b, \x1b, \033, \e, ^[), each sequence form (CSI, nF, single character) and the 8-bit
  CSI.
- Bearer and Basic match in any case.
- The default URL-password rule takes a scheme of up to 32 characters from any start: linear
  on long dotted runs (it was quadratic). redact.test's linear-time inputs gain dotted runs,
  and its KNOWN_SLOW exemption for that rule is emptied.
- redactCut: decisionDiff, a PR section, a chat attachment and the dashboard's log views redact
  only what they keep plus a margin (16 KB; 4 KB per log record), not the whole input.
- Broad mode's mask subtraction walks the sorted spans and masks once.

Test (fails without): test/redact-escapes-windows.test.ts at d0f4ab9: 3 fail and 2 did not
finish in 280 s (the linear-time and window tests); 1 passes (a key cut at a window edge).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Second adversarial review (round 4): CHANGES NEEDED

Head checked: 88ec30b7. I ran everything below against a detached worktree of that head, built locally. All inputs were stand-ins built at run time, and the scripts are in the orchestrator's scratchpad (work/adv-921-r4/). No payload is posted here.

Attack table

# Attack (claim aimed at) Result Label
A1 A token across a redactCut window edge ("a token across the cut is redacted whole"; "What this can never do", first bullet). Redaction before the edge shrinks the text by more than CUT_MARGIN. That happens with an END line with no BEGIN (the orphan rule masks from the start), or with a closed key of over 16 KB. The shrink brings the window's raw last characters inside max. A token cut by the window's slice is too short for its rule, so it is shown. Leak. Measured on redactCut at max 2,000, 20,000 and 512 KB, with both shrinkers. Shown: a dashboard key with 31 of its 32 body characters, a ghp_ token with 29 of its 30 random characters, a URL password minus its last character, and a Bearer and a Stripe value cut short. redactString on the whole text hides all of them. Sites: src/events/redact.ts:551-554, used at src/decision-detail.ts:148 and :359 and src/chat-attachments.ts:375. measured (function); the 3 sites inferred (same call)
A2 The same edge on the dashboard log views (readPart cuts each record at 6,096 before redactRecords, src/dashboard.ts:892). A record holds an END line, or a closed key on one JSON line, before character ~4,100. The redacted record falls under 2,000, so clip keeps it whole, cut token included. Leak. Measured through readServiceLog with an injected journal reader (no journalctl run) and with a temp log file. A dashboard key with 31 of 32 body characters is shown, and so is a ghp_ with 29 of 30. measured
A3 eyJ header bound ("a shape with a distinctive prefix is found after any character"). A JWT whose header is over 515 characters (an x5c chain makes them several KB) comes right after a letter, a digit, - or %3D. Leak, default and broad. The signature is shown at header lengths 600 and 3,000. Header lengths 500–513 are fine, and so are _, ESC, = and space before the token. The anchor-free rule (redact.ts:87) is bounded to 512, and the anchored one (:85) excludes letters, digits and -. Not a regression: main's default rule excluded the same characters (measured, main's literal). The bench's main rule \beyJ…{8,} did catch the x/%3D cases, but the bench's withholdTasks withholds any word over 512 first, so the bench doesn't narrow. measured
A4 "A percent-encoded … character before a token is now fine" (What remains, R12 line). False for the shapes with no distinctive prefix. Stripe, 0x, plain sk- and Discord after %3D or %20, and Bearer after %20, are all shown in default and broad mode. &#61; before them is fine. Not a regression (main used \b). measured
A5 A prefix-less shape glued after a letter or digit (Stripe, 0x, sk-, Discord, Bearer/Basic after a digit). Shown. This matches the stated scope ("standing alone, or right after a separator…"), so it is not a claim breach. Name it in What remains. measured
A6 Hostile inputs at 1–8 MB, each rule (linear time; no stall). Linear, measured: time doubles with size. The worst case is a run of eyJ: about 0.45 s/MB in default mode and 1.8 s/MB in broad. That is about 0.25 s at the diff view's 528 KB window. But every {N,} rule throws RangeError: Maximum call stack size exceeded (V8's backtrack stack) on one run of about 5.33 MB or more after its prefix: GitHub, PAT, wld_, sk-ant-, Stripe, Bearer, Basic, Discord, the JSON value, Slack and Linear. redactString throws in both modes. The cut sites are safe because redactCut bounds them. Unbounded callers include lastLines (src/testrun/run.ts:182), where the test run ends as an error, and the updater's redactDeep/appendLog. Pre-existing: main's literals throw the same way (measured). measured (redactor, main literals); caller effects inferred
A7 Fullwidth confusables (ghp_…). Not redacted, but NFKC gives back the ASCII token. No consumer normalizes output: the only normalize('NFKC') calls are in the bench's task-shape checks (scripts/bench/cli.ts:211,286). This is a deliberate encoding, the same class as the named encoded-prefix residual. No finding. measured (redactor) / inferred (consumers)
A8 eyJ / BEGIN / END / URL / Basic / secret-name / JSON / Discord-dash / ESC / private-use catastrophic shapes (the bounded-stretch argument in each rule's comment). No super-linear case found. At 1 MB, each of 23 shapes takes at most 153 ms in default mode and 589 ms in broad, apart from eyJ runs and the all-private-use-characters input (643 ms default). The heavier ones (eyJ runs, dotted URL runs, the JSON key, mixed text, private-use) scale linearly from 1 to 4 MB. measured

Tests vs the most dangerous attack

The PR's window test (test/redact-escapes-windows.test.ts:147) covers only a key whose END is past the window. No test puts a token at the window edge after a shrink, so A1 and A2 pass the suite at this head (measured: the PoC leaks at 88ec30b7).

"What this can never do" / "Compromised component", line by line

  • Bullet 1 ("Show… a token of a listed shape, written whole… through a path that calls redactCut/redactRecords"): false (A1, A2). Its sub-claim "a shape with a distinctive prefix is found after any character" is false for a JWT whose header is over 515 characters (A3).
  • "Show the body of a PEM or PGP private key…": held in my runs.
  • "Spend more than its window on a huge input at a cut site": held (measured, A6).
  • "Redact less than main for the three modules": held for A3 on the bench (measured). I found no narrowing.
  • Compromised component, agent user: "can no longer get a listed token into… logs in clear in the forms listed" is false through A1 and A2. It needs one END line or one large closed key in the same text, both of which an agent controls in a diff, a PR section or an attachment.
  • What remains, R12 line: "A percent-encoded … character before a token is now fine" needs scoping to distinctive prefixes (A4).

Required changes

  1. A1/A2, by design (principle 1): a cut site must never show the raw end of its read window. Suggested fix: end the window at a whitespace character and drop the rest. In redactCut, cut s.slice(0, max + CUT_MARGIN) back to its last whitespace before redacting. Do the same in readPart. A token, a URL's user info or a Bearer/Basic/JSON/secret-name value holds no whitespace, and a PEM key fails closed by its BEGIN. If the window has no whitespace past max, fail closed by not showing that last run. Add a test that fails at 88ec30b7. It should put a dashboard key, a ghp_ and a URL password at the window edge, after an orphan END line and after a closed key over 16 KB, at every site: redactCut at 2,000, 20,000 and 512 KB, and the journal and file log views.
  2. A3: match a JWT from the start of its run of [A-Za-z0-9_-], with no header bound, and judge in a function whether the run holds eyJ. Each start is then a run start, so it stays linear. Alternatively, correct the claim to state the 515-character header limit. A fix is preferred, since x5c headers are real.
  3. A4: scope the R12 sentence to distinctive prefixes, and add "a prefix-less shape after a letter, a digit or a percent-encoded separator" to What remains (principle 5).

Follow-up (not blocking, pre-existing on main)

  • A6 throw: wrap redactString so a RangeError fails closed. It should return a fixed placeholder rather than throw into the test run, the event log or the root updater. Add a test with a 6 MB run after each prefix. Suggest a separate issue.

Digest

  • Guardrail: one shared redactor for every output path. Distinctive prefixes match anywhere, broad mode masks the union of spans, and cut sites redact a bounded window.
  • It now blocks the glue, case and updater-narrowing routes from earlier rounds. It still allows a token cut at a window edge after a shrink, and a JWT with a long header glued to a letter.
  • Why: a cut site's raw window end can land inside what is shown, and the anchor-free JWT rule caps the header at 512.
  • Verdict: CHANGES NEEDED (head 88ec30b7).
  • Risk if wrong: a near-whole dashboard key, GitHub token or URL password is shown in the diff view, PR sections, chat attachments or the service log. No regression vs main, which cut before redacting.

…d, unscannable text fails closed (#889)

Round 5, on the fourth adversarial review (#921):

- readWindow: a cut site's read window (redactCut's max + 16 KB, a log record's 6,096) ends at
  its last whitespace, or is dropped when it has none. Redaction earlier in the window (an END
  line with no BEGIN, a closed key over 16 KB) could shrink the text until the window's raw
  end, a token cut too short for its rule, landed inside what is shown.
- A JWT is read from the start of its header's run with no length bound, and a function finds
  `eyJ` in the run, so a header over 512 characters glued after a letter, digit, `-` or `%3D`
  is found. Each start is a run start: linear.
- redactString catches V8's RangeError on a run of millions of characters and returns a fixed
  placeholder (the line count kept for redactRecords) instead of throwing.

Test (fails without): test/redact-window-edge.test.ts, 5 of 5 failing at 88ec30b.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (delta check, rounds 3 and 4, head 88ec30b7)

This is the delta final check of 88ec30b7 against the round-2 final check (issuecomment-6308008794) and the adversarial reviews r2 (issuecomment-6307840204) and r3 (issuecomment-6308839848). Labels: measured means from a command I ran, inferred means reasoned from the code, guess means a guess. Every run used a scratch HOME, state dir and TMPDIR, with recording stand-ins (exit 1) first on PATH, and token stand-ins only.

The head moved to a5c86ec2 (round 5) while this check ran. This verdict is for 88ec30b7. For each finding I say whether a5c86ec2's commit message says it is addressed. I did not check a5c86ec2.

Summary:

  • The updater, gh-login and the bench no longer narrow against main: 0 hits on 1.5M inputs per set (measured).
  • Default mode (redactString with no options, the path for events, comments, the digest and the dashboard) hides less than main on composed input. Each rule alone is the same or broader, but the rules apply in turn, and one rule's match now eats the next rule's anchor (measured). This is required change 1.

1. No narrowing against main (measured)

The round-2 fragment fuzz (both piece sets, 1.5M inputs each), main d2a549f4 against 88ec30b7:

Module frag real
updater redact() 0 0
bench scrub 0 0
gh-login safeLine 0 13, all from the 200-character cut
default redactString 813 2,345
  • gh-login: with main's TOKENISH rule applied without the cut, the head gives 0 hits on 1.5M inputs. So the 13 are secrets that main hid only by cutting the line off, not by its rule. The round-3 test makes the same distinction.
  • Default mode, the trend: the same fuzz against earlier rounds gives 452/262 at round 2 (8633356d) and 629/654 at round 3 (d0f4ab94). So this class predates round 4 and grows each round. Main against itself gives 0.
  • Default mode, realistic input: a second generator joins full-length shapes with separators only (space, -, ., +, _, :, ", a newline, an ANSI escape, \n), never with nothing between them. It still gives 979 hits in 300,000 inputs at the head (745 at round 2, 844 at round 3, 0 for main against itself).

Minimal cases, all measured. Main hides each secret. The head's default mode shows it. The head's broad mode and updater hide it:

Input (shape) What the head's default mode does
wld_<key>-redis://app:<pw>@db… The dashboard-key run takes -redis, the URL rule finds no scheme, and the password is shown
<jwt>.postgres://app:<pw>@db… The JWT's further dotted parts (round 3) take .postgres, and the password is shown
<jwt>.Bearer <tok> The JWT takes .Bearer, and the bearer value is shown
xoxb-…-lin_api_<40> and xapp-1-…-lin_api_<40> The Slack end check (?![A-Za-z0-9]) (round 3) takes lin, and the Linear key is shown as _api_<key>
Authorization: BEARER <tok>+postgres://app:<pw>@… The bearer rule (now /i, round 4) takes +postgres, and the password is shown
tskey-auth-<k>-Bearer <tok> The Tailscale run takes -Bearer, and the bearer value is shown
"a\nsk-ant-oat01-<40>-postgres://app:<pw>@…" The anchor-free sk-ant- (round 4) takes -postgres, and the password is shown
Bearer <s>eyJ…, Bearer <s>xoxp-…, sk-ant-<s>ghp_… (glued, contrived) An anchor-free prefix inside a value cuts it below its rule's minimum

Broad mode doesn't narrow because it masks the union of spans found on the original text. Default mode still applies the rules one after another.

The PR body's "Every default shape main redacted is still redacted by the same or a broader rule" is true rule by rule. Under "What this can never do", though, it reads as "redacts no less than main", and that is false for default mode (principle 5).

2. Distinctive prefixes with no left boundary: false positives (measured)

  • The repo's text (1,333 files, 282,752 lines: docs, code, tests, CI, .worklane):
    • Against round 3: 0 lines changed differently, and 0 files differ as one text. Round 4 changed nothing in the repo's own text.
    • Against main: 37 lines. All of them are stand-in keys in tests and CI fixtures (tskey-…, a Discord-shaped stand-in), the same set as earlier rounds.
  • node_modules (all 7,265 text files, 1,034,108 lines, 52 MB), against round 3: 0 lines and 0 files differ.
  • eyJ with a 512-character header on base64 blobs:
    • measured: inline source maps and other base64 in node_modules are unchanged (they are covered by the scan above).
    • inferred: standard base64 has no ., and the rule needs two dots, so a base64 blob alone can't match.
    • inferred, possible but not seen: a code identifier with eyJ in it followed by two dotted parts (keyJsonParser.prototype.parse). It would be partly masked. That is acceptable under the fail-closed policy.

3. Linear time (measured)

  • perf.mjs:
    • at 400 KB, the slowest case is 158 ms (broad, URL user info);
    • at 1 MB, it is 298 ms.
  • diffperf.mjs:
    • whole-text redaction takes 33 ms at 512 KB and 499 ms at 8 MB;
    • the site itself now uses redactCut (inferred from the diff).
  • logperf.mjs: 300 entries of 48 KB take 246 ms, and 10 entries take 9 ms (was 7.5 s).
  • My own 1 MB inputs: 90 hostile inputs (prefix fragments, cut-short shapes, BEGIN and END runs, escape chains, dotted runs, mask words, name and value runs).
    • Each of the 38 rules, with plain replace and with the broad 64-character restart: all under 500 ms.
    • The whole redactor in default, broad and identifier mode, and redactCut: two cases go over 500 ms, broad on eyJ… (1.78 s) and broad on AKIA… (0.97 s).
    • Their scaling from 125 KB to 2 MB is linear, about 2x per doubling: eyJ 199 → 402 → 923 → 1,712 → 3,330 ms. It is a constant-factor cost, not a stall.
  • KNOWN_SLOW: emptied in test/redact.test.ts, with nothing else exempted. A grep finds no other skip, and the new inputs only add (measured from the diff).

4. redactCut windows

  • The sites: decisionDiff, the PR section, chat attachments and the log records (readPart plus clip) all use the window (measured from the diff).
    • Other redactString(…).slice() sites in src/ are main's (setup-steps, post-deploy and others), with small inputs. Not this PR.
  • A key across the window's end fails closed (measured: the PR's key-at-window-edge test passes, and the logic agrees). If BEGIN is inside and END is past the window, the BEGIN-without-END rule masks to the window's end.
  • Gap (inferred, not run): a token at the window's raw end could be shown.
    • Redaction earlier in the window can shrink the text by more than the margin: an orphan END masks from the start, a closed key is longer than 16 KB, or a record's key is longer than 4 KB.
    • Then the window's raw end lands inside the shown part. A token cut there, too short for its rule, would show in part.
    • Main cut before redacting, so this is not a regression. It does contradict redactCut's "a token across the cut is redacted whole".
    • At a5c86ec2: the commit message says this is addressed ("readWindow … ends at its last whitespace, or is dropped"). Not verified.

5. The vendored updater copy (measured)

  • node scripts/gen-redact-vendor.mjs reports that the copy is current.

  • With in-memory fixtures (no repo file edited), the drift check fails for each of these:

    • the eyJ bound changed in redact.ts;
    • CUT_MARGIN changed;
    • a PATTERNS line removed;
    • the 8-bit CSI removed from GLUE;
    • one byte changed in the copy;
    • a line added to the copy;
    • the block removed.

    A comment-only edit still passes, as intended.

  • redact-vendored passes at the head.

6. Fail-without, re-measured (method (b), new files only, sha256 identical to the head's)

  • test/redact-escapes-windows.test.ts in a fresh worktree at d0f4ab94: it compiled cleanly.
    • 3 fail:
      • R1–R4 escapes: 1,424 diff lines, starting with 'a GitHub classic token, JSON-escaped ESC, CSI, via default';
      • the distinctive prefix after any character;
      • Bearer and Basic in any case: authorization: bEaReR bR5x….
    • 2 time out: the linear-time test and the window test, each killed by timeout 280 (exit 124).
    • 1 passes: the key-at-window-edge guard.
    • This matches the PR body.
  • Round 3's redact-fail-closed and redact-broad-union at 8633356d: 11 of 14 fail. Passing, as the body says: the gh-login and bench generated comparisons, and the linear-time guard. This matches.
  • At the head: redact-escapes-windows, redact-fail-closed, redact-broad-union, redact-vendored and redact pass, 34 of 34.

The round's findings

Finding Result at 88ec30b7
Final r2: the updater narrower than main, classes (a) to (c) Fixed: 0 narrowings for the updater, gh-login and the bench (measured).
Adv r2: escape glue (A9, A10) Fixed for every encoding in the r3 list (measured: the test passes, and it fails at d0f4ab94).
Adv r2: PEM fail-closed, PGP (A1, A3, A4, A1c) Fixed (measured: round-3 tests).
Adv r2: Basic (A5) Fixed (measured).
Adv r3: the quadratic URL rule (R6, R8) Fixed: a bounded scheme, KNOWN_SLOW empty (measured).
Adv r3: whole-input redaction at the cut sites Fixed by redactCut/readPart (measured timings). Gap in section 4 (inferred).
Adv r3: outside() Linear: one forward pointer (inferred from the code; measured through perf.mjs's mask-word cases at 1 MB, 191 ms).
Claims (Bearer, escapes, key formats, the 64-character restart) Corrected in the body (measured: the body text).

Weakening gate (measured from the diff)

  • Existing test lines changed in rounds 3 and 4: only test/redact.test.ts. It adds 5 hostile inputs and empties KNOWN_SLOW, which tightens.
    • dashboard-logs.test.ts:22 and tailscale-setup.test.ts:161 are unchanged since round 2, where they were judged not weaker.
    • test/token-standins.ts adds a Basic stand-in.
  • Changed PATTERNS/BROAD lines: each broadens alone (boundaries dropped, /i, the URL scheme bounded from any start).
    • Their combination in default mode narrows (section 1). The per-rule view the gate takes doesn't see that, which is why required change 1 asks for a test on composed input.

Other checks

Required changes

  1. Make default mode hide at least what main's default did on composed input (principles 1, 3 and 4). Fix the class by design, as round 3 did for broad mode. For example, find every PATTERNS match on the original text and mask the union of their spans: each span keeps its rule's marker where it stands alone, and merged spans get a generic one. Then no rule's match can eat another rule's anchor.
    • Add a test like redact-broad-union's for default mode. Keep main's PATTERNS as a fixture, and run the fragment fuzz plus a separator-only generator over full-length shapes.
    • It must fail at 88ec30b7, at least on the cases in the table in section 1.
    • At a5c86ec2: not addressed, as far as the commit message says (it doesn't mention default-mode order).
  2. Correct "What this can never do" so it no longer implies default mode redacts no less than main, until change 1 lands, and then restate it as measured (principle 5). At a5c86ec2: not known.
  3. The window edge (section 4): add a test that a token cut at the read window's raw end isn't shown after earlier shrinkage. At a5c86ec2: addressed by the commit message (readWindow, test/redact-window-edge.test.ts). Not verified here.
  4. When Merge policy: a check table or lint test registers itself where it's declared; only removing or loosening one waits (#869) [PROTECTED: owner merges] #886 lands: re-express this PR's registrations as marks, with none dropped. Merge LINT_TESTS with git: one runner for every engine git call, with replace refs and grafts off (#807) [PROTECTED: owner merges] #927 and Adversarial suite: attack cases for the agent guards, push guard, push gate, commit scan and redaction (#707) [PROTECTED: owner merges] #932, keeping both lines.

Not required (nit): src/dashboard.ts has the "A message cut to 2,000 characters, redacted first…" doc comment twice, the first copy orphaned above RECORD_SHOWN.

@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (delta check, round 5, head a5c86ec2)

This is the delta final check of a5c86ec2 against the previous reviewed head 88ec30b7. It covers the round-4 adversarial review (issuecomment-6309389199) and the round-3+4 delta check (issuecomment-6309685989).

Labels: measured means from a command I ran, inferred means reasoned from the code, guess means a guess. Every run used a scratch HOME, state dir and TMPDIR, with recording stand-ins (exit 1) first on PATH, and token stand-ins only. Main is d2a549f4, which is origin/main now.

Summary:

  • A1/A2 (the window's raw end), A3 (long JWT headers), A4 (the claim) and the RangeError follow-up are fixed (measured).
  • The new single JWT rule reopens a route main had closed. In default mode, a JWT after a . and a segment of 11 or more base64url characters is now shown. Main and round 4 hide it (measured). This is required change 1.
  • Default mode's narrowing against main is still there, and larger: 2,750 and 2,338 hits, up from 2,345 and 979 (measured).

The round-4 review's findings at a5c86ec2

Finding Result Label
A1: redactCut showed a token cut by its window after a shrink Fixed. The review's own edge.mjs, pointed at the head: 0 of 30 leak. At 88ec30b7 it is 30 of 30. measured
A2: the log views (readPart) Fixed. logedge.mjs: 0 of 4 (4 of 4 at 88ec30b7). logfile.mjs: not leaked. measured
Q1: can readWindow still show a cut token? No route found. My window.mjs tried 9 separators before the token: space, CRLF, NBSP, U+3000, U+2028, BOM, ZWSP, tab and none. With 6 shapes (wld_, ghp_, a URL password, Bearer, a JSON value, Discord) and 3 cut depths after an orphan END, 0 of 162 were shown. A window that is all one run comes back empty, so redactCut returns ''. measured
Q1, why it holds No default rule's token class takes whitespace. Each rule's right context (a closing quote, @, a lookahead) comes before the next whitespace. Where a construct does span whitespace, it fails closed: a PEM key by its BEGIN, and a Bearer, Basic or JSON value is dropped whole after its gap. Broad mode's secret-name rule does take a quoted value with spaces in it. But all 4 redactCut sites run in default mode (decision-detail.ts:148,359, chat-attachments.ts:375, dashboard.ts:896), so no cut site reaches that rule. inferred (the sites measured by grep)
A3: a JWT header over 515 characters, glued after x, 7, - or %3D Fixed for those glue characters, in default and broad mode. This is the PR's new test plus my jwt.mjs (header 600 after x: hidden at the head, shown on main and at 88ec30b7). But see required change 1. measured
A4: the R12 claim; A5 named Fixed in the body ("What remains" now has the A4/A5 line, scoped to distinctive prefixes). measured (body text)
A6 follow-up: RangeError redactString fails closed. A run of 5.5 MB or 50 MB returns [REDACTED: too large to scan] in 60 to 140 ms, in default and broad mode. Main and 88ec30b7 throw on an eyJ run. The updater copy has the same catch: the PR's test passes for the updater. secretKinds doesn't (required change 3). measured

Required change 1, in detail: a JWT after a dotted segment (measured)

The new rule starts at a run start and reads <run>.<run>.<rest>. Its function looks for eyJ only in the first run (the "head"). Take a JWT preceded by . and a run of 11 or more base64url characters, such as abcdefghijk., api.example-service., sess_0123456789. or /cb/abcdefghijklm.. That run becomes the head, and the JWT becomes the payload and the rest. The head has no eyJ, so the function returns the match unchanged. replace then moves on past the whole match, so the JWT inside is never matched.

  • jwt.mjs (work/delta-921-r5/) gives this:

    Input main default 88ec30b7 default head default head broad
    abcdefghijk.<jwt> hidden hidden SHOWN hidden
    api.example-service.<jwt> hidden hidden SHOWN hidden
    abcdefghijk.<jwt, header 600> hidden hidden SHOWN SHOWN

    The 10-character case is hidden everywhere.

  • jwtfuzz.mjs: I joined 100,000 JWTs (headers of 8 to 2,000 characters) with random pieces before and after them.

    • The head's default mode shows the signature main's default hides in 7,684 of them. Every example has a dotted segment of 11 or more characters before the JWT.
    • Broad mode shows what default hides in 0. So the 512 bound in BROAD opens no route that default closes (that answers Q2's second part).
  • The broad modules against main on this case (jwtmods.mjs): the updater, the bench and gh-login show nothing that main's versions hid. Main's updater shows the signature too. So the regression is in default mode: events, comments, the digest, chat notices, the dashboard and the cut sites.

  • The union-of-spans rework won't close it alone (inferred). The rule gives no span for that match, and spansOf restarts only 64 characters before the match's end, which is inside a long JWT.

The fuzz the coordinator asked for (round-3+4 delta's fuzz.mjs/gen.mjs, main d2a549f4)

Set 88ec30b7 a5c86ec2
frag, default mode, 1.5M 813 813
real, default mode, 1.5M 2,345 2,750
gen sep (separators only), 300k 979 2,338
updater, bench, gh-login rule (frag and real, 1.5M each) 0 0

All measured. I re-ran 813 and 979 at 88ec30b7 myself, so they match the delta check. Among the head's sample hits, one is xoxp-….eyJ….….…/ (a JWT after a dotted run). So at least part of the growth comes from required change 1 (inferred from the samples; I did not split the count by cause).

Q3: the RangeError placeholder, and secretKinds

  • redactString: fail-closed everywhere it is called (inferred).
    • A secret check redactString(s) !== s reads the placeholder as a secret.
    • A displayed or stored text becomes the placeholder.
    • redactRecords keeps the line count.
    • Only RangeError is caught; anything else is rethrown.
  • A new trigger with no prefix:
    • The new JWT rule reads every base64url run, so a plain run of about 5.3 MB or more, such as 'a'.repeat(5.5 MB), now overflows. That text becomes [REDACTED: too large to scan], where main and 88ec30b7 returned it unchanged (measured at 5.5 MB and 50 MB).
    • This is fail-closed, but it is a behaviour change, and the body doesn't name it.
  • secretKinds is unwrapped and now reachable with any 5.5 MB run:
    • On a plain 5.5 MB run, the head throws RangeError, where main and 88ec30b7 return [] (measured).
    • The caller is storeUpload (chat-attachments.ts:227), on any text attachment from a writer, under a 10 MB default cap. Its catch removes the part file, and the dashboard turns a non-UserFacingError into a fixed 500 (dashboard.ts:2916). So nothing is stored and nothing leaks, but the upload fails without a reason (inferred).
    • Compromised component says the agent "can no longer … make the redactor throw". That is false for secretKinds.

Q4: the updater's generated copy

  • node scripts/gen-redact-vendor.mjs reports "the redactor copy is current".
  • redact-vendored passes, and its byte comparison of the generated block is part of it.

Both measured.

Q5: false positives (fp.mjs from the round-3+4 delta, re-pointed)

  • The repo's text (1,334 files, 282,877 lines):
    • against 88ec30b7: 0 lines and 0 files differ;
    • against main: 37 lines in 11 files, all stand-in fixtures (tskey-…, Discord stand-ins). That is the same set as earlier rounds.
  • All of node_modules (7,265 files, 1,034,108 lines), against 88ec30b7: 0 lines differ.

All measured.

Q2: linear time (jwtperf.mjs, big.mjs, stack.mjs)

  • The JWT rule alone on a plain run takes 16 ms at 1 MB and 81 ms at 5 MB. On dotted runs, eyJ runs, x. pairs and short-payload chains it takes at most 34 ms at 5 MB. At 50 MB it overflows and the result is the placeholder (about 130 ms). (measured)

  • redactString:

    • At 5 MB, the slowest case is an eyJ run: 477 ms in default mode and 5.3 s in broad.
    • At 50 MB, plain words take 1.4 s in default mode and 4.9 s in broad. That is the broad mode's constant factor from round 3, scaling linearly.
    • The eyJ run in default mode got faster than at 88ec30b7: 373 ms against 1,689 ms at 4 MB.

    All measured.

Fail-without (method (b), measured)

  • test/redact-window-edge.test.ts was written with Write into a fresh worktree at 88ec30b7, sha256 2f9b224b… (identical to the head's).
  • 5 of 5 fail. The readWindow test fails on the missing export (tsc reports TS2339 but still emits). The others fail for the stated reasons: the leak lists, and "Got unwanted exception: default … RangeError: Maximum call stack size exceeded".
  • At the head it passes. 145 of 145 pass across 15 named files: redact-window-edge, redact-escapes-windows, redact-fail-closed, redact-broad-union, redact-vendored, redact, redact-shapes, redact-records-keys, redaction-one-module, dashboard-logs, update-redact, gh-login, decision-detail, chat-attachments and merge-policy.

Weakening gate (measured from git diff 88ec30b7 a5c86ec2)

  • Test lines: no existing test line was changed or removed. One file was added.
  • PATTERNS, a never-shrink table: the two JWT entries (main's anchored rule, and the anchor-free rule bounded to 512) are replaced by one. That shrinks the table's entry count, and it is narrower than main's rule (required change 1).
  • The body's "For the weakening gate" section doesn't list this round-5 change (principle 9).

Other checks

Round-3+4 delta check (issuecomment-6309685989): what still applies at a5c86ec2

  1. Default mode narrower than main: still applies, and larger (table above).
  2. Correct "What this can never do" and "the same or a broader rule": still applies, and now also for the JWT. The JWT rule alone is narrower than main's (measured).
  3. The window edge: addressed (measured above).
  4. The Merge policy: a check table or lint test registers itself where it's declared; only removing or loosening one waits (#869) [PROTECTED: owner merges] #886/git: one runner for every engine git call, with replace refs and grafts off (#807) [PROTECTED: owner merges] #927/Adversarial suite: attack cases for the agent guards, push guard, push gate, commit scan and redaction (#707) [PROTECTED: owner merges] #932 registry merge: still applies. git: an instance's git runs nothing its checkout's config names, and .git/ is the coordinator's (#926) [PROTECTED: owner merges] #944 and Remove the desktop app, its source, Tauri lockfile and docs; keep the desktop check one more release as a no-op (#744) [PROTECTED: owner merges] #746 now conflict there too.
  5. Nit: the duplicated "A message cut to 2,000 characters…" doc comment above RECORD_SHOWN (dashboard.ts:887) is still there.

Required changes

  1. The JWT rule must find every JWT main's rule found (principles 1 and 3). One way is to have the function look at every dot-separated segment of the match: redact from the first eyJ (with 8 or more characters after it) in a segment that is followed by a payload of 8 or more and a third part. Another is to keep main's anchored rule (^|[^A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.… beside the new one.
    • Add a test that fails at a5c86ec2. It should cover abcdefghijk.<jwt>, api.example-service.<jwt> and /cb/abcdefghijklm.<jwt>, with headers of 30 and 600, in default and broad mode.
    • Also add a JWT comparison against main's rule as a fixture, in the round-6 default-mode union test.
    • List the PATTERNS change under "For the weakening gate".
  2. Default-mode narrowing (the round-3+4 delta's change 1): still open. Round 6's union-of-spans test should include the JWT cases above. Report the three fuzz counts (frag, real, gen sep) against main; the target is 0.
  3. Wrap secretKinds like redactString. On a RangeError, return a kind such as too large to scan, so an attachment is refused with a reason (409) rather than a 500. Add a test with a 6 MB plain run. Otherwise, correct the Compromised-component claim and "What remains", which says "inferred, not run" but is now measured and reachable without a prefix.
  4. Body corrections (principle 5):
    • "A shape with a distinctive prefix (a JWT's eyJ included…) is found after any character" and "Every default shape main redacted is still redacted by the same or a broader rule" are false at this head (change 1).
    • Name that a plain base64url run of about 5.3 MB or more, with no prefix, now becomes [REDACTED: too large to scan] where main returned it unchanged.

Scripts (stand-ins only): /tmp/claude-1000/-home-jaydenv-worklane/e46f35b8-c30d-4292-9769-391731529ba3/scratchpad/work/delta-921-r5/ (jwt.mjs, jwtfuzz.mjs, jwtmods.mjs, window.mjs, big.mjs, jwtperf.mjs, fuzzrun.mjs, fprun.mjs, rerun.mjs).

… any dotted part, no stack overflow (#889)

Round 6, on the round-3+4 and round-5 delta checks (#921):

- Default mode is order-free: every shape runs on the text as it was (and from each glue point), and each run of
  overlapping spans is replaced once; a span that covers its run keeps its rule's marker, otherwise `[REDACTED]`.
  Then again on the result, up to 3 passes, as broad mode does. One rule's match can no longer take in the text
  another rule starts from (`wld_<key>-redis://u:<pw>@`, `<jwt>.Bearer <tok>`, `xoxb-...-lin_api_<key>`).
- The orphan-END rule decides in a function: an END line counts only when no BEGIN opened it, since the rules now
  read the text with each closed key still in it.
- The JWT rule reads every dotted part of a run as a possible header (`sess_0123456789.<jwt>`), and steps over its
  first two parts with a lookahead and backreference, so a plain run of millions of characters no longer overflows
  V8's backtracking stack (it is left as it was, as on main).
- secretKinds fails closed on a RangeError: it names the text `text too large to scan`, so an attachment is refused
  (409) until attached anyway.
- The dashboard's duplicate doc comment is gone. test/redact-default-union.test.ts's fixtures are registered
  never-shrink.

Tests (fail without): test/redact-default-union.test.ts and test/redact-unscannable.test.ts, 6 of 6 failing at
a5c86ec.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Second adversarial review, round 6: head 1eef3625. Verdict: CHANGES NEEDED

I built my own attack list before reading the earlier verdicts. All payloads were run against scratch copies and in-memory modules. Every redactor call used a scratch HOME and state dir. Main is origin/main, whose src/events/redact.ts is identical to d2a549f4.

Finding 1 (measured): default mode shows a JWT that main hides. This is a regression.

Shape (described generically, per #707): a JWT whose third part is empty, or starts with a character outside base64url, written right after a dotted name. Two examples are an unsigned (alg: none) token after name., and a token whose signature is standard base64 starting with + or /. The header plus payload must be longer than about 64 characters. Real tokens usually are.

Why it happens (inferred from the code, confirmed by the mutant below):

  1. The JWT regex (src/events/redact.ts:127) builds its run from non-empty dotted parts. So the match stops at the payload, and the empty third part is left outside it.
  2. jwtIn (:89-98) then sees only name, header and payload. It checks only name as a header, so it returns the match unchanged.
  3. spansOf (:435) then restarts at the match's end minus 64. That skips the dot before the header, so the run that starts at the header is never read.
  4. Main's rule ((^|[^A-Za-z0-9_-])eyJ…\.…\.[A-Za-z0-9_-]*) starts at that dot and hides header and payload.

Measured (a1-jwt-unsigned.mjs): 252 cases, built from 6 name prefixes × 7 header/payload sizes × 6 tails.

  • Main hides the payload in all 252.
  • The head's default mode shows it in 180. Broad mode shows it in 150, and redactCut in 180.
  • The chain fuzz found the same class: 29 of 20,000 chains, and 29 of 7,600 nestings (inside URL user info, Basic, wld_, JSON values and the other containers). No other class appeared.

secretKinds regresses too (measured): it names JWT in 0 of 252, where main names it in all 252. That includes the short cases that redactString does hide. secretKinds uses plain replace, so the run with no JWT in it is consumed and nothing is read again.

  • So a chat attachment holding such a token gets no 409 from our own rules. Main refused it. Whether gitleaks still catches it depends on the host (inferred).
  • The attachment's inlined body then shows the token for long payloads.

Paths affected: events, comments, the digest, the dashboard, PR sections, decisionDiff, chat attachments (both the gate and the body), and broad mode. Main's updater, gh-login and bench rules had no JWT rule, so the broad paths are not regressions.

Claims this makes false:

  • "A shape with a distinctive prefix is found after any character. That includes a JWT's eyJ … in any dotted part of a run."
  • "What one shape's match takes in doesn't hide another shape". Here the JWT rule's own declined match hides its next start.
  • The "Redact less than main" bullet is correctly scoped to the inputs tried, so it is not false as written. But this is a case outside them, and jwtfuzz and every JWT stand-in always have a non-empty signature.

Not caught by the PR's tests (measured): redact-default-union, redact-window-edge, redact-vendored and redact-unscannable pass 14 of 14 at this head.

A fix that closes it (measured on an in-memory mutant only, a1-mutant.mjs): let the run's tail take empty parts: (?:\.[A-Za-z0-9_-]*)* in place of (?:\.[A-Za-z0-9_-]+)*. jwtIn then sees the empty third part.

  • Shown: 0 of 252. secretKinds: 252 of 252.
  • 1 MB of a., a.., . and a.eyJ….. takes 9 to 152 ms, against the head's 7 to 119 ms.

Required changes

  1. Fix the JWT run so an empty or non-base64url third part ends a JWT, not the run. The mutant above is one way. Then check the class rather than just this instance: any rule whose function can return its match unchanged must not, through the 64-character restart, skip a start of the same rule inside that match. Today only the JWT rule can hit this, because its matches are long (inferred). The Stripe, Basic, JSON and orphan-END functions decline only matches that can't contain a new start of their own rule.
  2. Add a test that fails at 1eef3625: a JWT with an empty or +-led third part after x., a.b. and similar names, with header plus payload over 64 characters, through redactString, redactCut and secretKinds, compared with main's rule. Extend jwtfuzz and redact-default-union's JWT stand-ins to unsigned tokens.
  3. Correct the two claims above (principle 5). Also scope the "under 500 ms" line, per the next section.

Smaller, not blocking

  • Perf wording (measured): with every private-use character present, readAt reads each piece on its own. Adding a JSON escape every 2 characters then makes default mode take 535 to 621 ms at 1 MB, and broad 1.2 to 1.4 s. It scales linearly (138 / 248 / 535 ms at 0.25 / 0.5 / 1 MB), so it is not a DoS. But "the default redactor stays under 500 ms" holds only for the PR's 90 inputs.
  • Broad mode shows what default mode hides (measured, 6 of 30,000 tight chains, all Basic). A Basic value is glued to a following token, so its lookahead fails on the first read. Default finds it on its second pass. In broad mode, a broad "cut short" rule (xox?-… with _) glued before it has already masked the Basic keyword. Main's module rules had no Basic rule, so this is not a regression.

Attack table

# Attack (aimed at) Result
A1 JWT with an empty or non-base64url third part after a dotted name (any-dotted-part claim, restart) Leaks: 180/252 default, 0/252 secretKinds (measured)
A2 Chains of 3 to 7 shapes with 34 separators (Unicode spaces, CRLF, ZWSP, Cyrillic confusable, ESC, \n, %3D, none), 20k+40k; tight glue ('', _, -, x, 7, .), up to 10 shapes, 30k (union, 3-pass limit) Only A1's class (measured). Without it, 0 head-vs-main hits and 0 secretKinds losses. I found no marker-enabled chain longer than 2, so the 3-pass limit holds (inferred from the rules' boundaries)
A3 Superlinear: private-use exhaustion plus dense glue, _+prefix glue, tskey-/a.eyJ/Basic/Discord-shaped/URL/JSON-key runs at 0.25, 0.5 and 1 MB (union, readAt, freeMark) Linear everywhere (measured). Worst: default 0.62 s and broad 1.4 s at 1 MB (see perf wording)
A4 The updater copy against the module: generator check, plus 50k random compositions of redact() against broad mode with [redacted] Copy current; 0 differences (measured)
A5 Window edge: 6 shrinkers (orphan END, a closed key over 16 KB, 0x runs, composed wld_+URL, JWT runs) × 8 tokens × 8 whitespace kinds (NBSP, U+2028, U+3000, BOM, CRLF…) × 5 offsets × max 2,000 and 20,000 0 of 1,280 show a 10-character piece (measured)
A6 Throw: 6 MB runs after 14 prefixes × 7 run kinds, through 6 entry points; 100k short strings with lone surrogates, NUL, C1 CSI, $1/$&, PUA, with the mask $1 No throw, and the line count is kept (measured)
A7 Nesting: 19 shapes × 20 containers × 20 containers Only A1's class (measured)
A8 Marker and span maths: prefix/suffix stripping in spansOf with secrets starting [REDACTED, mask words inside secrets, back() at mark positions No leak. Output equals the rule's replacement (inferred from the code; A2 and A7 measured nothing else)
A9 Orphan-END function: an orphan before a closed key, a closed key before an orphan, nested BEGINs Hidden (inferred from the code; A5's orphan and closed-key shrinkers measured)

The other "What this can never do" and "Compromised component" lines held against what I ran. The exceptions are the two claims named under Finding 1.

Tests run (measured): at this head, redact-default-union, redact-window-edge, redact-vendored and redact-unscannable pass 14 of 14. node scripts/gen-redact-vendor.mjs says the copy is current. The scripts are in the review's scratch folder (adv-921-r6/), and the payload examples stay there.

@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (delta check, round 6, head 1eef3625)

This is the delta final check of 1eef3625 against the previous reviewed head a5c86ec2. It covers the round-3+4 delta check (issuecomment-6309685989) and the round-5 delta check (issuecomment-6309967139). Main is 9f9533aa. Its src/events/redact.ts, updater, gh-login, bench and chat-attachments are identical to d2a549f4 (measured: git diff --stat d2a549f4 9f9533aa on those files is empty).

Labels: measured means from a command I ran, inferred means reasoned from the code, guess means a guess. Every run used a scratch HOME, state dir, held-out dir and TMPDIR, with recording stand-ins (exit 1) first on PATH, and token stand-ins only.

Summary:

  • The union-of-spans rewrite works. On every earlier generator, default mode now shows 0 secrets that main hid (measured).
  • My own composed and nested generator (3 to 9 shapes, shapes nested in URL passwords, JSON values, Bearer values, keys and dotted names) found a new JWT gap. An unsigned JWT (<header>.<payload>., empty signature) is shown when it is not the first dotted part of its run: a.<jwt>., sess_0123456789.<jwt>.. Main hides it, and main's secretKinds names it JWT; the head's returns []. This is required change 1.
  • Private key, repeated BEGIN with no END (the coordinator's Receipts: signed, chained merge receipts anyone can verify offline (#717 slice 1) [PROTECTED: owner merges] #918 item): linear at this head. At 8 MB it takes 189 to 323 ms in default mode and 263 to 593 ms in broad mode. Main is quadratic on the same inputs. Details below.

The findings this round had to fix

Finding Result at 1eef3625 Label
R3+4 delta 1: default mode narrower than main (sequential rules eat each other's anchors) Fixed for every earlier generator. Main 9f9533aa against the head, default mode: fuzz frag 0, real 0, frag+ 0, real+ 0 (1.5M each); gen sep 0 and gen glue 0 (300k each). measured
R3+4 delta 2: the "never redacts less" claims Rewritten as "as far as measured". Two of its sentences are false at this head (change 1). measured (body text)
R3+4 delta 4: registry merge with #886/#927/#932 Still applies (merge-tree below). measured
R3+4 nit: duplicate doc comment in dashboard.ts Removed. measured (diff)
R5 delta 1: JWT after a dotted part of 11+ characters Fixed for signed JWTs. jwt.mjs gives hidden for all 9 cases in head default and broad. jwtfuzz (100k) gives 0 where head default shows what main hides, and 0 where broad shows what default hides. Not fixed for unsigned JWTs (change 1). measured
R5 delta 2: the three fuzz counts against main 0 / 0 / 0 (above). measured
R5 delta 3: secretKinds throws RangeError Fixed. ghp_ plus 6 MB gives ["text too large to scan"], and the attachment is refused with 409 (the PR's test passes). measured
R5 delta 4: claims, and the 5.3 MB plain run A plain run is now left as it was. big.mjs at 4, 5.5 and 50 MB: head default and broad return same for a plain run, an eyJ run and words. Main throws RangeError on the eyJ run at 5.5 MB and 50 MB. measured

The broad modules against main, 1.5M each (measured):

  • updater: frag 0, real 0;
  • bench: frag 0, real 0;
  • gh-login's rule without the cut: frag 0, real 0;
  • gh-login with the cut: real 13. These are the known 200-character-cut artefacts (the example matches the body's explanation).

Q1: is the union of spans order-free? (measured, then inferred)

I wrote a new generator, compose.mjs.

  • What it builds: 3 to 9 parts, from 22 full-length shapes and 11 nested forms:
    • a shape inside a URL password, inside URL user info, a JSON value or a Bearer value;
    • a shape inside a closed key, after an orphan END, or before an open BEGIN;
    • a shape after a dotted name or after a.b.<9>., token= or "token": ".
  • Joiners: 27 of them, among them nothing (weighted 3x), _, ESC sequences, \n, Bearer , ://, .eyJ, sk- and 0x.
  • What it counts: every secret occurrence the head's default shows more often than main's default.

Results:

  • 1M inputs, up to 9 parts: 32 hits.
  • The same run without the .eyJ joiner: 1 hit. A second run without it (1M inputs, up to 6 parts, another seed) gives 0. So 31 of the 32 come from a .eyJ… piece followed by a . and then a non-run character or the end. That is an unsigned JWT in a non-first dotted part (change 1).
  • The 1 remaining hit is main over-masking by accident, not a route main closed (inferred, from the full input).
    • In …://sk-<key>x-----END RSA PRIVATE KEY-----\nbearer …AIza<35>0x-----BEGIN…END…, main's sk- rule takes in the first key's -----END line, because - is a key character.
    • Main's BEGIN rule then runs on to the second key's END and swallows everything between. That includes an AIza-like value glued to 0x, which no rule on main or the head finds alone.
    • The head reads the END where it is. I don't count this as a regression; noted for the owner's judgement.
  • Order-free by design (inferred from replaceOnce/allSpans): every span is found on the original text and on the glue-marked copy. The union covers each rule's own span, so no rule's match can take another's anchor. The two classes above are a rule's own gap and a main side effect, not order.

Required change 1 in detail: an unsigned JWT after a dotted part (measured)

The rule is (^|[^A-Za-z0-9_-])(?=([A-Za-z0-9_-]+))\2\.(?=([A-Za-z0-9_-]+))\3\.[A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*.

  • Its tail (?:\.[A-Za-z0-9_-]+)* takes only non-empty parts, so a trailing . (the empty signature) is not in the match unless the JWT starts at the run's first part.
  • jwtIn then needs i + 2 < parts.length, so a header at part 1 or later with an empty signature has no third part and is passed over. Main's eyJ{8,}\.{8,}\.[…]* takes the trailing dot.

unsigned.mjs (<header 30>.<payload 40>., stand-ins) gives:

Input main default a5c86ec2 default head default head broad
token <jwt>. end hidden hidden hidden hidden
id=a.<jwt>. end hidden hidden SHOWN SHOWN
Cookie: s=sess_0123456789.<jwt>.; Path=/ hidden SHOWN SHOWN SHOWN
GET api.example-service.<jwt>. 200 hidden SHOWN SHOWN SHOWN
"a.<jwt>." / a.<jwt>. at the end hidden hidden SHOWN SHOWN
id=a.<signed jwt> end hidden hidden hidden hidden

The head's default output for the cookie case is Cookie: s=sess_0123456789.eyJ….eyJ….; Path=/. Main's is Cookie: s=sess_0123456789.[REDACTED JWT]; Path=/.

  • secretKinds on fatal: sess_0123456789.<unsigned jwt>. end: main gives ["JWT"], the head gives []. So a chat attachment main refused (409) is now stored without a warning (measured; the route is storeUpload, inferred).
  • The broad modules: the updater, the bench and gh-login show it on main too, so this is no regression there (measured with unsignedmods.mjs). The regression is in default mode (events, comments, the digest, chat notices, the dashboard, the cut sites) and in secretKinds.
  • A case with a two-character first part is new at this head. a.<jwt>. was hidden at a5c86ec2 and is shown now. Round 6's "every dotted part" walk replaced round 5's first-part test, so round 6 itself opened this one.

Q2: time (measured; this machine has 16 cores, and timings vary with load)

perrule1mb.mjs at the head, 1 MB:

  • No single rule and no default, identifier or redactCut run is over 500 ms.
  • Broad: 9 inputs over 500 ms, the slowest eyJ at 1,707 ms (run while a fuzz job was running).

scale6.mjs uses the same 90 units at 1, 5 and 50 MB, with each rule using spansOf's 64-character restart:

Input 1 MB 5 MB 50 MB
default ESC[ 288 to 697 ms (two runs) 1,706 to 2,842 ms 22,771 ms, then the placeholder
default ASIA 468 ms 1,603 ms 22,230 ms
default AKIA 350 ms 1,874 ms 18,808 ms
default \n (two characters) 574 ms 3,248 ms 18,670 ms, then the placeholder
slowest single rule (eyJ, BROAD's JWT) 386 ms 2,578 ms 19,739 ms
broad eyJ 1,413 ms 8,940 ms 75,485 ms
broad AKIA 1,660 ms 8,441 ms 60,642 ms
  • Nothing is superlinear. The worst time ratio per size ratio over all measured pairs is 1.64 (1 to 5 MB, base of 20 ms or more). From 5 to 50 MB each is about 10 to 14x.
  • It is slow by a constant factor: default mode at 50 MB takes about 20 s, against main's 0.2 s.
  • The body's "every rule and the default redactor stay under 500 ms" on 1 MB held in one run of mine and not in another (697 ms on ESC[). Say it as measured with a range.
  • New placeholder trigger (measured with escbig.mjs): a 50 MB run of escape sequences with no token prefix (ESC[, or \n as two characters) now becomes [REDACTED: too large to scan] after 16 to 20 s. Main returns it unchanged in 0.2 s. secretKinds returns [] for it on both. This fails closed, but the body names only "a token prefix and then millions of characters" (change 2). At 5, 10 and 20 MB it is returned unchanged.

The coordinator's item: repeated BEGIN with no END (measured, pem.mjs)

Shape main default, 1 → 2 MB head default, 1 / 2 / 4 / 8 MB head broad, 8 MB head secretKinds, 8 MB updater copy, 8 MB
BEGIN RSA…\n repeated 907 → 3,641 ms 32 / 46 / 96 / 204 ms 322 ms 206 ms 306 ms
BEGIN line + body line 1,012 → 4,034 ms 33 / 68 / 134 / 260 ms 408 ms 266 ms 404 ms
BEGIN glued (no newline) 3,587 → 14,816 ms 33 / 64 / 143 / 269 ms 403 ms 276 ms 428 ms
PGP BEGIN repeated 6 → 13 ms 25 / 52 / 116 / 242 ms 355 ms 203 ms 304 ms
one END, then BEGINs 975 → 4,139 ms 28 / 58 / 101 / 189 ms 272 ms 189 ms 297 ms
orphan ENDs repeated 5 → 11 ms 28 / 59 / 99 / 186 ms 263 ms 179 ms 314 ms
BEGIN END BEGIN (last open) 3 → 9 ms 55 / 84 / 170 / 323 ms 593 ms 207 ms 495 ms

Q3: the 3-pass limit (inferred from the code, then measured)

It fails open.

  • replaceUnion (and maskUnion) return the third pass's output without checking whether a fourth pass would change it.
  • So a chain that needs a fourth pass would leave what that pass finds unredacted.

No input I tried needs one:

  • compose.mjs checks the head's output for idempotence (redactString(out) === out): 0 non-idempotent outputs in 3M inputs.
  • idem.mjs glues 2 to 10 pieces with nothing between them (prefixes, tokens, markers, [, ], escapes, BEGIN/END, token=), in default and broad mode: 0 non-idempotent outputs in 300k.

Recommended, not blocking: by principle 1, after the last pass, if one more pass would change the text, either run to a fixed point or fail closed.

Q4: the weakening gate (measured)

  • Existing test lines changed this round: none. The round adds two files, redact-default-union.test.ts and redact-unscannable.test.ts. PR-wide against 9f9533aa, the changed existing test lines are the same three as before:

    • dashboard-logs.test.ts:22 (50 → 150);
    • tailscale-setup.test.ts:161 ([a key] → tskey-[REDACTED]);
    • redact.test.ts: hostile inputs added, KNOWN_SLOW emptied.

    None is weaker.

  • Registrations: CHECK_TABLES gains two never-shrink entries (MAIN_PATTERNS, CREDENTIAL_WORDS in test/redact-default-union.test.ts), and nothing is removed.

    • The gate's own tableLines finds both: MAIN_PATTERNS is 22 lines, from its declaration to ];. CREDENTIAL_WORDS is 1 line, so any edit to it removes that line and is flagged.
  • The fixture is verbatim: all 18 rule lines of MAIN_PATTERNS equal main's PATTERNS (comments dropped). So do credentialShaped, CREDENTIAL_WORDS, CREDENTIAL_ENDINGS, KEY_QUALIFIERS and credentialKey.

    • Recommended: KEY_QUALIFIERS and CREDENTIAL_ENDINGS in the fixture are not registered. Shrinking either would lower the baseline unnoticed.
  • PATTERNS changes this round: the JWT regex and jwtIn, and the orphan-END replacement as a function. Both are listed under "For the weakening gate". The JWT entry's "It finds at least what main's rule found" is false for unsigned JWTs (change 1).

Q5: the updater's generated copy (measured)

  • node scripts/gen-redact-vendor.mjs reports "worklane-update.cjs: the redactor copy is current". Its check compares the block in the updater with what the generator makes of src/events/redact.ts, byte for byte.
  • redact-vendored passes.
  • The copy behaves like the module on the unsigned-JWT case: it shows it in broad, as the module does.

Q6: false positives and size (measured)

  • The repo's text (1,336 files, 283,104 lines, fp.mjs):
    • against a5c86ec2: 1 line in 1 file, test/setup-steps.test.ts's Bearer sk-[REDACTED] → Bearer [REDACTED]. This matches the body.
    • against main: 38 lines in 12 files, all stand-in fixtures (tskey-…, Discord-shaped). That is the earlier set plus this one.
  • node_modules (7,265 files, 1,034,108 lines): 0 lines differ from a5c86ec2.
  • Size: tarball 1,492,626 (the body's figure), unpacked 4,996,122, 493 files. Against main 9f9533aa (1,484,470 / 4,971,214) that is +8,156 packed and +24,908 unpacked.

Fail-without (method (b), measured)

  • I wrote test/redact-default-union.test.ts and test/redact-unscannable.test.ts with Write into a fresh worktree at a5c86ec2. Their sha256 are a3b1b547… and 62bcd3db…, identical to the head's.
  • tsc reports TS2551 (UNSCANNABLE_KIND missing) and still emits.
  • 6 of 6 fail. The three default-union tests fail on their narrowing lists. The two secretKinds/attachment tests fail with RangeError: Maximum call stack size exceeded. The plain-run test fails on strictEqual.
  • At the head: 144 of 144 pass across 16 named files (redact-default-union, redact-unscannable, redact, redact-vendored, redact-broad-union, redact-fail-closed, redact-escapes-windows, redact-window-edge, redact-shapes, redact-records-keys, redaction-one-module, chat-attachments, merge-policy, dashboard-logs, decision-detail, update-redact).
  • I did not run gh-login or setup-steps locally: they set or rely on PATH with real tools. CI ran them.

Claims (principle 5)

  • "What this can never do":
    • "a JWT's eyJ, with a header of any length, in any dotted part of a run" is false for an unsigned JWT (change 1).
    • So is "Redact less than main, as far as measured". It is true for the generators it names, and false for mine.
    • "A text a rule can't scan (a token prefix and then millions of characters)" leaves out the 50 MB escape-sequence runs (change 2).
  • "Compromised component", agent user: "can no longer hide a token from its rule by writing another shape right before it (… name.<jwt>)" fails for name.<unsigned jwt>. (change 1).
  • "For the weakening gate", round 6 JWT: "It finds at least what main's rule found" is false (change 1).
  • "What remains": otherwise unchanged and consistent with what I measured.

Other checks

Required changes

  1. An unsigned JWT in a non-first dotted part must be found, as main's rule found it (principles 1 and 3).
    • The fix: let the regex take a trailing empty part. For example, the tail (?:\.[A-Za-z0-9_-]*)*: each repetition takes a ., so it stays linear (inferred). Or have jwtIn accept i + 2 === parts.length when a . follows the match. The result must cover secretKinds too.
    • A test that fails at 1eef3625:
      • inputs a.<jwt>., sess_0123456789.<jwt>.;, api.example-service.<jwt>. 200, "a.<jwt>." and a.<jwt>. at the end;
      • headers of 30 and 600;
      • checked in default mode, in broad mode and with secretKinds (which must give JWT).
    • The composed test: add an unsigned JWT to redact-default-union's SHAPES, or a .eyJ… piece followed by ..
  2. Body corrections (principle 5):
    • the four statements listed under "Claims";
    • the 50 MB escape-sequence placeholder: what triggers it, how long it takes, and that main returns the text;
    • the 1 MB "under 500 ms" figure, given as a measured range.

Recommended, not blocking:

  • the 3-pass limit should fail closed (Q3);
  • register KEY_QUALIFIERS and CREDENTIAL_ENDINGS of the fixture as never-shrink (Q4);
  • note the main side-effect case from Q1 in "What remains" or the notes.

Scripts (stand-ins only): /tmp/claude-1000/-home-jaydenv-worklane/e46f35b8-c30d-4292-9769-391731529ba3/scratchpad/work/delta-921-r6/:

  • new: compose.mjs, unsigned.mjs, unsignedmods.mjs, idem.mjs, pem.mjs, scale6.mjs, escbig.mjs, tables.mjs, fixture.mjs, mergetree.sh;
  • rerun.mjs re-points the round-4 and round-5 scripts at this check's worktrees.

…eclines nothing, passes fail closed (#889)

Round 7, on the round-6 second review and the round-6 delta (#921):

- The JWT rule is a plain replacement again: it starts only at a part holding `eyJ` (a lookahead looks in the part,
  so each part is read once), steps over the header and payload whole (no stack overflow on a huge run), and takes
  further dotted parts, empty ones too. An unsigned JWT (`alg: none`) or one with a `+`/`/`-led signature after a
  dotted name (`a.<jwt>.`) was shown and secretKinds missed it: the match stopped before the empty part, its
  function declined it, and the 64-character restart stepped past the JWT's own start.
- By design for every rule: a match a rule's function declines restarts the search at its next character
  (spansOf), so no start inside it is skipped. test/redact-declined.test.ts holds every function-backed rule to a
  reference that tries every start, fails when a new one isn't listed, and times 1 MB of declined text.
- The passes fail closed (fixedPoint): each mode passes over its own output until nothing changes, at most
  MAX_PASSES (8) times; a text still changing becomes the placeholder. Before, each stopped after 3 passes.
- The Basic value ends where its run and padding end, whatever follows: broad mode showed `Basic <v>==<more>` that
  default found only in a later pass.
- test/redact-default-union.test.ts adds unsigned and `+`-led JWTs and the `x.` and `a.b.` separators; its
  KEY_QUALIFIERS and CREDENTIAL_ENDINGS are registered never-shrink.

Tests (fail without): test/redact-declined.test.ts and test/redact-fixed-point.test.ts, 4 of 6 failing at 1eef362
(the other two are a timing guard and an idempotence guard).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
src/merge-policy-weakening.ts is main's (40ef312) byte for byte: #886 replaced CHECK_TABLES and LINT_TESTS with
in-file marks. Each table #921 registered there is now marked where it is declared, none dropped:

- src/events/redact.ts: PATTERNS, BROAD (and CREDENTIAL_WORDS, BROAD_RULES), never-shrink.
- scripts/machine/<cli>-update.cjs: the generator puts each of the module's table marks back before the same table
  in the copy (the transpile drops comments), so PATTERNS, CREDENTIAL_WORDS, BROAD and BROAD_RULES are marked.
- test/redact-records-keys.test.ts CUT_SHORT, test/token-standins.ts SHAPES, test/redaction-one-module.test.ts
  BAD_SOURCES: never-shrink; its GOOD_SOURCES: never-grow; that file carries the lint-test mark.
- test/redact-default-union.test.ts: MAIN_PATTERNS, CREDENTIAL_WORDS, KEY_QUALIFIERS, CREDENTIAL_ENDINGS,
  never-shrink.

Measured with the registry probe (central lists plus marks): 0 registrations lost against origin/main, 1128389e or
d2a549f.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Second adversarial review, round 7 (head fa1318fd): CHANGES NEEDED

I tried to break the shared redactor, not to re-check the diff. I wrote my own list of 10 attacks before reading earlier verdicts. Every input was a fake built in a scratch script. Main is origin/main b3176d3e; its redact.ts was transpiled in memory. Main's updater, gh-login, bench and tailscale rules were copied verbatim from origin/main.

Required changes

1. A URL password main hid is shown when the scheme is longer than 32 characters and its last 32 hold no letter (measured: a regression in default, broad, the updater and secretKinds).

  • Round 4 bounded the scheme: [a-z][a-z0-9+.-]{0,31}://, at src/events/redact.ts:148 (default) and :192 (broad). That also requires a letter within 32 characters of ://. Main's rules (\b[a-z][a-z0-9+.-]*://, default and the updater's) take a scheme of any length.
  • Measured on x + 40 digits + ://deploy:<pw>@db…, git + 33 - + ://…, and svn+ + 32 . + +0://u:<pw>@h/:
    • main's default shows deploy:[REDACTED]@, and main's updater shows [redacted]@;
    • head default, head broad and upd.redact() all show the password;
    • secretKinds gives main ["password in a URL"] and head [].
  • A 40-letter scheme is still hidden (measured), so only a scheme whose last 32 characters hold no letter is affected. These are unlikely in real logs (guess), but principle 3 still applies.
  • This makes two body claims false:
    • "Each default shape main redacted is redacted by the same or a broader rule";
    • "Root updater: … measured not to show anything main's rules hid".
  • A fix I measured in memory: the scheme's last 1 to 32 characters from any start, ([a-z0-9+.-]{1,32}:\/\/…, in both rules.
    • It hides all three cases in default and broad.
    • It contains main's rule: every match main makes has at least one scheme character right before :// (inferred).
    • Timing at 256 KB / 512 KB / 1 MB: a 1…1://u:p@h run takes 138 / 200 / 583 ms; a+.- 30 / 82 / 231; ab. 44 / 72 / 197.
    • Add a long-scheme case to redact-default-union's shapes or separators. No test catches this now (measured: the cases leak at a head whose CI is green).

2. The Tailscale login link is shown when it is glued after a token (measured: a regression).

  • src/tailscale-setup.ts:120 now runs redactString before the login-link replace. Main replaced the link first.
  • When a token's run takes in the https (tskey-…https://login.tailscale.com/a/<id>, the same with ghp_… or wld_…), the link regex no longer matches:
    • head shows tskey-[REDACTED]://login.tailscale.com/a/1a2b3c4d5e6f;
    • main shows [a key][the login link].
  • Fix: replace the link before redactString, as main did, or both before and after. Add a test with a glued token.

3. A rule can be narrowed through an unmarked helper with no hold (measured).

  • I fed weakeningReasons (src/merge-policy-weakening.ts:814) one-line edits to src/events/redact.ts, in memory. Each of these gave no reason:

    • dropping 'sk-' from GLUED_PREFIXES;
    • DASHBOARD_KEY_BODY {32,} → {33,};
    • basicShaped 16 → 24;
    • credentialShaped 32 → 40 hex;
    • api dropped from KEY_QUALIFIERS;
    • accesskey dropped from CREDENTIAL_ENDINGS;
    • CUT_MARGIN 16 KB → 1 KB.

    The control, a PATTERNS line narrowed in place, is held.

  • These are removals or changes, not the add-only weakening hold: add-only narrowing of a marked lint or guard passes (proposal; owner decision) #992 gap.

  • For GLUED_PREFIXES without 'sk-', measured with an in-memory mutant:

    • OPENAI_KEY_sk-proj-<key> and x_sk-proj-<key> are shown (head hides both);
    • the glued-shape test's own loop (redact-records-keys.test.ts:73-77 over SHAPES) still finds nothing, because SHAPES has no plain sk- key.
  • Fix:

    • Mark GLUED_PREFIXES, KEY_QUALIFIERS and CREDENTIAL_ENDINGS // check-table: never-shrink, and hold GLUE's alternatives as a marked array.
    • Add a plain sk- stand-in to SHAPES.
    • Say in the body that a scalar or predicate helper (DASHBOARD_KEY_BODY, basicShaped, credentialShaped, CUT_MARGIN) is held only by the file's lock and the tests, not by a mark.
    • The "Someone with push access" bullet should say the same.

4. Correct the Basic claim (measured; not a regression, since main has no Basic rule).

  • A Basic value of 12 characters is shown in default mode, broad mode and the updater. That is base64 of a 9-byte user:password: mixed case, no digit, +, / or =, so basicShaped declines it.
  • "Compromised component" says the agent user can no longer get "a Basic credential" out. Either add short Basic values (under 16 characters with no digit, +, / or =) to "What remains", or widen basicShaped without catching "Basic Authentication".

Attack table

# Attack (claim aimed at) Result Label
A1 A URL password after a scheme over 32 characters whose last 32 hold no letter ("same or broader rule", the updater) Shown in default, broad, the updater and secretKinds; main hides it (required 1) measured
A2 The Tailscale login link glued after a tskey-, ghp_ or wld_ token, through scrub (never shows what main hid) Shown; main hides it (required 2) measured
A3 Basic + a short base64 user:pass ("includes a Basic credential") Shown on every path; main shows it too (required 4) measured
A4 Defeat MAX_PASSES or the fixed-point loop: random composed inputs, counted with an in-memory mutant that records passes 350,000 inputs per mode: at most 2 passes (default 2,879, broad 2,805 needed 2). fixedPoint (redact.ts:499-507) returns only a text that a pass leaves unchanged, or the placeholder. I couldn't build a 3-pass chain: a marker for a prefixed token keeps its alphanumeric prefix, so a right boundary is never made. No route. measured, plus inferred for the chain argument
A5 Super-linear time: 16 families. They include every private-use character (the piece mode in readAt), dense \\n, dense _sk- and _Bearer, a declined JSON chain, declined Stripe and Basic, orphan END and BEGIN without END, 2-pass units, escape runs, dotted and eyJ runs, and scheme-like runs About 2x per doubling from 0.25 to 2 MB in each family. The slowest was 1,357 ms at 2 MB (broad mode, private-use plus \\n). No route. measured
A6 Make the engine and the updater copy disagree redact-vendored passes 3 of 3; upd.redact against redactString({broad, mask:'[redacted]'}) on 30,000 composed inputs: 0 differ measured
A7 13 JWT forms (padded, +//, in a query or fragment, wrapped, JSON, JWE, unsigned, after %3D or ESC) and 5 PEM/PGP forms (CRLF, \n-escaped in JSON, two keys, an orphan END then a BEGIN with no END) Head shows nothing main hid on any of them, default or broad measured
A8 A later narrowing without a hold (the marks) Not held for 7 helper edits; GLUED_PREFIXES leaks and the test misses it (required 3) measured
A9 A declined match or the 64-character restart hiding a second match of the same rule No new route. The only case I built is one main shows too. inferred
A10 A span's common-suffix trim (a password ending in CTED]) showing characters The text shown is the marker itself; no information leaks unless spans overlap. Contrived. inferred

"What this can never do" and "Compromised component", line by line

  • "Show … a token of a listed shape": false for a URL password after a long, letterless scheme end (A1). On every other input I tried, it held (A4, A5, A7).
  • "Redact less than main" (scoped to the generators): true as scoped. The general line "each default shape main redacted is redacted by the same or a broader rule" is false (A1).
  • "Root updater: measured not to show anything main's rules hid": false (A1).
  • "Agent user … includes a Basic credential": partly false (A3).
  • "Someone with push access: removing a shape or a broad rule waits": true for the marked tables' lines. A narrowing through a helper does not wait (A8).
  • "A hostile input author": consistent with A4 and A5.

Head checked: fa1318fd0aaea5fbe0de1fbbec9637f4a347ed1c. Scripts: p1 to p8 in my scratch dir. Nothing here was run against a real system or with real tokens.

@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: PASS (delta check, round 7, head fa1318fd)

This is the delta final check of fa1318fd against the previous reviewed head 1eef3625. It covers:

  • the round-6 second review (A1, Basic in broad mode, the pass bound);
  • the round-6 delta's required and recommended items;
  • the owner's decision of 2026-10-21 on src/merge-policy-weakening.ts.

Main is b3176d3e (#899 merged). Its src/events/redact.ts, updater, gh-login and chat-attachments are identical to d2a549f4 (measured).

Labels: measured means from a command I ran, inferred means reasoned from the code, guess means a guess. Every run used a scratch HOME, state dir, held-out dir and TMPDIR, with recording stand-ins (exit 1) first on PATH, and token stand-ins only.

1. The owner's condition: 0 registrations lost (measured)

I counted with main's registrationsOf, central lists plus marks (issue-807-r4/registry-probe.mjs), on the head's built worktree.

Compared with Registrations there Lost at the head Head has more
head fa1318fd 95 n/a n/a
origin/main at the merge base 40ef312a 78 0 17 (#921's marks)
1eef3625 (previous head) 87 0 8
1128389e (unpushed first round-7 commit) 89 0 6
9da2067f (unpushed merge) 95 0 0
d2a549f4 75 0 20
origin/main b3176d3e 79 1: src/review.ts HARNESS_CONFIG_GLOBS 17
merge-tree of b3176d3e and the head (tree 3eb1e4c7) 96 0 against b3176d3e n/a
  • The one entry the head lacks against b3176d3e is Sandbox: the agent's home is read-only to a project command, so it can't plant config for a later agent-user process; harness-config review locked (#798) [PROTECTED: owner merges] #899's own new table. Sandbox: the agent's home is read-only to a project command, so it can't plant config for a later agent-user process; harness-config review locked (#798) [PROTECTED: owner merges] #899 merged after this head's merge base. The merged tree keeps it, so the merge loses nothing.
  • Same kind as the earlier central entry: yes. The probe's key is path table kind, so a kind change would count as lost, and 0 were lost against 1eef3625 and 1128389e. The new marks (CREDENTIAL_WORDS and BROAD_RULES in the module, BROAD in the copy, KEY_QUALIFIERS and CREDENTIAL_ENDINGS in the fixture) are all never-shrink. Each only adds a hold.
  • The updater copy's marks are read: registrationsOf on scripts/machine/worklane-update.cjs gives CREDENTIAL_WORDS, PATTERNS, BROAD and BROAD_RULES, all never-shrink, with no problems. MARKABLE matches .cjs. tableLines finds each table in the copy (33, 10, 1 and 1 lines).
  • Removing a mark is held (markhold.mjs, linthold.mjs, with main's weakeningReasons on in-memory texts):
    • On the copy and on the module, each of the 4 marks gives registration-gone when it is removed, when its kind is changed, and when it is detached by a blank line.
    • Removing every mark is held as well.
    • A rule line removed from PATTERNS or BROAD is held as a table entry.
    • An entry taken out of the one-line tables ('token' from CREDENTIAL_WORDS, SHAPE_RULES from BROAD_RULES) waits as "unreadable".
    • A harmless added comment line gives no reason. The unchanged text gives none either.
    • The marks in the test files are held too: SHAPES, CUT_SHORT, BAD_SOURCES/GOOD_SOURCES, the lint-test mark and the 4 fixture tables. A line removed from the lint test waits as a guard line.
  • The generator takes the copy's marks from the module's marks (scripts/lib/redact-vendor.mjs marksIn). It throws if a marked table is missing from the copy. node scripts/gen-redact-vendor.mjs says the copy is current (measured).
  • src/merge-policy-weakening.ts is identical to origin/main: git diff origin/main fa1318fd on it is empty. It is not in the PR's file list.

2. The rebuilt history (measured)

  • The trees of fa1318fd and 9da2067f differ only in test/redact-declined.test.ts (3+/2−). 21952721 and 1128389e differ by the same hunk. The parents match: 21952721 and 1128389e both sit on 1eef3625, and both merges take 40ef312a.
  • What the hunk changes:
    • 'sess_0123456789.' becomes ['sess', '0123456789.'].join('_'). That is the same string.
    • The Basic piece 'dTpDR2g4NjQxNEE3' (base64 of u:CGh86414A7) becomes Buffer.from('u:Ch8641A7E2').toString('base64') = dTpDaDg2NDFBN0Uy. The shape is the same: 16 base64 characters with no padding, from u: plus a 10-character password.
    • Not just an assembly: the prefix 'token=a.' was also replaced by 'id=a.'.
  • Does the test still test the same thing? Yes (inferred). id=a. puts the JWT after a dotted name, as token=a. did, without a secret name in front that another rule could hide it by. So it tests the JWT rule at least as strictly. The token=a. case is still covered by the review's a1-jwt-unsigned probe: 0 shown at this head (measured below). But it is no longer in the test, and the body doesn't mention the swap (recommendation 2).

3. The fixes

The probes were re-run at this head against main b3176d3e. Main's redactor was transpiled in memory from b3176d3e:src/events/redact.ts.

Probe Result at fa1318fd Label
Review's a1-jwt-unsigned (252 cases) Shown where main hides: default 0, broad 0, redactCut 0. secretKinds names JWT in 252/252, as main does. measured
Delta's unsigned.mjs (7 cases) All hidden at the head in default and broad. 1eef3625 shows 5 in both modes, as round 6 found. The cookie case gives sess_0123456789.[REDACTED JWT], the same as main. measured
Review's a2-chains (20k, and 60k with another seed) Head shows what main hides: 0. Broad shows what default hides: 0. secretKinds loses a main kind: 0. measured
a2-chains tight glue, up to 10 shapes (30k and 100k) 0 / 0 / 0. That covers item 4: Basic in broad mode is 0 here. The round-6 review measured 6 in 30k at 1eef3625. measured
Delta's compose (300k and 1M, up to 9 parts) Not idempotent: 0 and 0. A further pass hides: 0. Threw: 0. Narrowing: 0 and 1 (see below). measured
Delta's idem (300k) Not idempotent: 0 default, 0 broad. measured
  • The 1 compose hit is not a regression. It is the same class as round 6's Q1, where main over-masks by accident. In the input, main's BEGIN rule runs past the first key's END to a later one and swallows everything between, and that includes a sk- key glued right after a Slack webhook path.

    • I ran that piece on its own (hit1.mjs): https://hooks.slack.com/services/…sk-<key>, with /0x…, a space or the end after it, and with a closed key before it.
    • Main, 1eef3625 and the head all show the key in default and broad, and secretKinds gives only Slack webhook on all three.
    • It is the known "prefix-less shape glued after a letter or digit" gap listed in "What remains" (measured).
  • The JWT rule is now a plain replacement:

    (^|[^A-Za-z0-9_-])(?=([A-Za-z0-9_-]*?)eyJ)\2eyJ(?=[A-Za-z0-9_-]{8})(?=([A-Za-z0-9_-]+))\3\.(?=[A-Za-z0-9_-]{8})(?=([A-Za-z0-9_-]+))\4\.[A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]*)*
    

    The lazy lookahead takes the first eyJ in a part. It is atomic, but no later eyJ in the same part could have 8 characters after it if the first can't, so nothing is lost (inferred). It finds what main's anchored rule found, and also any eyJ inside a part.

  • Declined-match restart (spansOf: g.lastIndex = m.index + 1):

    • It is present and correct (read).
    • The reference test: redact-declined's test 2 lists every secret-group function-backed rule (Stripe, JSON key, Basic, orphan END) and fails on a new one. Each rule is checked against an every-start reference.
    • A mutant without the restart line (measured, mutant.mjs): I took the line out of the head's built module, in memory. That gives round 6's restart at the match's end minus 64. The mutant still shows 0 misses, both in the test's own loop (4,000 per rule) and in a wider one (40,000 per rule, with pieces over 64 characters). So with today's rules the restart is defence in depth, and no test fails without it. That fits round 6's inference that only the JWT rule could hit this, and the JWT rule now declines nothing.
    • The test holds the property (nothing a rule accepts from any start is shown) for every function-backed rule, now and when one is added. So this is not blocking (recommendation 3).
    • BROAD's replacements are all strings, and IDENTIFIERS don't go through spansOf (read).
  • fixedPoint / MAX_PASSES = 8:

    • A text still changing after 8 passes becomes UNSCANNABLE, with line breaks kept when asked (read and tested). That is fail closed.
    • Both modes and the updater's copy use it: the copy is generated from the module and is current.
    • A text the first pass doesn't change still takes one pass, and a text that settles after one change takes two. That is no more than round 6's 3-pass loop for every input measured, since none needs a third pass (inferred).
  • Timing (perf7.mjs, 0.5 / 1 / 2 MB; head default / broad / secretKinds) is linear: each roughly doubles per doubling.

    • The review's private-use input plus \n every 2: d 279 / 642 / 1,313 ms, b 330 / 657 / 1,533 ms. This is the slowest, and it is the same as 1eef3625 (282 / 590 / 1,315 ms).
    • Round-7-specific inputs, at 2 MB:
      • one 2 MB part with eyJ at its end: d 170 ms;
      • xeyJ repeated in one part: d 188 ms, b 936 ms;
      • short-header and short-payload runs, a JWT followed by 2 MB of . or a., glued JWTs: d 68–184 ms;
      • long declined Basic, Stripe and JSON values, Basic and JSON quote chains, repeated orphan ENDs: d 57–235 ms, b 73–382 ms.
    • Main is quadratic on a.eyJ… runs: 17 s at 0.5 MB, against the head's 44 ms.

4. Fail-without (method (b), measured)

  • I wrote test/redact-declined.test.ts and test/redact-fixed-point.test.ts with Write into a fresh worktree at 1eef3625. Their sha256 are d6e9be2c… and 511b7db1…, identical to the head's.
  • tsc is clean, because the test reads fixedPoint through a cast.
  • 4 of 6 fail there, as the builder reported:
    • the unsigned/+-led JWT test fails with AssertionError … strictly deep-equal;
    • the function-backed-rule list fails, because the JWT rule had a function at 1eef3625;
    • the bound test fails with AssertionError … strictly equal;
    • the "more than 3 passes" test fails with TypeError: fixedPoint is not a function.
  • The 1 MB timing guard and the idempotence guard pass there too.
  • At the head: 161 of 161 pass across 18 named files: redact-declined, redact-fixed-point, redact-default-union, redact-unscannable, redact, redact-vendored, redact-broad-union, redact-fail-closed, redact-escapes-windows, redact-window-edge, redact-shapes, redact-records-keys, redaction-one-module, chat-attachments, merge-policy, dashboard-logs, decision-detail and update-redact.

5. The weakening gate (measured)

  • Existing test lines changed this round: none removed. redact-default-union gains 2 shapes and 2 separators, with additions only.
  • PR-wide against the merge base: the changed existing test lines are the same three as round 6. They are dashboard-logs.test.ts (2+/1−), redact.test.ts (4+/2−) and tailscale-setup.test.ts (1+/1−), and round 6 judged none of them weaker.
  • PATTERNS changes this round: the JWT and Basic rules. Both are listed under "For the weakening gate", and each broadens (inferred from the regexes; the probes above measured 0 narrowing).

6. Claims (principle 5)

  • "What this can never do":
    • the JWT bullet (any dotted part, empty or +//-led signature) holds on everything I ran;
    • "Redact less than main, on the inputs measured" holds: 0 narrowing, apart from main's over-mask case above;
    • the placeholder bullet now names the 50 MB escape runs and the 8-pass bound.
  • "Compromised component":
    • Agent user holds.
    • Someone with push access: "Removing a line from the lint test waits (LINT_TESTS)" is stale wording. The hold now comes from the file's lint-test mark, and main's LINT_TESTS is empty. The claim itself holds, since the removal waits (measured above) (recommendation 1).
  • "What remains": unchanged and consistent with what I measured.

7. Other checks (measured)

Required changes

None.

Recommended, not blocking

  1. In "Compromised component", say that the lint test's lines are held by its lint-test mark, not LINT_TESTS (principle 5).
  2. In the round-7 notes, say that the history rebuild also replaced the token=a. prefix with id=a. in redact-declined, beyond assembling two literals at run time. If you want token=a. covered in the test again, assemble it at run time.
  3. The declined-match restart is not held by a failing test. Removing it changes no result with today's rules (measured with the mutant). A white-box test, such as a fixture rule through an exported spansOf seam, would hold the mechanism as well as the property.
  4. Merge order with tool guard: protected-path checks decide on the file a write really reaches; redact secrets in keys [PROTECTED: owner merges] #976: the second to land regenerates the updater copy (measured above).

Scripts (stand-ins only) are in /tmp/claude-1000/-home-jaydenv-worklane/e46f35b8-c30d-4292-9769-391731529ba3/scratchpad/work/delta-921-r7/: markhold.mjs, markhold2.mjs, linthold.mjs, mutant.mjs, perf7.mjs, hit1.mjs, vendor-merged.mjs, mergetree.sh and load7.mjs. The earlier rounds' probes were re-pointed through load7.mjs.

…s, helper tables marked (#889)

Round 8, on the round-7 second review (#921):

- The URL-password rule and the broad user-info rule read the last 1 to 32 characters of the scheme, with no letter
  required: a scheme longer than 32 characters whose last 32 are digits, `-` or `.` hid the password from both, where
  main's rule (a scheme of any length) found it. Still linear (each `://` is reached from at most 32 starts).
- src/tailscale-setup.ts replaces the login link before redacting, as main did: a token glued before the link
  (`tskey-...https://login...`) took in its `https` and the link was shown.
- basicShaped also takes a value that decodes to printable text with a `:` (a short user:password with no digit, `+`,
  `/` or `=`); prose ("Basic Authentication") doesn't decode to that.
- GLUED_PREFIXES, KEY_QUALIFIERS, CREDENTIAL_ENDINGS and the glue sequences (now GLUE_PARTS, an array) carry
  never-shrink marks, in the module and, through the generator, in the updater's copy. SHAPES has a plain `sk-` key.
- test/redact-default-union.test.ts adds a long-scheme URL password.

Test (fails without): test/redact-round8.test.ts, 4 of 4 failing at fa1318f.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
No conflicts. #976 adds redactKeys to src/events/redact.ts; the updater's copy is regenerated
(node scripts/gen-redact-vendor.mjs --write) so test/redact-vendored.test.ts holds it to the module.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Second adversarial review, round 8 (head 1356f047): CHANGES NEEDED (body only)

I tried to break the shared redactor, not to re-check the diff. I wrote my own list of 10 attacks before reading the round-7 review. Every input was a fake, built in a scratch script. Main is origin/main cec056a5. Its redact.ts was transpiled in memory, and its updater rule and Tailscale scrub were copied verbatim. The updater's copy was loaded from the head's scripts/machine/<cli>-update.cjs.

No route found (measured). On every input I built, the head shows nothing main hid, in default, broad, the updater, secretKinds, redact() keys and the Tailscale scrub. The engine and the updater copy never disagree, and the new rules stay linear. Two body claims are false, and both are text fixes.

Required changes

1. "Someone with push access": the list of what a mark doesn't hold is incomplete (measured).

  • The body names four helpers as "not held by a mark": DASHBOARD_KEY_BODY, basicShaped, credentialShaped and CUT_MARGIN. But a marked table's effect can also be removed by editing or deleting the line that applies it, and that line doesn't name the table.

  • I fed each edit below to weakeningReasons (src/merge-policy-weakening.ts:814) in memory, with the exact removed and added lines. Each gave no reason. I then ran the same mutant, transpiled in memory, on fixtures. Each one shows a fixture the head hides:

    Edit (src/events/redact.ts) Marked table it disables Newly shown
    delete the _ loop in gluePoints (:328) GLUED_PREFIXES MY_sk-proj-…, MY_sk_live_…
    const cuts = [...s.matchAll(GLUE)]… → const cuts: number[] = [] (:327) GLUE_PARTS ESC-glued Bearer …
    delete readAt(s, gluePoints(s), rules, spans) (:475) both all three above
    rules.forEach(…) → rules.slice(0, 3).forEach(…) in allSpans (:474) PATTERNS (default and broad) JSON key values, short Basic, URL password
    if (words.some(credentialWord) || …) → if (last === 'auth') in credentialKey (:65) CREDENTIAL_WORDS, CREDENTIAL_ENDINGS {"db_password":"…"}
    credentialKey's last line → return false (:67) KEY_QUALIFIERS {"apikey":"…"}
    basicPair → return false (:34; round 8's new predicate, not in the body's list) none (helper) Basic dXNlcjpwYXNz
    control: SHAPE_RULES from PATTERNS.slice(1) PATTERNS held (table-used)
  • Fix:

    • Say that a mark holds a table's own lines only.
    • Say that anything outside them is held only by the file's lock and the tests: the lines that apply a table, and every helper. That covers gluePoints, allSpans/readAt, credentialKey, basicPair and the four already named.
    • On main, redact.ts carries no mark at all, so this is not a regression (measured: git grep -c check-table origin/main -- src/events/redact.ts gives nothing).
    • A by-design hold for consumers belongs to Merge policy: a check table or lint test registers itself where it's declared; only removing or loosening one waits (#869) [PROTECTED: owner merges] #886 (a follow-up, not this PR).
    • Whether the existing tests catch each mutant: inferred yes. I did not run them, since that would mean planting the mutant in src/.

2. "Compromised component → Agent user": a short Basic value is still shown when its decoded pair isn't printable ASCII (measured; not a regression, since main has no Basic rule).

  • The body says "since round 8 a short one too, base64 of a short user:password". basicPair accepts only [\x20-\x7e] around the :.

  • So a short pair with a UTF-8 password is still shown. I measured this on 100,000 random pairs: a 2 to 6 letter user, and a 3 to 10 letter password with one of äöüßéñçøåï, base64 with standard padding. 1,531 are shown in default mode (examples: ecs:éuim, iag:çiuq, qge:uévi). The ASCII-only control: 0 of 100,000 shown.

  • Fix: do one of these:

    • add it to "What remains" ("a short Basic value whose pair isn't printable ASCII");
    • or widen basicPair to decode the bytes as UTF-8 (TextDecoder with fatal: true) and accept any text with a : and no control characters.

    On the 11,821 distinct base64-shaped runs from the repo's own text (src, docs, test, scripts, web) that the round-7 basicShaped declines, the UTF-8 variant matches the same 2 as round 8 (dXNlcjpwYXNz and diskOnly), measured. A plain Latin-1 widening (\x80-\xff) matches 89, so don't use that one.

Attack table

# Attack (claim aimed at) Result Label
A1 URL user info after the scheme change. 38 forms: upper case, 40-character letter, digit and - schemes, after _ or ESC, %40 in user or password, IPv6 hosts [::1] and [fd00::1]:8080, : ; [ ? # \ in user info, git+ssh, jdbc:, mongodb+srv, and after a JWT, wld_, Bearer or Basic The head hides every case main hides, in default, broad, the updater and secretKinds. It hides more: digit and - schemes, after _, after ESC. measured
A2 The scheme rule contains main's ([a-z0-9+.-]{1,32}:// against \b[a-z][a-z0-9+.-]*://) Every main match has a scheme character right before ://, and the part after :// reads the same deterministic run (no : or / in the user, no @ or / in the password). So the same end is reached from a start at most 32 back, and the 64-character restart can't skip a later ://. inferred, plus A8
A3 Basic over-match ("Authentication" etc.): 11,821 distinct base64-shaped runs from the repo's text, and 21 "basic …" phrases 2 newly redacted (dXNlcjpwYXNz, diskOnly); 0 phrases changed. No log breakage. measured
A4 Basic under-match (the round-8 claim): 352 fixed pairs (ASCII, UTF-8, tab/CR/space; padded and unpadded), NBSP/ideographic space, base64url, a glued tail, then 200,000 random pairs 0 of 352 shown; 1,531 of 100,000 UTF-8 short pairs shown (required 2) measured
A5 Narrow a marked table without a hold, by its consumer line 7 edits not held, each newly showing a fixture; the control is held (required 1) measured
A6 Super-linear time: 13 families at 0.5 / 1 / 2 MB through default, broad, the updater, redactCut and secretKinds. They include a dash run then ://, repeated 32-character near-schemes, a://u: then a password with no @, @ chains, Basic with 2 MB values (pair and non-pair), dense basic /basic, ESC-glued Basic, and tskey-https://login… runs About 2x per doubling everywhere. The slowest is 511 ms at 2 MB (broad, dash run). measured
A7 Make the engine and the updater copy disagree after the regen The copy is byte-identical to a fresh vendoredBlock(). On 60,000 composed inputs × 6 option sets, plus secretKinds, redact() with secret keys, and redactCut: 0 differ. Node ≥ 22.13 (engines), so atob is global in the updater. measured
A8 Regression fuzz aimed at round 8: URL pieces with 19 scheme forms, IPv6 and @h hosts, Basic, Tailscale keys and links glued both ways, wld_, JWT, sk-, ghp_ and Bearer, joined by 22 separators 150,000 inputs: 0 in each of six checks. Default, broad (against main's default and updater), the updater, scrub, secretKinds and redact() keys show nothing main hides. measured
A9 Glue around the new shapes: 31 glue forms (every GLUE encoding, OSC ESC ] … ESC \, BEL, _, MY_, JSON escapes, punctuation, CRLF) before a short Basic, letterless long-scheme URLs and a plain URL 0 shown in default, broad and the updater; 0 where main hides and the head shows measured
A10 redactKeys interplay: a secret as an object key next to values, and colliding redacted keys Covered by A7 and A8 (redact({[s]: …})): 0 regressions, 0 copy differences. Numbering adds (2), never key text. measured

"What this can never do" and "Compromised component", line by line

  • "Show … a token of a listed shape …": held for every shape and glue I built (A1, A8, A9).
  • "Redact less than main", and the round-8 correction ("every match main's rule made"): true on A1, A2 and A8.
  • "Agent user … a Basic credential (since round 8 a short one too)": partly false (required 2).
  • "Root updater: … shows nothing main's rules hid": true on A1, A7 and A8.
  • "Someone with push access": the marked-table lines are held. The "not held" list is incomplete (required 1).
  • "A hostile input author": consistent with A6. A small wording point: "each start reads at most 33 characters" holds for the scheme only. A start that reaches :// also reads the user info, and up to 32 starts read the same user info. That is still linear (measured).

Fail-without for test/redact-round8.test.ts is builder-measured; I didn't re-run it. At this head it passes, with redact-vendored, redact-default-union, redact-records-keys and tailscale-setup: 29 of 29 (measured, scratch HOME and state dirs).

Head checked: 1356f047853d819ef047da7d6a43874f3ec9b859. Nothing here was run against a real system or with real tokens.

@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (body only; head 1356f047 stands)

Delta final check of round 8. Head 1356f047853d819ef047da7d6a43874f3ec9b859 (merge of 71d5d4a4 with main cec056a5). The previous reviewed head was fa1318fd. I checked the round-7 second review's findings (issuecomment-6320305706) and the body's Round 8 section. The code passes every check below. What remains is three body corrections. No new code round is needed for them.

Required changes (body only)

  1. The merge notes are stale and now false (measured). Under "Notes for review" → "Merge notes":

    Update both.

  2. Say that the merge commit carries a change of its own (measured).

    • git merge-tree --write-tree 71d5d4a4 cec056a5 gives tree e1d8839b. Against the head's tree, it differs only in scripts/machine/worklane-update.cjs (+15/−1: the regenerated redactKeys).
    • So 1356f047 is not a plain merge: it was amended. Its committer date is 2.5 minutes after its author date. It was amended before the push; the PR has no force-push events.
    • The commit message says the copy was regenerated. The body's item 5 says only "There were no conflicts. The updater's copy is regenerated."
    • Say in item 5 that the merge commit was amended to carry the regenerated copy. A first-parent or plain-merge reading of this protected file would otherwise miss it.
  3. The "Not held by a mark" list is incomplete (measured).

    • It reads "These are DASHBOARD_KEY_BODY, basicShaped, credentialShaped and CUT_MARGIN". Round 8's own new predicate basicPair is not held either. Fed to weakeningReasons in memory, basicPair's test narrowed to {3,} before the : gives no reason. So do MAX_PASSES 8 → 2, which fails closed, so it is harmless, and the four the body names.
    • Add basicPair, or say "for example" and name it.

Optional: the "Package size" section still opens with round 7's figures. Round 8's figures are in the Round 8 section; I measured the same ones (below).

Round-7 findings: each fixed (measured)

The reviewer's p1–p8 and mutant.mjs were re-run against this head, unchanged except their load.mjs. It now points at this head and at main cec056a5: main's redact.ts transpiled in memory, and main's updater redact() and tailscale scrub, verbatim from cec056a5.

# Finding Result at 1356f047 Label
1 Long-scheme URL password (x+40 digits, git+33 -, svn++32 .++0) Hidden in default, broad and upd.redact(). secretKinds gives ["password in a URL"], the same as main. A 40-letter scheme is still hidden. measured (p1)
2 Tailscale link glued after tskey-, ghp_, wld_ tskey-[REDACTED][the login link], gh*_[REDACTED][the login link], wld_[REDACTED][the login link]. Main shows the ghp_ and wld_ tokens; head hides them. The round-8 test drives the real readStepLines. measured (p1, test)
3 Helper narrowing not held Each of GLUED_PREFIXES −sk-, KEY_QUALIFIERS −api, CREDENTIAL_ENDINGS −accesskey and GLUE_PARTS −8-bit CSI is held. DASHBOARD_KEY_BODY, basicShaped, credentialShaped and CUT_MARGIN are not held, as the body says. The PATTERNS control is held. The sk- mutant (p6): the SHAPES loop now misses 3 cases (a plain sk- project key after x_, MY_TOKEN_, a_b_), so the test catches it. measured (p4 and the builder's p4, p6)
4 Short Basic (user:pass, admin:hunter, bot:Secretpw, deploy:abcdefghijk) [REDACTED] / [redacted] in default, broad and the updater. Main shows all four. measured (p1)
5 Body notes Done as listed, apart from required changes 1–3 above. measured (read)

Other probes:

  • p2, passes (120k inputs per mode, seed 4242): at most 2 passes. Default {0: 9,731, 1: 109,319, 2: 950}, broad {0: 8,451, 1: 110,570, 2: 979}. Measured.
  • p5: the engine against the updater copy, 30,000 composed inputs: 0 differ. Measured.
  • p8: 13 JWT forms and 5 PEM/PGP forms: head shows nothing main hid, default or broad. Measured.
  • p3 and p7: both were aimed at round 7's rule, and p7's anchor is gone. They are superseded by the timing table below.

Scheme rule and basicPair: over-match and timing (measured)

Over-match

  • The builder's fp.mjs, head against fa1318fd: 1,326 files, 280,019 lines. One line differs, in default and broad: the round-8 test's own long-scheme fixture.
  • Every line of node_modules' md/ts/js/json text (5,279 files, 1,475,743 lines), head against fa1318fd: 0 differ in default and broad.
  • basicPair, tried on every distinct run of 8 or more base64 characters in the repo plus node_modules (81,956 runs), each after Basic and basic :
    • 4 newly redacted: TildeToken, TypeOnly, diskOnly and the test's dXNlcjpwYXNz. These over-match by failing closed, at about 3 in 62,000 letter-only words.
    • The 63 real basic <run> occurrences in that text: 0 newly redacted.
    • Not a required change (principle 1).

Timing, at the head, in ms at 0.25 / 0.5 / 1 / 2 MB, default mode then broad (with secretKinds on the same input):

Input Default Broad
digits then ://u:p@h 55 / 93 / 197 / 341 95 / 187 / 388 / 733
a+.- run then ://x 25 / 54 / 104 / 219 50 / 91 / 187 / 381

Other families stayed at or under 367 ms (broad) at 2 MB, about 2x per doubling:

  • 1:// repeated, -://a: repeated;
  • 1…1://u: followed by x: with no @;
  • a 35-digit scheme with ://u:pw and no @, repeated;
  • ab. and 1. runs;
  • one 2 MB Basic value: letters, printable with no :, printable with a : at the end;
  • declined Basic abcdefgh repeated, a basic …+basic chain, _Basic and \nBasic glue chains.

No input went super-linear.

Marks (measured)

  • registrationsOf: redact.ts and the updater copy each carry 8 never-shrink marks. The 4 new ones are GLUED_PREFIXES, GLUE_PARTS, KEY_QUALIFIERS and CREDENTIAL_ENDINGS.
  • In both copies, for each of the 4: removing the mark alone is held, narrowing the table is held, and doing both is held (2 reasons). Removing the existing marks (CREDENTIAL_WORDS, PATTERNS, BROAD_RULES, and BROAD, which I measured in the copy only) is held as well.
  • The generator puts back every // check-table: mark from the module (scripts/lib/redact-vendor.mjs:34), so a new mark carries over without a list.
  • Also held:
    • GLUE built from fewer parts;
    • credentialWord without CREDENTIAL_ENDINGS.

Merge (measured)

  • The tree re-derives as merge-tree(71d5d4a4, cec056a5) plus the regenerated updater copy only (see required change 2).

  • redact-vendored passes.

  • Registrations, over every JS/TS file, read with the head's registrationsOf:

    Commit Count Lost at the head
    1356f047 (head) 106
    cec056a5 81 0
    fa1318fd 94 0

    The 12 new since fa1318fd are the 8 redactor marks plus 4 from main. The builder's 107/82/95 is mine +1 on each side (probably package.json's CHECK_TABLES entry; inferred).

Usual checks

Nothing here touched real state or real tokens: every input was a stand-in built in a scratch script, run with a temp HOME.

@jayden1711

Copy link
Copy Markdown
Owner Author

Second adversarial review, round 8 follow-up (head 1356f047, unchanged): PASS

I re-read the PR body through gh api after the builder's body-only edits. Both required changes are done (measured, by reading the body).

  1. "Not held by a mark" now says that everything outside a marked table's own lines is held only by the file's lock and the tests. It names:

    • the lines that apply a table: gluePoints' loop, the GLUE read and how it is built from GLUE_PARTS, readAt, allSpans, spansOf and credentialKey;
    • basicPair;
    • the four scalar and predicate helpers.

    This matches what I measured.

  2. "What remains" now names both short-Basic limits: a short pair with a non-ASCII character, and a pair of 5 bytes or fewer. "Compromised component → Agent user" now says "a short printable-ASCII one" and points to both.

    • The 5-byte bound is right (inferred). 5 bytes give 7 base64 characters before the padding, and 6 bytes give 8, which the {8,} value needs.

One optional label fix: "(inferred: so would an edit to the applying lines)" can say measured. My round-8 comment fed 7 such edits to weakeningReasons in memory, and none was held.

No route found at this head (see my round-8 comment). Verdict: PASS on 1356f047853d819ef047da7d6a43874f3ec9b859.

@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: PASS (head 1356f047)

This follows up issuecomment-6322711211. I re-read the body through gh api repos/.../pulls/921, parsed with node. The head is still 1356f047853d819ef047da7d6a43874f3ec9b859 (measured).

  1. Merge notes: fixed (measured). They now say the head contains origin/main cec056a5, with Merge policy: a check table or lint test registers itself where it's declared; only removing or loosening one waits (#869) [PROTECTED: owner merges] #886, tool guard: protected-path checks decide on the file a write really reaches; redact secrets in keys [PROTECTED: owner merges] #976 (redactKeys) and Setup: the machine helper's sudoers rule follows the engine, rewritten on its own only when it narrows; --replace-sudoers for the rest (#898) [PROTECTED: owner merges] #902. The stale 40ef312a line and the "tool guard: protected-path checks decide on the file a write really reaches; redact secrets in keys [PROTECTED: owner merges] #976 is not on main yet" line are gone from the notes. 40ef312a still appears only in the historical Round 7 section, which is correct there.
  2. Amended merge: disclosed (measured). The body says the round-8 merge commit was amended before the push, with -s, and that it carries the regenerated updater copy (tool guard: protected-path checks decide on the file a write really reaches; redact secrets in keys [PROTECTED: owner merges] #976's redactKeys) inside 1356f047, not in a commit of its own. That matches what I measured: the head's tree is merge-tree(71d5d4a4, cec056a5) plus only that change to scripts/machine/<cli>-update.cjs, and the PR has no force-push events.
  3. "Not held by a mark": fixed (measured). It now covers everything outside a marked table's own lines and names basicPair along with the four scalar and predicate helpers.

Non-blocking note. The new sub-bullet lists "how GLUE is built from GLUE_PARTS" and "credentialKey" as not held. It adds "(inferred: so would an edit to the applying lines)". Two such edits I fed to weakeningReasons in memory in my first check are in fact held (measured):

  • GLUE built from GLUE_PARTS.slice(1);
  • credentialWord without CREDENTIAL_ENDINGS.

So the inferred claim is partly wrong, but in the safe direction: it understates the hold. Worth a word if the body is touched again; it is not required.

Everything else in my first check stands: all code checks pass at this head. CI at 1356f047 is now all green: every check passes or is skipped (bootstrap), and the two real-systemd jobs have finished (measured, gh pr checks 921; not rerun).

@jayden1711
jayden1711 merged commit 1cee9bd into main Oct 21, 2026
30 checks passed
@jayden1711
jayden1711 deleted the issue-889 branch October 21, 2026 22:04
jayden1711 added a commit that referenced this pull request Oct 21, 2026
No conflicts (git merge-tree --write-tree origin/main HEAD exit 0).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 21, 2026
…ith TypeScript, and pin yaml

Both reviews of 87cf216 found that round 18's claim, that everything the
backstop runs is held, was false.

- Four src/ modules run in its process through test/isolate.ts and the
  weakening module: src/brand.ts, src/os/system-paths.ts,
  src/os/own-env.ts and src/os/child-process.ts. A changed line in any of
  them got no hold. One such line, patching what fs returns, turned the
  backstop green over a red desktop twin. They are now in GUARD_FILES,
  which is an addition only. A new test walks the built import closure
  of test/isolate.ts and the backstop with TypeScript's import reader. It
  fails on any local module whose changed line isn't held, and on a
  closure module that loads another by name at run time.
- yaml could be swapped through the lockfile, or shadowed by a
  dist/node_modules/yaml that Node finds first. The backstop now pins
  each package its closure loads (yaml, and typescript, its new import
  reader) to the lockfile's integrity, held as a line of this lint test.
  It also checks that the package resolves under the repo's own
  node_modules (a plain directory, not a link), that no node_modules sits
  between a built module and the repo top, and that the package isn't
  the repo itself.
- The import-list test read only column-0 imports with a space after
  `import`. It now reads imports with ts.preProcessFile. typescript is
  added to MAY_IMPORT with it. The test also fails on the text that
  loads a built-in by name. IMPORT_FORM_FIXTURES holds the forms the
  regexes missed: indented, a tab, a comment first, no space (side effect
  and named), export* with no space, and a comment inside import().

Also merges origin/main 1cee9bd (#920, #921), with no conflicts.

Test (fails without): test/desktop-removed.test.ts. A new file in a
worktree at 87cf216 turns each fix red there: the 8 import forms pass
that head's scan; the 4 modules get [] from its hold; its closure walk
finds them unheld; and with a dist/node_modules/yaml shadow and a red
twin added as new files, its backstop passes all its tests. At this
head the same shadow turns the pin test red.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 21, 2026
No textual conflict, but a semantic one in src/decision-detail.ts: #921 (#889) imports only redactCut there and
redacts every shown diff before cutting it, while this branch's maxBuffer branch of decisionDiff still called
redactString(stdout.slice(0, MAX)), which the merged tree can't compile and which cut a token across the 512 KiB cut
before redacting it. That branch now uses redactCut(stdout, MAX) too; redactString is not re-imported.

Test (fails without): test/decision-diff-overflow.test.ts, a real diff over the 8 MiB buffer with a token the cut lands
four characters past `ghp_` in. At 5d8c98a (slice, then redact) the shown diff ends in "+ghp_0054"; here no part
of any token is shown.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 22, 2026
…on current main (#918)

With origin/main 1cee9bd (#921) merged, the package measured with dist/web built
(npm pack --dry-run --json --ignore-scripts, after npm run build and npm run build:web)
is 2,064,895 tarball and 6,463,906 unpacked; main 1cee9bd, built the same way, is
2,023,978 and 6,328,480. The tarball is over its 2,050,000 budget. It is raised to the new
size + 5%, rounded up to 10,000 (2,168,140 to 2,170,000), the package-budget practice; the
unpacked budget (6,750,000) still holds. The growth is the four signed-receipts modules
(+127,008 unpacked) and their wiring. A loosening of the size check (decision guide 9),
named in the PR body; #910 sets 2,160,000, so this takes the higher measured value.

Test (fails without): test/package-files.test.ts "the package stays within its size
budget" fails here with dist/web built (2,064,895 > 2,050,000).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 22, 2026
Main 1cee9bd (#921) adds an import line to src/doctor.ts beside this branch's moneyListCheck import. Kept both.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Redaction: one shared redactor knows every token shape (Discord bot token is redacted only in incidents.ts)

1 participant