# ADR-0043: Default-On Denylist Redaction of Credential Material in Persisted and Debug Output

## Status

✅ Accepted (2026-07-07)

## Context

omni-dev persists an append-only request log (`log.jsonl`, see
[docs/log.md](../log.md)) recording every invocation and the HTTP
requests it issues, and it emits `Debug` and `anyhow` error-chain output across
many subsystems (atlassian, datadog, snowflake, the AI backends, the browser
bridge). Both are diagnostic sinks that a developer reads, redirects to a file,
or pastes into a bug report — so **credential material must never reach them**.

Credential material flows toward those sinks through five channels:

1. **HTTP request/response headers** — `Authorization`, `Cookie`, API-key
   headers, and off-list variants (`x-auth-token`, `x-goog-api-key`, …).
2. **URL query and fragment parameters** — `?access_token=…`, presigned
   `X-Amz-Signature=…`, OAuth `#id_token=…`.
3. **Process argv** — a secret passed as `--api-key VALUE` or embedded in a
   `--url` argument, captured in the invocation record.
4. **The `OMNI_DEV_*` env snapshot** — captured alongside the invocation.
5. **Struct `Debug`** — a credential-bearing config/client struct reaching a
   `tracing::debug!("{creds:?}")` or `.context(format!("… {creds:?}"))`.

The asymmetry is the crux: a single **false negative** (one leaked secret) is a
credential-disclosure incident, whereas a **false positive** (an over-redacted
benign value) merely makes one log line less informative. The two error
directions are not equally costly, so the policy is not tuned for accuracy — it
is tuned to eliminate false negatives.

