Audit C3/H1: exec reaches its own gateway, and the mode says what it actually does - #5
Merged
Conversation
… actually does Closes audit C3 and H1, and the partial L2. Two findings, one decision, because they are the same question: what is Gateway mode for? C3 — `exec` returned 401 to its own child. `runExecCommand` generated a per-run token and injected it as `TOKENDAMPER_GATEWAY_TOKEN`, a variable nothing in `src/` read and that no third-party client has heard of. The child is `aider`, `claude`, `codex` or `curl`; it sends `authorization` or `x-api-key` and nothing else. Reproduced by spawning a real child through `runExecCommand`: every request came back 401, and `exec` exited 0. The suite passed throughout, because its gateway test presented the header no real client sends — which is why the new test spawns a child instead. Loopback peers are now trusted. The server binds to 127.0.0.1, so a loopback peer was already the only peer that could connect, and the token was protecting one local process from another on the same machine — a real but narrow boundary that was being paid for with a mode nobody could use. Determined from `req.socket.remoteAddress`, never from a header, since `X-Forwarded-For` is attacker-supplied; includes `::1` and the IPv4-mapped `::ffff:127.0.0.1` form Node reports on a dual-stack listener. The token is still enforced on any non-loopback bind, where the boundary is not narrow. `HTTP_PROXY` and `HTTPS_PROXY` are no longer set. `GatewayServer` implements neither HTTP proxy semantics (absolute-form request URIs) nor the `connect` event `CONNECT` requires, so any child honouring them would have failed to reach the provider at all — a second failure, independent of the 401 and masked by it. Setting a proxy variable for a server that is not a proxy is worse than setting nothing. Base-URL interception is now the only supported mechanism. Partial L2: `?token=` query authentication is removed — a credential in a query string lands in access logs, shell history and any error that echoes the URL — and the header comparison is constant-time. H1 — the Gateway saves nothing across turns, and that is correct. Measured over real sockets on realistic two-turn conversations, where a resent history contains each block exactly once: 0 bytes saved, fallback on every turn, for code, prose and JSON tool results alike. (An earlier attempt at this measurement duplicated the block inside turn 2 and appeared to save; that is the within-payload case, not the cross-turn one.) Not a bug. `cleanup:session-dedup` marks an elision recoverable only when an intact copy survives elsewhere in the same payload (§16); a sole copy seen only in a previous turn is scored in full and refused, correctly, because the consumer is a stateless provider API with no rehydration mechanism and such a marker is deletion rather than reference. No cross-turn transform is available without provider-side resolvability, which does not exist. So the mode is documented as experimental and the saving claim is withdrawn. What it does deliver — transparent interception, the full validation pipeline, byte-faithful forwarding, metrics, within-payload dedup — is a coherent product; "Cross-turn Session Deduplication" was not. README, ARCHITECTURE.md and CLAUDE.md invariant 8 updated. The measurement is pinned by `test/integration/gateway-dedup-reality.test.ts` rather than left as prose. If a cross-turn saving ever appears, that is the signal to read: either resolvability was implemented, in which case update the test deliberately, or the drift gate was relaxed and the Gateway is deleting content the model cannot recover, in which case do not. Also resolves the rest of the audit's M4 list rather than deferring it: knapsack planning marked implemented-but-unreachable (H5), token hashing qualified as irreversible on the CLI by design, `TOKENDAMPER_RISK_TOLERANCE` marked as having no effect on optimization (H4, still open), `TOKENDAMPER_GATEWAY_TOKEN` described accurately. One existing test asserted the old 401 and was updated with its finding preserved: it passed only because it sent a header real clients do not. New tests verified to fail 3/6 against the unfixed gateway. See DECISIONS §41. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes audit C3, H1, the remainder of M4, and partially L2.
Two findings, one PR, because they are the same question: what is Gateway mode for? C3 is a straightforward bug. H1 is a design conclusion with a consequence, and treating it as a bug would have been the wrong move.
C3 —
execreturned 401 to its own childrunExecCommandgenerated a per-run token and injected it asTOKENDAMPER_GATEWAY_TOKEN— a variable nothing insrc/reads and that no third-party client has heard of. The child isaider,claude,codexorcurl; it sendsauthorizationorx-api-keyand nothing else.Reproduced by spawning a real child through
runExecCommand:The flagship integration was non-functional end to end — and
execreported success.The existing suite passed throughout, because its gateway test presented the header that no real client sends. That is why the new test spawns an actual child process; the audit's own closing note on C3 said exactly this would have caught it.
Fix: trust loopback, keep the token for other binds
The server binds to
127.0.0.1, so a loopback peer was already the only peer that could connect. The token was protecting one local process from another on the same machine — a real but narrow boundary, being paid for with a mode nobody could use. Loopback is determined fromreq.socket.remoteAddress, never from a header (X-Forwarded-Foris attacker-supplied), and covers::1and the IPv4-mapped::ffff:127.0.0.1form Node reports on dual-stack listeners. The token is still enforced on any non-loopback bind.Second, independent defect: the proxy vars
HTTP_PROXYandHTTPS_PROXYare no longer set.GatewayServerimplements neither HTTP proxy semantics (absolute-form request URIs) nor theconnectevent thatCONNECTtunnelling requires — verified, there is no handler. Any child honouringHTTPS_PROXY(most HTTP clients) would have failed to reach the provider at all, independently of the 401 and masked by it. Setting a proxy variable for a server that is not a proxy is worse than setting nothing. Base-URL interception is now the only supported mechanism, and is documented as such.Partial L2
?token=query authentication removed — a credential in a query string lands in access logs, shell history and any error that echoes the URL — and the header comparison is now constant-time.After: child gets
200,HTTP_PROXYunset.H1 — the Gateway saves nothing across turns, and that is correct
Measured over real sockets on realistic two-turn conversations, where a resent history contains each block exactly once:
This is not a bug.
cleanup:session-dedupmarks an elisionrecoverable: trueonly when an intact copy survives elsewhere in the same payload (DECISIONS §16). A sole copy seen only in a previous turn is scored in full and refused — correctly, because Phase A established that the consumer is a stateless provider API with no rehydration mechanism, so such a marker is deletion, not reference.No cross-turn transform is available without provider-side resolvability, which does not exist.
Decision: document it as experimental, withdraw the claim
The mode does deliver transparent interception, the full validation pipeline, byte-faithful forwarding (§38, in #3), metrics, and within-payload deduplication. That is a coherent product. "Cross-turn Session Deduplication" was not. README,
ARCHITECTURE.mdand CLAUDE.md invariant 8 updated.The measurement is pinned as a test rather than left as prose (
test/integration/gateway-dedup-reality.test.ts). If a cross-turn saving ever appears, that is the signal to read: either resolvability was implemented — update the test deliberately — or the drift gate was relaxed and the Gateway is deleting content the model cannot recover, in which case do not.M4, finished rather than deferred
With the Gateway question settled, the remaining overstated README claims are resolved:
TokenHasheris wired inTOKENDAMPER_RISK_TOLERANCE→ marked as having no effect on optimization (H4, still open)TOKENDAMPER_GATEWAY_TOKEN→ described accuratelyVerification
npm run typecheck,npm run lint— cleannpx vitest run— 507 passing, 58 files (up from 499)401 → 200signatureStill open
C4 (structured message content flattened to a string) remains open and is still masked by the fallback — worth noting that if H1's gate were ever relaxed, C4 becomes live
400s. M7 (savings measured against a newline-joined render rather than wire bytes) and M8 (two environment branches in the request path) also remain.🤖 Generated with Claude Code