Status
Accepted. The redactor was rebuilt in v0.2.Context
Sponson handles secrets on every run:{ secret } references, provider credentials, sensitive outputs such as a
database connection string. Its output goes to terminals, CI logs, pull request comments, MCP results and receipts
committed to a branch. A secret that leaks into any of them is effectively public.
In v0.1, redaction was applied in several places and each was incomplete. It matched only the raw bytes of a value,
while providers echo values JSON-escaped, URL-encoded or base64-encoded. It registered only values resolved inside
the apply loop, so a provider error during plan could print one. It ran on serialized JSON by string replacement,
which once turned "ok": true into "ok": [REDACTED] because a secret’s value was true. And error text was
truncated before it was redacted, so the tail of a secret survived.
Decision
OneRedactor instance (packages/core/src/redact.ts) lives for the whole command. It is created in
packages/cli/src/main.ts, passed to the engine in RunOptions, and everything that leaves the process passes
through it.
- Register first. Before any output exists, the engine registers credential-looking environment variables, every
secret the active lines reference (every command resolves them,
planincluded, so they can be masked), and every sensitive output, including the password inside a URL or DSN. - Every encoding. A registered value is masked raw, JSON-escaped, URL-encoded, base64/base64url at any alignment, whitespace-flattened and line by line, and by its head or tail when a long value was cut off.
- Redact, then truncate. Adapters call
actx.redacton provider text before shortening it. - Structured output is redacted field by field. JSON is produced only by
serialize()(packages/cli/src/output.ts):redactDeeprewrites string leaves, skipping enum fields (status,kind,state,code, …), and only then serializes. Serialized JSON is never string-replaced. - Diffs carry data, not text. A diff side whose value matches a registered secret becomes
{ state: "sensitive" }. - Receipts never hold values, only keyed hashes and non-sensitive outputs.
- Values shorter than four characters cannot be masked without destroying ordinary text; they are not masked, and the CLI warns, without echoing them.
Consequences
- Property invariant I1 (no registered secret in any output, receipt or plan text) holds by construction, and the scenario runner checks it after every step; the secrets journeys include the receipts branch history and PR comments.
- Code that resolves a secret must never format output itself (CONTRIBUTING.md, Style).
- Adding an output channel means routing it through the redactor; there is no second mechanism to keep in sync.
- Over-masking is possible: ordinary text equal to a registered value is masked too.