This policy was built out incrementally across
[#1129](https://github.com/rust-works/omni-dev/issues/1129),
[#1131](https://github.com/rust-works/omni-dev/issues/1131),
[#1133](https://github.com/rust-works/omni-dev/issues/1133),
[#1134](https://github.com/rust-works/omni-dev/issues/1134),
[#1143](https://github.com/rust-works/omni-dev/issues/1143), and
[#1144](https://github.com/rust-works/omni-dev/issues/1144) (commits
`8a356ff9`, `1ff8eb29`, `d3d807cf`, `6c70cddc`, `b590e551`) without an ADR
recording the *why*. It is a cross-cutting security invariant whose reach
extends beyond the log — the `Secret` `Debug` newtype is wired through the
atlassian/datadog/snowflake credential structs — so it is documented here as an
independent decision rather than folded into the request-log design.

## Decision

**Invariant, stated once:** no secret material is ever persisted or printed.
The guarantee is **default-on** — it holds without any flag, on every code path;
the only opt-*out* is disabling the request log wholesale
(`OMNI_DEV_LOG_DISABLE`). Two independent mechanisms compose to enforce it:
write-side scrubbing of the request log, and the `Secret` newtype guarding
`Debug`/error output.

### Denylist over allowlist — over-redact in the safe direction

The load-bearing, deliberately contested choice: redaction matches a
**denylist** — a value is redacted when its *name* (header, query key, flag, or
env-var name) matches a sensitive substring — rather than an allowlist that
would pass through only names known to be safe.

A denylist has known false negatives (a novel secret parameter named nothing
like `token`/`secret`/`key` slips through) and an allowlist has known false
positives, but they fail in opposite directions. Given the asymmetry above, the
denylist's failures are still oriented toward disclosure while an allowlist's are
oriented toward over-redaction — the wrong way round. So the policy pairs the
denylist with **broad substring markers** and **open-ended families**
(suffixes/prefixes) that intentionally over-match, accepting false positives to
shrink the false-negative surface. The one place an allowlist *is* used is the
`OMNI_DEV_*` env snapshot, which first restricts to the `OMNI_DEV_` prefix (an
allowlist of names) and *then* applies the denylist to values within it — the
allowlist there bounds *what is captured at all*, not what is treated as secret.

### Write-side, central enforcement

Redaction runs where records are **written**, not where they are read:

- HTTP records: `record_http_with`
  ([src/request_log.rs:814](../../src/request_log.rs#L814)) redacts the URL
  unconditionally and headers/bodies only when opted in.
- Invocation records: `record_invocation`
  ([src/request_log.rs:715](../../src/request_log.rs#L715)) scrubs argv via
  `scrub_argv` and the env snapshot via `whitelisted_env` before writing.

Because the on-disk line is already clean, **no caller can bypass redaction and
every reader/format (`oneline`, `json`, `full`) is covered with zero reader-side
logic** — the `json` renderer is byte-identical to the stored line and still
carries no secrets.

### Coverage — one denylist per channel

| Channel | Mechanism | Anchor |
|---|---|---|
| HTTP headers | fixed `SENSITIVE_HEADERS` list **plus** `SENSITIVE_HEADER_MARKERS` substrings (`auth`, `token`, `key`, `secret`, `cookie`, `password`, `session`, `signature`, `credential`) → value replaced with `REDACTED` | `redact_headers` ([:886](../../src/request_log.rs#L886)) |
| URL query/fragment **values** | `sensitive_query_key`: exact keys `sig`/`sas`/`jwt`/`auth`, suffixes `*token`/`*secret`/`*password`/`*signature`/`*api_key`…, and `x-amz-`/`x-goog-` presigned-family prefixes; the value becomes `REDACTED` while scheme/host/path and all keys are preserved | `redact_url` ([:1065](../../src/request_log.rs#L1065)) |
| Argv flag values | `is_secretish_flag` (matches per `-`/`_`-separated segment, so `--api-key` is caught but `--keyword` is not; `-file`/`-path` tails exempt as they carry paths) plus dedicated `--header`/`--body` handling, in both `--flag value` and `--flag=value` forms | `scrub_argv` ([:962](../../src/request_log.rs#L962)) |
| Argv URL values | every argv element is additionally run through `redact_url`, so a secret carried in a URL argument (`--url …?access_token=…`, an OAuth callback with `#id_token=…`) is redacted even though `--url` is not a secret-bearing flag name (#1162) | `scrub_argv` ([:962](../../src/request_log.rs#L962)) |
| `OMNI_DEV_*` env | after the `OMNI_DEV_` prefix allowlist, any name containing the `SECRETISH` substrings `TOKEN`/`SECRET`/`KEY`/`PASSWORD`/`PASSWD` has its value replaced with `REDACTED` | `whitelisted_env` ([:1143](../../src/request_log.rs#L1143)) |

### Bodies and headers are opt-in

Request/response **bodies** (`OMNI_DEV_LOG_BODIES`,
[:298](../../src/request_log.rs#L298)) and **headers** (`OMNI_DEV_LOG_HEADERS`,
[:303](../../src/request_log.rs#L303)) are off by default — a usability-vs-
exposure balance: they are the highest-value diagnostic content but also the
highest-risk. Even when headers are enabled they still pass through
`redact_headers`, so opting in never disables header redaction; it only adds the
non-sensitive headers to the record.

### URL redaction preserves keys and paths

`redact_url` replaces only the *values* of secret-bearing parameters, leaving
the scheme, host, path, and every parameter *key* intact. This is a deliberate
usability/security balance: `omni-dev log --url <substring>` filtering keeps
working (the operator can still search by endpoint and parameter name), while no
secret value survives. It matters most for the browser bridge, which logs
arbitrary operator-supplied target URLs.

### Second mechanism — the `Secret` newtype for `Debug`/error output

Log scrubbing does not cover the *other* sink: a credential struct formatted
with `{:?}` into a `tracing` line or an error context. `Secret`
([src/utils/secret.rs](../../src/utils/secret.rs)) is a `String` newtype whose
hand-written `Debug` prints `<redacted>`, and which implements **neither**
`Display` **nor** any serde trait — so a wrapped credential cannot be
`{}`-formatted, `{:?}`-formatted, or accidentally serialized. The only way out
is a loud, greppable `expose_secret()` call, making every point where a secret
leaves the wrapper auditable. It is wired through the atlassian, datadog, and
snowflake credential structs (commit `b590e551`, ~15 files), so a future
`debug!("{creds:?}")` on any of them redacts by construction rather than by the
author remembering to.

### Explicitly out of scope

- **Prompt / body *content* sensitivity.** The guarantees above keep *credential
  material* out; they do not make AI prompt **content** secret. Prompts carry
  whatever was asked about (repo diffs, commit messages, JIRA/Confluence data),
  and that content surfaces via `OMNI_DEV_LOG_BODIES`, `RUST_LOG=debug` stderr
  tracing, and `claude-cli` subprocess error passthrough. This is a
  data-sensitivity note, **not** a credential-leak guarantee — no API keys or
  tokens appear on those paths.
- **The browser-bridge stdout session token.** A foreground `serve` prints the
  loopback-only, process-lifetime session token to stdout for the operator to
  paste into DevTools; capture of that output is an accepted residual risk
  already recorded in [ADR-0036](adr-0036.md).

## Consequences

- **False positives are accepted and observable.** A benign flag like
  `--keyword` is *not* caught (segment-aware matching), but a benign parameter
  such as `?keychain=…` or a header named `x-request-key` *will* show `REDACTED`.
  This is by design and does not warrant narrowing a denylist entry — a narrower
  match trades a harmless false positive for a possible false negative.
- **Readers and formats carry zero redaction logic.** Because enforcement is
  write-side and central, adding a new output format or query filter cannot
  reintroduce a leak, and the stored line is safe to copy verbatim.
- **The denylist arrays are the maintenance surface.** Supporting a new secret
  family is a one-line addition to `SENSITIVE_HEADERS`/`SENSITIVE_HEADER_MARKERS`,
  `SENSITIVE_QUERY_*`, `SECRETISH_FLAG_WORDS`, or `SECRETISH`, each covered by a
  case in the `#[cfg(test)]` block in `src/request_log.rs`. New credential
  structs must wrap secret fields in `Secret` to inherit the `Debug` guarantee —
  it is not automatic for types that predate the newtype.
- **Relationship to neighbouring ADRs.** [ADR-0028](adr-0028.md) documents
  scrubbing the environment passed to the sandboxed `claude-cli` **subprocess**
  (a different mechanism and threat — preventing a child process from seeing
  host secrets), and [ADR-0036](adr-0036.md) documents the browser-bridge
  **token** trust boundary. Neither states this general "never persist or print
  secret material; denylist redaction; bodies opt-in" invariant, which is why it
  stands alone.
- **Sync obligation.** The operator-facing description of this policy lives in
  [docs/log.md](../log.md) "Redaction posture"; keep the two in sync when
  changing any denylist, the opt-in flags, or the URL-preservation behaviour.

See [docs/log.md](../log.md) "Redaction posture" for the operator-facing
description.
