Closes the last remaining 2026-05-19 audit finding by documenting the existing transitive guarantee rather than restructuring the hook contract. The audit observed that PostToolUse hooks receive raw tool output before the firewall scan runs, and proposed reordering or splitting the event into raw-local-only and redacted-for-LLM variants. After Wave 1 (SafeProvider boundary at every router arm + non-engine provider consumer), the audit's threat model is closed transitively: - Shell hooks see raw output but never reach an LLM. - Prompt hooks route Stream calls through routerStreamer → router → arm.Provider, every arm.Provider is now *SafeProvider, outgoing messages are scanned at the boundary. - Agent hooks spawn an elf whose engine has Firewall set; buildRequest scans inline. Reordering would regress legitimate shell-hook use cases (audit, forensic, local alert) that need raw access. Splitting the contract forces every existing hook config to migrate and introduces a wrong-variant footgun. Neither is justified by the residual risk. Three changes ship with the ADR: - ADR-004 records the decision and the conditions for re-opening it. - Doc comments on hook.PostToolUse and the dispatcher call site in the engine point at the ADR. - internal/hook/posttooluse_redaction_test.go locks in the invariant: a prompt PostToolUse hook firing on a secret-bearing tool result produces a redacted prompt at the inner provider. If this test fails, ADR-004's Position A is no longer correct and the audit finding re-opens.
9.9 KiB
ADR-004: PostToolUse Hook Ordering vs. Firewall Redaction
Status: Accepted Date: 2026-05-19
Context
The 2026-05-19 external security audit raised this concern as a "High" finding:
Ablauf in
internal/engine/loop.go: tool executes → PostToolUse hook bekommt raw output → danachFirewall.ScanToolResult()Das heißt: Ein
type = "prompt"Hook kann raw Tool-Output in ein LLM schicken, bevor Redaction passiert.
In source order, the relevant code is at internal/engine/loop.go:699-712:
// PostToolUse hook: can transform result (Deny treated as Skip).
output := result.Output
if e.cfg.Hooks != nil {
payload := hook.MarshalPostToolPayload(call.Name, args, output, result.Metadata)
transformed, _, _ := e.cfg.Hooks.Fire(hook.PostToolUse, payload)
if s := hook.ExtractTransformedOutput(transformed); s != "" {
output = s
}
}
// Scan tool result through firewall
if e.cfg.Firewall != nil {
output = e.cfg.Firewall.ScanToolResult(output)
}
The audit's literal observation is correct: the hook receives result.Output before
Firewall.ScanToolResult runs. The audit's preferred fix was either:
- Reorder: scan first, then fire the hook with redacted input.
- Split contract: introduce
PostToolUseRawLocalOnly(shell hooks only) andPostToolUseRedactedForLLM(prompt/agent hooks only) as distinct events.
Either fix is non-trivial. The reorder regresses legitimate shell-hook use cases that need raw access (auditing, log forwarding, transformation). The split is a contract change that requires migrating every existing hook config.
Before adopting either, we re-examined whether the leak path is actually open after Wave 1 (SafeProvider boundary) merged.
What Wave 1 changed
The audit was performed against main(2).zip, which did not include the
SafeProvider boundary. With Wave 1 (ADR-pending; PR #1 merged), every
provider.Provider registered with the router — and every provider handed to a
non-engine consumer (SLM classifier, summarizer, hook streamer) — is wrapped in
security.SafeProvider. The wrapper scans outgoing messages and the system
prompt through the firewall before delegating to the inner provider.
This affects PostToolUse hooks as follows, by hook type:
type = "command"(shell): runs a local subprocess. No LLM round-trip. The hook sees raw output, but the output stays local. The audit's threat model (raw output → cloud LLM) doesn't apply.type = "prompt": useshook.PromptExecutor, which callsp.streamer.Stream(...). In production,streameris therouterStreameradapter fromcmd/gnoma/main.go, which delegates torouter.Router.Stream(...). The router selects an arm; each arm'sProvideris a*SafeProviderafter Wave 1. Outbound messages — including the rendered template containing{{.Result}}— are scanned at the SafeProvider boundary before the inner provider sees them.type = "agent": useshook.AgentExecutor, which spawns an elf viaelf.Manager. The elf engine is constructed withFirewall: m.firewall, so the elf'sbuildRequest()scans inline. Same effect as the main engine's request path.
So the leak path — raw tool output reaching an LLM — is already closed transitively by Wave 1 for both LLM-bound hook types. The audit's literal observation about source ordering remains true; the practical leak it implied does not.
What's left
Two residual concerns survive the transitive guarantee:
- Output transformation produces unscannable downstream payloads. A PostToolUse hook (any type) may transform raw output into a form that defeats regex-based scanning — e.g. base64-encoding, JSON-wrapping with reformulated content, summarisation via LLM that drops the secret-shaped string. The firewall scan at line 711 runs on the transformed payload, which may no longer match patterns the raw output would have. This is user-configured behaviour (they chose to add the hook), but it's worth acknowledging as a known limit.
- Transitive safety is non-obvious. The "leak is closed" property
depends on every LLM-bound hook path going through SafeProvider. A future
change that adds a new hook type with its own LLM transport, or replaces
the
routerStreameradapter with something that bypasses the router, would silently reopen the leak. There is no compile-time or test-time guarantee that this property holds.
Decision
Accept the current ordering. Document the transitive guarantee.
Concretely:
- No reorder, no split. The PostToolUse contract stays: hooks see raw
result.Output; the firewall scan runs after hook transformation. - Add a doc comment to
hook.PostToolUseininternal/hook/event.goand to the dispatcher call site ininternal/engine/loop.gomaking the transitive guarantee explicit: "PostToolUse hooks receive pre-scan output. LLM-bound paths (prompt/agent) inherit firewall scanning at the SafeProvider boundary; shell hooks stay local. Any new hook type that talks to an LLM must route through SafeProvider or perform its own scan." - Add a regression test that asserts a
type = "prompt"PostToolUse hook firing on a tool result containing a known secret-shaped string does not deliver that string verbatim to an inner provider. The test builds a recording fake provider, registers it via a router arm wrapped in SafeProvider, configures a prompt-type PostToolUse hook that includes{{.Result}}in its template, fires the hook, and asserts the recorded request was redacted.
Why not reorder?
Reorder eliminates Concern 2 cheaply, but it regresses Concern 1's mirror case: shell hooks that need raw output for legitimate local-only purposes. Examples we want to preserve:
- Audit-log hooks that record exact bash output for later inspection.
- Forensic hooks that hash raw output before any transformation.
- Local-only alerting (e.g. fire pagerduty when a specific stderr pattern appears in a build hook) that needs the unredacted signal.
A redacted payload changes byte offsets, regex matches, and content size — all of which break legitimate shell-hook code that's not LLM-bound.
Why not split the contract?
A split (PostToolUseRaw vs. PostToolUseRedacted) is technically clean
but introduces a migration cost out of proportion to the residual risk.
Every existing hook config has to be re-categorised. The split also
encourages a footgun: a hook author who picks the wrong variant gets
either unsafe behaviour or broken transformation. The audit-flagged risk
doesn't justify that tax against an already-closed leak path.
When to revisit
Re-open this decision if any of the following happen:
- A new hook type lands that performs LLM round-trips outside the router (e.g. a hook that calls a vendor SDK directly). At that point the transitive safety argument breaks and Position D (dispatcher-level redaction for LLM-bound paths) becomes necessary.
- The SafeProvider boundary is removed or relaxed (Wave 1 plan flagged this as a possible one-release follow-up; if engine-level scanning is removed AND a future change weakens SafeProvider, the transitive chain loses its second link).
- Telemetry shows that a meaningful number of users configure prompt-type PostToolUse hooks. The split contract's migration cost becomes proportional to the population it protects.
- A real-world incident proves Concern 1 is exploitable — e.g. a user-configured transformation that the firewall fails to scan.
Alternatives Considered
Alternative A: Reorder (scan before hook)
- Pros: Defense in depth at the hook boundary; eliminates the "transitive safety is non-obvious" concern.
- Cons: Breaks shell hooks that need raw output. Redacts content that may never leave the local machine.
Alternative B: Split contract
(PostToolUseRawLocalOnly + PostToolUseRedactedForLLM)
- Pros: Each hook type sees exactly what it should. Compile-time clarity for hook authors.
- Cons: Migration cost for every existing config. New footgun (wrong variant chosen → wrong semantics). Two near-identical event types in the API surface.
Alternative C: Dispatcher routes by command type
- Pros: Single event, transparent split. Hook authors don't pick.
- Cons: Dispatcher needs access to the firewall (new dependency). Per-handler redaction means firing the hook chain multiple times with different payloads, or pre-computing both raw and scanned variants. Increases dispatcher complexity meaningfully.
Alternative D: Accept current ordering, document, test
(this ADR)
- Pros: No code reorder. No migration. Acknowledges the existing transitive guarantee from Wave 1. Closes the "non-obvious" concern via doc + regression test.
- Cons: Concern 1 (transformation defeats scanning) remains as a user-configured behaviour. Future hook types that bypass the router silently reopen the leak — the regression test catches one shape but not all shapes.
Consequences
Positive:
- No churn to the hook contract or existing configs.
- Existing shell-hook use cases (audit, forensic, local alert) keep raw access.
- Regression test makes the transitive guarantee load-bearing in CI.
Negative:
- The transitive guarantee is documented but not type-enforced. A motivated change can still reopen the leak.
- Output-transforming hooks can produce unscannable downstream payloads. Same as today; no improvement.
Neutral:
- The audit finding is closed by reframing, not by code change. This ADR is the artifact that records why.
Implementation
Three small changes land with this ADR:
internal/hook/event.go— extend thePostToolUseconstant's doc comment with the transitive-guarantee statement.internal/engine/loop.go:699— extend the inline comment above the hook fire to point at this ADR.internal/hook/posttooluse_redaction_test.go(new) — regression test as described in the Decision section.
Total: ~80 LOC including the test.