Skip to content

fix: redact secrets where text leaves the process, not per message - #219

Merged
fstubner merged 1 commit into
mainfrom
fix/secret-egress-chokepoint
Sep 4, 2026
Merged

fix: redact secrets where text leaves the process, not per message#219
fstubner merged 1 commit into
mainfrom
fix/secret-egress-chokepoint

Conversation

@fstubner

@fstubner fstubner commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Six consecutive reviews found the same defect. Six fixes closed the path that reviewer had found. Each was correct; each was incomplete.

Round Where How it was found
1 two endpoint error paths acceptance pass
2 two sibling paths, same file next pass
3 mid-stream SSE error, same file verification pass
4 the usage tool, different module repo-wide sweep
5 a CLI harness quoting its own key reproduced with a stub harness
6 configure --print, and a key in a URL path verification pass

Four of the six were the fix landed in one branch and the sibling beside it kept leaking — twice inside a single file. One round shipped a comment asserting every branch was covered while one was not.

Sites are the wrong unit

There is no bounded list of places a string can be built, and ordinary feature work adds more without their author having any reason to think about credentials. What is bounded is how text leaves the process: JSON for a tool result or an HTTP response, the dispatch log, job files, the terminal.

  • src/redaction.ts owns the registry and one redact(). Secrets are derived from the loaded config — envRefs (every ${VAR} by resolved value), per-route apiKey, and a base_url's userinfo, query values and long path segments — plus values seen while parsing that never become a route field.
  • Registration happens inside loadConfig, so the CLI, the MCP server, the hot reloader and the detached job runner all get it by loading config at all.
  • Wired at eight sinks: dispatch log, MCP tool results, both MCP status resources, HTTP JSON, job stdout/stderr/result.md/partialOutput, the job JSON writer, and stdout/stderr for the whole terminal.

Two findings this turned up on its own

The api_keys: block. Its entries are keyed by route name, so they match no credential-looking key name — a pass measured every entry after the first leaking, and this is the setup this project documents for itself. An entry overridden by an inline api_key: never reaches a route at all, so it is collected at parse time instead.

The terminal sink, found by doing four sinks out of five and measuring rather than assuming. A path-embedded credential survived into status --json, usage and configure --print while the same value was correctly gone from the MCP and HTTP payloads.

Also fixed: configure --print emitted base_url in full while redacting the api_key two lines below and printing a note implying the whole preview was sanitised.

The test

tests/secret-egress.test.ts plants a credential of every shape a config can hold and asserts none appears on any surface. It does not know which code path built the string — which is why a new leaking branch fails it without anyone having thought to write a test for that branch, and why it would have caught all six rounds at once.

Neutering redact() fails four of its six cases.

Verified end to end at the built binary

Config carrying all six credential shapes:

ok  doctor          ok  status          ok  usage
ok  doctor --json   ok  status --json   ok  usage --json
ok  configure --print

A real dispatch through a harness that prints its own key reports auth error: rejected <redacted>, and none of the six values appears in logs/dispatches.jsonl or the job directory.

npm run check: 1237 passed, 8 skipped, exit 0.

Six consecutive reviews found the same defect and six fixes closed the path
that reviewer had found. Each fix was correct and each was incomplete: four
of the six were "the fix landed in one branch and the sibling beside it kept
leaking", twice inside a single file, and one round shipped a comment
asserting every branch was covered while one was not.

Sites are the wrong unit. There is no bounded list of places a string can be
built, and ordinary feature work adds more with no reason for its author to
think about credentials. What IS bounded is how text leaves: JSON for a tool
result or an HTTP response, the dispatch log, job files, the terminal.

- src/redaction.ts owns the secret registry and one redact() call. Secrets
  come from the loaded config — envRefs (every ${VAR} by resolved value),
  per-route apiKey, and a base_url's userinfo, query values and long path
  segments — plus values seen while parsing that never become a route field.
  The api_keys: block is that case: keyed by ROUTE NAME, so it matched no
  credential-looking key name, and a pass measured every entry after the
  first leaking. It is this project's own documented setup.
- Registration happens inside loadConfig, so the CLI, the MCP server, the
  hot reloader and the detached job runner all get it by loading config.
- Wired at eight sinks: the dispatch log, MCP tool results, both MCP status
  resources, HTTP JSON, job stdout/stderr/result.md/partialOutput, the job
  JSON writer, and stdout/stderr for the whole terminal.

The terminal sink was found the way everything else here was found: by
doing four sinks out of five and measuring. A path-embedded credential
survived into `status --json`, `usage` and `configure --print` while the
same value was correctly gone from the MCP and HTTP payloads.

Also: configure --print emitted base_url in full while redacting the
api_key two lines below and printing a note implying the preview was
sanitised. Both now redacted, and the note names what it did.

tests/secret-egress.test.ts plants a credential of every shape a config can
hold and asserts none reaches any surface. Neutering redact() fails four of
its six cases. Verified end to end against the built binary with a config
carrying all six shapes: doctor, doctor --json, status, status --json,
usage, usage --json and configure --print are clean, a real dispatch
through a harness that prints its own key reports <redacted>, and none of
the six values appears in logs/dispatches.jsonl or the job directory.

npm run check: 1237 passed, 8 skipped, exit 0.
@fstubner
fstubner merged commit 5a91b6e into main Sep 4, 2026
4 checks passed
@fstubner
fstubner deleted the fix/secret-egress-chokepoint branch September 4, 2026 12:46
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