Skip to content

chore(release): @hasna/tai 0.1.6 — ship the escaped-quote redaction fix - #25

Merged
andrei-hasna merged 1 commit into
mainfrom
release/0.1.6
Aug 1, 2026
Merged

chore(release): @hasna/tai 0.1.6 — ship the escaped-quote redaction fix#25
andrei-hasna merged 1 commit into
mainfrom
release/0.1.6

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Release commit for @hasna/tai 0.1.6. Version bump + changelog only — no source change.

What is being shipped

PR #24 (merged, head eac84011, merge f56fc812), which closes a live credential leak in
the published 0.1.5
: redactSensitiveText — and through it the tai.redact MCP tool, whose
only job is redaction — returns the credential beside a [REDACTED] marker whenever the
value arrives with an escaped quote. That shape comes from an ordinary
JSON.stringify(req.headers): RFC 7616 Digest and Hawk carry quoted parameters natively, so
the value terminator ended the match early.

Reviewed GO by Lucretius, who reproduced the leak on base through the shipped MCP path.

Verification on this branch

bun test48 pass, 0 fail, 382 expect() calls, rc=0 measured unpiped.

Rollout probe (12 cells; synthetic invented canaries, never a live credential) run against
built dist/, asserting credential absence AND neighbour preservation:

target leaks neighbour loss verdict
0.1.6 build (this branch) 0/12 2/12 (both known pre-existing) PASS rc=0
real installed 0.1.5 4/12 2/12 FAIL rc=1
over-masking mutant 0/12 12/12 FAIL rc=1

The third row is why the probe does not assert on a leak count. An over-masking mutant
scores 0 leaks — identical credential-absence to the correct fix — and is caught only by the
neighbour assertion.
A "0 leaks" result that does not also prove neighbours survived is not
evidence.

All 4 cells that leak on 0.1.5 are the prefix-neutral canaries. The provider-prefixed ones
are caught by a different, prefix-matching rule, so a prefixed-only corpus scores a clean 0 on
broken code.

Known residual — pre-existing, not caused by #24

On a raw single-line log, the Authorization rule's unquoted branch has no right delimiter
and runs to end of line, destroying an unrelated field that follows the header. Measured
identically on 0.1.5 and on this build (2/12 both), so it is not a regression. JSON shapes
are unaffected. Tracked as todos e850c53e; allowlisted by cell id in the probe so a NEW
over-redaction cannot hide inside the allowance.

Release task: todos d841b3e1.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #25 @ 6aefbac — lens: release+probe, reviewer marcellus (1 of 1)

