f8c85a26e9
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.
230 lines
9.9 KiB
Markdown
230 lines
9.9 KiB
Markdown
# 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 → danach `Firewall.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`:
|
|
|
|
```go
|
|
// 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) and
|
|
`PostToolUseRedactedForLLM` (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"`:** uses `hook.PromptExecutor`, which calls
|
|
`p.streamer.Stream(...)`. In production, `streamer` is the
|
|
`routerStreamer` adapter from `cmd/gnoma/main.go`, which delegates to
|
|
`router.Router.Stream(...)`. The router selects an arm; each arm's `Provider`
|
|
is a `*SafeProvider` after Wave 1. Outbound messages — including the
|
|
rendered template containing `{{.Result}}` — are scanned at the SafeProvider
|
|
boundary before the inner provider sees them.
|
|
- **`type = "agent"`:** uses `hook.AgentExecutor`, which spawns an elf via
|
|
`elf.Manager`. The elf engine is constructed with `Firewall: m.firewall`, so
|
|
the elf's `buildRequest()` 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:
|
|
|
|
1. **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.
|
|
2. **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 `routerStreamer` adapter 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:
|
|
|
|
1. **No reorder, no split.** The PostToolUse contract stays: hooks see raw
|
|
`result.Output`; the firewall scan runs after hook transformation.
|
|
2. **Add a doc comment** to `hook.PostToolUse` in
|
|
`internal/hook/event.go` and to the dispatcher call site in
|
|
`internal/engine/loop.go` making 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."*
|
|
3. **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:
|
|
|
|
1. `internal/hook/event.go` — extend the `PostToolUse` constant's
|
|
doc comment with the transitive-guarantee statement.
|
|
2. `internal/engine/loop.go:699` — extend the inline comment above
|
|
the hook fire to point at this ADR.
|
|
3. `internal/hook/posttooluse_redaction_test.go` (new) — regression
|
|
test as described in the Decision section.
|
|
|
|
Total: ~80 LOC including the test.
|