PostToolUse rewrite collisions are last-registered-wins, not "last-write-wins" — and a clobbered redaction is silent
What the docs say
TS SDK 0.3.237 added PostToolUseHookSpecificOutput.classifierContext. Its
JSDoc warns:
(Do NOT return an identity rewrite just to pair an assertion: hooks run in parallel on the ORIGINAL output, so an identity rewrite competes last-write-wins with sibling rewrites and can clobber a real redaction.)
Two problems with this, both with security consequences.
1. "last-write-wins" implies a timing race; the behavior is deterministic
"last-write-wins" reads as completion order — i.e. whichever hook finishes last
overwrites. That suggests the hazard is a nondeterministic race you cannot
design around.
Measured on CLI 2.1.234, it is deterministic last-registered-wins:
| Scenario | Redaction hook | Identity-rewrite hook | Result |
|---|---|---|---|
| A | registered 1st, 50ms | registered 2nd, 250ms | redaction lost (credential reached the model) |
| C | registered 2nd, 50ms | registered 1st, 250ms | redaction survived |
C inverts registration against completion: the identity rewrite still finishes
last, but now registered first — and the redaction survives. So completion
order does not decide the winner; registration order does. Stable across
repeated runs. This matches the merge rule for PreToolUse updatedInput.
This distinction is actionable and currently undocumented: because it is
deterministic, hosts can protect themselves by registering security-relevant
rewrites last. The current phrasing tells them the opposite — that it is a race.
2. The warning is on the wrong field, and the discard is silent
The hazard belongs to updatedToolOutput, but it is documented only in the
JSDoc of classifierContext — a different field, added later, in a ~2000-character
paragraph. An author writing a redaction hook has no reason to read it.
And when two hooks return conflicting updatedToolOutput, the losing rewrite is
dropped with no signal: no warning, no diagnostic. For a redaction, that is a
silent data leak — a plugin or org-level hook appended after a redactor quietly
un-redacts its output.
Suggestions (in priority order)
- Document the merge rule on
updatedToolOutputitself (andupdatedInput),
stating it is last-registered-wins, with the explicit guidance to
register redaction hooks last.
- Replace "last-write-wins" with "the last-registered hook's rewrite wins" —
the current wording actively misleads about whether it is designable-around.
- Emit a warning when two hooks return conflicting
updatedToolOutputfor the
same call. Silent discard of a security-relevant rewrite is the failure mode
worth eliminating.
Reproduction
The merge happens CLI-side, so this is not specific to any one SDK.
- Register two
PostToolUsehooks on the same matcher (e.g.Bash). - Have the tool produce output containing a recognizable secret, e.g.
echo 'deploy config: API_KEY=sk-live-DEADBEEF'.
- Hook 1 returns
hookSpecificOutput.updatedToolOutputwith the secret
masked (a genuine redaction).
- Hook 2 returns
hookSpecificOutput.updatedToolOutputset to the
original, unmodified output — the "identity rewrite" the JSDoc describes.
- Inspect the
tool_resultthe model receives.
Observed on CLI 2.1.234: the tool_result contains the unmasked secret.
Swap the two hooks' registration order and the mask survives, with the timing
held constant — which is what isolates registration order from completion
order. Making hook 2 the slower of the two in both orderings rules out
completion order entirely.
Note on classifierContext itself
Not part of this report, just context: the field appears inert on CLI 2.1.234 —
zero occurrences in the CLI binary and zero in sdk.mjs 0.3.237, so upstream's.d.ts is currently ahead of both the CLI and the TS runtime. The collision
above is live today regardless.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