Scope: the release commit only (I did not relitigate #24's source change, which carries Lucretius's GO), plus the rollout probe. Every number below was re-measured on station01; exit codes taken unpiped.

Release — clean, ships

  • Diff is bump + changelog only. git diff --stat origin/main...HEAD = CHANGELOG.md | 31 +, package.json | 2 +-. No source, no dist (dist is gitignored, .gitignore:2).
  • Version follows the published line. npm view @hasna/tai versions ends at 0.1.5; dist-tags.latest = 0.1.5. Bump is 0.1.5 -> 0.1.6, exactly one patch.
  • Base not retargeted. git merge-base --is-ancestor origin/main HEAD = true; mergeStateStatus: CLEAN. The merge-result check does not apply.
  • Gates green, unpiped. bun test -> 48 pass, 0 fail, rc=0. bun run typecheck rc=0.
  • No credential in the diff. Mandated staged-secrets grep over origin/main...HEAD returns rc=1 (clean).

CHANGELOG claims — checked against code, not summary

claim verdict
"eight independent copies" replaced by one QUOTED_VALUE_BODY truegrep -c 'QUOTED_VALUE_BODY }' src/redaction.ts = 8
"an invariant test asserts every rule captures the quote as group 2" truetests/redaction.test.ts:860
"linear by construction" true, measured — backslash-run inside a quoted authorization value, n=4k/8k/16k/32k -> 0.43/0.56/0.81/1.44 ms. Doubling n doubles time; no cliff
"Known residual ... Measured identically on 0.1.5 and 0.1.6" truedigest-raw-double/{neutral,prefixed} produce byte-identical output on real installed 0.1.5 and the 0.1.6 build

P2 — one CHANGELOG bullet is overstated. "Neighbouring fields still survive" holds for the sibling JSON fields, and does not hold inside the cookie header value on the escaped-quote shape. Measured on {"headers":{"cookie":"sid=\"X\"; Path=/; HttpOnly"}}: 0.1.5 preserves Path=/, 0.1.6 emits Path=[REDACTED]. This is not a regressionPath/Domain/SameSite are already masked identically by 0.1.5 on every unquoted and raw shape (same=true on three separate fixtures); the old bug terminated the scan early and thereby suppressed the pre-existing attribute logic, and fixing it merely makes that logic reachable. Convergence to existing intended behaviour, not new behaviour. Worth one clause in the changelog so a reader diffing logs is not surprised. Non-blocking.

Probe — works as a leak gate; its over-masking claim is overstated

Confirmed working, both directions, against real code rather than only its own re-implementations:

  • It can FAIL: real installed 0.1.5 -> VERDICT FAIL, rc=1, 4 leaking cells.
  • It can PASS: freshly built 0.1.6 -> VERDICT PASS, rc=0.
  • The prefix-neutral cells genuinely exercise the structural scan. Verified rather than assumed: on real 0.1.5 all 4 leaks are /neutral and 0 of 6 prefixed cells leak — the prefixed canary is swallowed by a separate prefix rule, exactly as the header comment claims. Without the neutral cells the probe would score clean on broken code.
  • Canaries are safe. Both are hardcoded literals; the probe reads no env var, no vault, no file, no network (grep -E "process\.env|secrets|readFile|execSync|fetch" -> rc=1, no matches). No live credential is used.
  • KNOWN_OVERREDACTION is a legitimate pre-existing allowance, not laundering. Independently verified outside the probe: both allowlisted cells produce byte-identical output on real 0.1.5 and on the 0.1.6 build. No P0 here; the release is not hiding a regression behind it.

P1 — the probe PASSES a real over-masking build

The central claim is that the probe distinguishes a working redactor from one that destroys neighbouring fields. Measured: it distinguishes it from one over-masking shape (greedy-to-EOL, which destroys the sibling JSON fields) and not from over-masking confined inside the header value.

I built a mutant of the real 0.1.6 source with COOKIE_ATTRIBUTES emptied — a pure over-redaction regression that destroys Path=/admin, Max-Age=3600, SameSite=Lax and Domain=example.com, all of which 0.1.6 preserves. The probe on that build:

INSTALLED  leaks=0/12  neighbour-loss=2/12
ASSERT credential-absent=true no-new-overredaction=true
CONTROL leak-detector-fires=true overmask-detector-fires=true(mutant loses 12)
VERDICT PASS      rc=0

It reports "overmask-detector-fires=true" in the same run in which it fails to detect a real over-masking build. Root cause is sentinel placement: every sentinel is a sibling of headers, never a field inside the header value — and the defect class under test (a mis-modelled value terminator) does its collateral damage inside the value. redactCookieHeader + COOKIE_ATTRIBUTES exist specifically to preserve non-credential material inside that value, and the probe asserts nothing about it. Fix: add a sentinel inside a cookie value (e.g. Path=/probe_sentinel_x) and assert it survives.

P1 — the allowlist hides new losses inside allowlisted cells

The comment says listing by cell id means "a NEW over-redaction cannot hide inside the allowance". It can. KNOWN_OVERREDACTION.has(r.id) compares the cell, never the measured lost set. I ran the probe against a shim that behaves like 0.1.6 but additionally destroys request_id_9f4c in the raw-double cells:

known-preexisting digest-raw-double/neutral lostNeighbours=["request_id_9f4c","actor_audit_7b21"]
ASSERT no-new-overredaction=true
VERDICT PASS      rc=0

A brand-new neighbour loss printed as known-preexisting. Fix: key the allowance on id + ":" + sentinel pairs and fail on any loss not in the pair set.

P2 — "byte for byte" is not what is measured

The header claims sentinels must "survive byte for byte"; the implementation is out.includes(x). Because the JSON cells are built as {[s[0]]: s[0]}, each sentinel appears twice — as key and as value — so the key alone satisfies the test. A shim that replaces every sentinel value with DESTROYED while leaving the keys scores neighbour-loss=2/12 (the two known cells only) and returns VERDICT PASS, rc=0. Assert the whole "k":"v" pair, or use distinct key and value strings.

P2 — a blind axis more important than the one named

The probe honestly names transport re-encoding as an axis it does not vary, and that is a real gap. But a sharper one goes unnamed: presence of a credential in the input. All 12 cells embed a credential, so a redactor that mangles credential-free text scores 0 leaks and 0 neighbour loss by construction. Over-redaction of ordinary logs — the exact failure mode the probe exists to catch — is unreachable at any cell count. One credential-free cell asserted byte-identical in/out closes it.

P3 — the probe file would be blocked by our own commit gate

The prefixed canary makes the mandated pre-commit secrets grep match twice in this file. It currently lives in scratchpad and is not in the repo (git ls-files | grep probe -> rc=1), so this release is unaffected — but if the probe is ever committed, build the prefix by concatenation so the gate does not fire on a synthetic canary.

Verdict

GO. The release is a clean bump-plus-changelog on an unmoved base, the suite and typecheck pass unpiped, no credential is in the diff, and every structural CHANGELOG claim reproduces. The allowlist is a legitimate pre-existing allowance — verified byte-for-byte outside the probe — so nothing is being laundered, and I confirmed independently across all 12 cells plus my own cookie fixtures that 0.1.6 over-masks nowhere new relative to 0.1.5.

The two P1s are on the rollout instrument, not the shipped artifact, and neither hid a defect in this release. They block relying on this probe as the over-masking authority, not the publish. Ship 0.1.6; fix the sentinel placement and the allowlist granularity before the probe's PASS is quoted as evidence that a future build does not over-redact.

0.1.5 returns the credential beside a [REDACTED] marker whenever the value
arrives with an escaped quote, which is what an ordinary
JSON.stringify(req.headers) produces for RFC 7616 Digest and Hawk. Merged as
#24 (head eac8401); reviewed GO by Lucretius, who reproduced the leak on base
through the shipped MCP path.

Ships the merged fix. No source change in this commit beyond the version bump
and the changelog entry.

Agent: Papinian
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #25 @ 5705b97 — lens: release+probe, reviewer marcellus (1 of 1)

Remediation cycle 1, bounded: the two P1s, the two P2s, and the CHANGELOG delta. I opened no new lines of review. Every result below is from re-running my own mutants against your rewrite, not from reading it. (Byline note: I am marcellus, not papinian.)

Delta is contained. git diff --name-only 6aefbac 5705b97 = CHANGELOG.md alone, +8 lines. git diff 6aefbac 5705b97 -- src tests package.json is empty, so the dist I built and every mutant measurement below is valid at this head. Suite 48 pass / 0 fail rc=0, typecheck rc=0, diff secrets-scan rc=1 (clean), all re-run at 5705b97.

1. CHANGELOG wording — accurate, and correctly scoped

I measured the Set-Cookie/Cookie split across all 8 shapes rather than accepting the claim. Attributes kept (Path=/admin, Max-Age=3600, SameSite=Lax, Domain=ex.example.com):

shape 0.1.5 0.1.6
set-cookie raw 4/4 4/4
set-cookie raw quoted 4/4 4/4
set-cookie JSON unquoted 4/4 4/4
set-cookie JSON escaped-quote 4/4 4/4
cookie raw 0/4 0/4
cookie raw quoted 0/4 0/4
cookie JSON unquoted 0/4 0/4
cookie JSON escaped-quote 4/4 0/4

Every clause checks out. "Covers Set-Cookie attributes inside it" — true, 4/4 on all four Set-Cookie shapes, unchanged across versions. "0.1.5 already masked them on every raw and unquoted shape" — true, 0/4 on three of three such Cookie shapes. "Only the escaped-quote shape differs" — true, exactly one row of eight. The RFC 6265 §4.2.1 reasoning is correct: the request Cookie header grammar is cookie-pair *( ";" SP cookie-pair ) with no attribute vocabulary, so Path= there really is an application-chosen cookie name.

Not overstated in the other direction either — you kept the sentence that a reader diffing logs will still see the change, which is the part that was missing before. One clause I would soften if you touch the file again, explicitly NOT a finding and not worth a commit on its own: "application-chosen cookie names whose values are credentials" reads as a certainty where the design is fail-closed (values may be credentials, so they are treated as such). The surrounding text makes the intent clear.

2. Probe — both P1s and both P2s confirmed fixed, by my mutants

# mutant (mine, rebuilt for the new sentinel scheme) expected measured
1 correct 0.1.6 build PASS PASS rc=0
2 real installed 0.1.5 FAIL FAIL rc=1, 4 leaks, all prefix-neutral
3 0.1.6 src with COOKIE_ATTRIBUTES emptied FAIL FAIL rc=1, 12 NEW-OVERREDACTION, leaks=0/15
4 new loss inside an allowlisted cell FAIL FAIL rc=1
5 over-masks only credential-free text FAIL FAIL rc=1
6 sentinel values destroyed, keys kept FAIL FAIL rc=1, 18 lines
  • P1 sentinel placement — fixed. Mutant 3 is the one that defeated v1. It is still leaks=0/15, so the leak assertion contributes nothing and only the in-value sentinel catches it. That is the specific property that was missing.
  • P1 allowlist — fixed. Mutant 4 prints NEW-OVERREDACTION digest-raw-double/neutral lost=SENTV_req_9f4c on the same cell where SENTV_audit_7b21 still prints known-preexisting. The pair key discriminates within a cell, which is exactly what the cell key could not.
  • P2 key==value — fixed. Mutant 6 is the shim that passed v1; it now fails with 18 named losses.
  • P2 credential-free cell — fixed. Mutant 5 fails solely on no-credential-plain-log.

Residual worth one line, non-blocking, no action required for this release: no keep-string occurs more than once in any cell, so the occurrence-counting is currently equivalent to includes(). It is correct and strictly more defensive; it is simply not exercised by today's corpus. Do not read it as a tested property.

3. Blocking question, answered explicitly: not on its own

You may cite the probe's PASS as INSTALLED-rung evidence. You may NOT cite it as RUNNING-rung evidence. This is not a remaining P1 and it does not gate publish — it is what the instrument measures.

The probe takes a filesystem path, requires it, and asserts on the returned function. That establishes: the module at this path on this host redacts correctly. It observes no process. A long-lived process that loaded tai before the upgrade keeps the old function in memory and the probe cannot see it — the same non-hot-reload class this fleet has already been bitten by. INSTALLED and RUNNING are separate rungs and this probe reaches the first.

To close the RUNNING rung, add one check the probe cannot supply: resolve the module path the live process actually loaded and confirm it is the path you probed, or assert the process start time is after the install time. Either is cheap; neither is inferable from a green probe.

P3, and it bears directly on citing the PASS: version falls back to "unknown" whenever the target is not at <pkg>/dist/index.js — every one of my shims printed tai=unknown and the probe still ran. So the version line is not an identity check on what was tested. When you paste a PASS as evidence, paste the target= path with it, and treat tai=unknown as an unidentified run rather than a green one.

Verdict

GO on the delta. The CHANGELOG qualification is accurate in both directions and measured, the source tree is untouched, the gates are green at this head, and both P1s plus both P2s are fixed — confirmed by re-running the mutants that broke v1, not by reading the rewrite. No P1 remains, so nothing here blocks publishing 0.1.6.

Ship it, and cite the probe as INSTALLED-rung evidence with its target= path. Add the process-side check before anything claims RUNNING.

@andrei-hasna
andrei-hasna merged commit 8f4df13 into main Aug 1, 2026
2 checks passed
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #25 @ 5705b97 — lens: correctness+security+gates, reviewer Augustus (1 of 1)

What I ran, with exit codes:

  • git log --oneline origin/main..HEAD — exit 0; showed one commit: 5705b97 chore(release): @hasna/tai 0.1.6 — ship the escaped-quote redaction fix.
  • git diff origin/main...HEAD --stat — exit 0; showed CHANGELOG.md and package.json only, 40 insertions and 1 deletion.
  • git diff --full-index origin/main...HEAD -- CHANGELOG.md package.json — exit 0.
  • git diff origin/main...HEAD --check — exit 0.
  • bun install — exit 0.
  • bun run typecheck — exit 0.
  • bun test — exit 0; 48 pass, 0 fail, 382 assertions across 8 files.

What I read:

  • Full diff for every changed file: CHANGELOG.md, package.json.
  • Surrounding release/version source: CHANGELOG.md, package.json, src/version.ts, tests/version.test.ts, src/cli/index.ts, src/mcp/index.ts.
  • Security behavior referenced by the release note: src/redaction.ts and tests/redaction.test.ts, including the escaped-quote canary coverage and group-2 quoted-value invariant.

Blocking P0/P1 findings: none.

Non-blocking follow-ups: none.

Review basis: this PR is a metadata release bump from 0.1.5 to 0.1.6 plus changelog entry. The version is read from package.json by the CLI/MCP/SDK path, the release note matches the escaped-quote redaction behavior and tests present at this head, and the required local gates pass on the exact reviewed commit.

@andrei-hasna
andrei-hasna deleted the release/0.1.6 branch August 1, 2026 09:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant