fix: redact secrets where text leaves the process, not per message - #219
Merged
Conversation
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.
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.
Six consecutive reviews found the same defect. Six fixes closed the path that reviewer had found. Each was correct; each was incomplete.
usagetool, different moduleconfigure --print, and a key in a URL pathFour 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.tsowns the registry and oneredact(). Secrets are derived from the loaded config —envRefs(every${VAR}by resolved value), per-routeapiKey, and abase_url's userinfo, query values and long path segments — plus values seen while parsing that never become a route field.loadConfig, so the CLI, the MCP server, the hot reloader and the detached job runner all get it by loading config at all.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 inlineapi_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,usageandconfigure --printwhile the same value was correctly gone from the MCP and HTTP payloads.Also fixed:
configure --printemittedbase_urlin 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.tsplants 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:
A real dispatch through a harness that prints its own key reports
auth error: rejected <redacted>, and none of the six values appears inlogs/dispatches.jsonlor the job directory.npm run check: 1237 passed, 8 skipped, exit 0.