Annie Voigt commited on
Commit
a28dcdc
·
1 Parent(s): 2ae2f74

feat(logging): ADR-0013 audit-trace redaction (secret/credential/PII scrubbing)

Browse files

Deterministic, no-LLM redaction pass applied to the execution trace at a single
seam before it is persisted, so no sink (local/hf/s3) ever receives credentials
or PII. The audit store IS the audit control, so a secret landing in it is a leak
into the trust artifact itself.

- src/core/trace_redaction.py: pure redact_trace() (deep-copies, walks every
string, typed «REDACTED:...» placeholders) + fail-closed redact_trace_safe()
that returns a minimal trace (run_id+timestamp, no payload) on any error and
never raises. Patterns: sk-ant-/hf_/AKIA/generic sk-/Bearer/gh[pousr]_ tokens,
credentialed URLs (host kept), email, whole-value env-dump drop, and an
entropy-gated 40+char base64 run for AWS secret keys. Config: TRACE_REDACTION
(on default; off = local-debug only), TRACE_MAX_FIELD_CHARS, TRACE_REDACTION_EXTRA.
- Wired at both persist seams: agent.run() before persist_trace_safe, and
WorkflowEngine.save_trace_to_file (covers agent.save_trace() too).
- tests/test_trace_redaction.py: 20 network-free tests — each pattern scrubbed,
clean/gene-symbol traces pass through, input not mutated, size cap, off switch,
fail-closed minimal trace, and end-to-end that a planted secret never reaches a
local sink.

Best-effort scrubbing, not a proof of secret-free logs; defense-in-depth with
ADR-0012 token hygiene and ADR-0014 secret scanning.

docs/adr/ADR-0013-audit-trace-redaction.md ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-0013 — Audit-Trace Redaction (Secret / Credential / PII Scrubbing)
2
+
3
+ **Status:** Accepted — implemented 2026-07-02 (AWS-independent; live once deployed)
4
+ **Date:** 2026-07-02
5
+ **Deciders:** Annie Voigt (project lead)
6
+ **Driver:** OHSU security review — the always-on audit trace (ADR-0008) captures
7
+ the full prompt and the code the agent generated and executed. Anything a user
8
+ pastes, or that generated code prints, is persisted **verbatim** to the trace
9
+ store. Since that store *is* the audit control, a secret or identifier landing in
10
+ it is a leak into the very artifact meant to be trusted.
11
+ **Related:** ADR-0008 (always-on logging — defines the persist path this hooks),
12
+ ADR-0009 (S3 sink — same redacted payload lands there at cutover), ADR-0011
13
+ (upload de-identification attestation — dataset content; this ADR is about
14
+ secrets/PII in prompts+code, a different surface), ADR-0012 (identity in trace —
15
+ must itself not over-collect).
16
+
17
+ ---
18
+
19
+ ## Context
20
+
21
+ `CodeAgent.get_trace()` returns
22
+ `{execution_time, config, messages, trace_logs}`, where `messages` is the full
23
+ message history (user prompts + model turns) and `trace_logs` includes the
24
+ generated code and captured stdout. `agent.run()` persists this on every live
25
+ run via `persist_trace_safe(get_log_sink(), run_id, self.get_trace())`. There is
26
+ **no redaction** anywhere in `src/logging_sink.py` or in `get_trace()`.
27
+
28
+ Realistic leak paths into the trace:
29
+ - A user pastes an API key, token, or a credentialed URL into the chat prompt.
30
+ - Generated code echoes the environment (`print(os.environ)`), a connection
31
+ string, or a bearer token.
32
+ - The ADR-0012 orchestrator→specialist **service token** appears in an error
33
+ string or a debug print.
34
+ - Personal identifiers (email, name) in a prompt — the data is de-identified per
35
+ ADR-0011, but free-text prompts are not.
36
+
37
+ The fail-open wrapper (`persist_trace_safe`) means a bad trace is written
38
+ silently, so there is no natural backstop.
39
+
40
+ ## Decision
41
+
42
+ Add a deterministic, **no-LLM** redaction pass applied to the trace immediately
43
+ before it is persisted, in both the always-on sink path and the opt-in
44
+ `save_trace` file dump.
45
+
46
+ - **A pure function `redact_trace(trace: dict) -> dict`** (new
47
+ `src/core/trace_redaction.py`, mirroring the shared-helper pattern of
48
+ `src/core/integrity.py`). It deep-copies and walks all string values in
49
+ `messages` + `trace_logs` and replaces matches with a typed placeholder
50
+ (`«REDACTED:anthropic_key»`, `«REDACTED:hf_token»`, etc.).
51
+ - **Pattern set (deterministic regex, high-precision):**
52
+ - Anthropic keys (`sk-ant-…`), HuggingFace tokens (`hf_…`), AWS access keys
53
+ (`AKIA…`) + secret-key-shaped high-entropy strings, generic `Bearer <token>`,
54
+ OpenAI-style `sk-…`, and credentialed URLs (`https://user:pass@…`).
55
+ - Whole-value drop for obvious environment dumps (a dict/text blob containing
56
+ multiple `KEY=VALUE` env lines) → `«REDACTED:env_dump»`.
57
+ - Email addresses → `«REDACTED:email»` (PII; conservative, on by default).
58
+ - **Applied at one seam.** Hook `redact_trace` into `agent.run()` right before
59
+ `persist_trace_safe(...)` and before the file dump — so every sink (`local` /
60
+ `hf` / `s3`) and every path receives the redacted payload. The sinks stay
61
+ dumb; redaction is not per-sink.
62
+ - **Fail-closed on redaction, fail-open on logging.** If `redact_trace` itself
63
+ raises, persist a **minimal** trace (run_id + timestamp + "redaction_error")
64
+ rather than the raw payload — never write an unredacted trace, but still never
65
+ crash the run.
66
+ - **Size cap.** Truncate any single value over a configurable limit
67
+ (`TRACE_MAX_FIELD_CHARS`) so a pathological paste can't bloat the store.
68
+ - **Config, allow tuning:** `TRACE_REDACTION` (`on` default | `off` for local
69
+ debug only) + an extensible extra-patterns list; **off is never the prod
70
+ posture** and that is documented.
71
+
72
+ Redaction runs on a copy; the in-memory trace the UI/eval harness reads is
73
+ unchanged, so no user-facing behavior changes — only what is *persisted*.
74
+
75
+ ## Plan
76
+
77
+ - **Now (AWS-independent, ~2–3 days):**
78
+ 1. `src/core/trace_redaction.py` with the pattern set + `redact_trace`.
79
+ 2. Wire it into `agent.run()` before both persist paths.
80
+ 3. Tests (`tests/test_trace_redaction.py`): each pattern is scrubbed; a
81
+ planted `sk-ant-…` / `hf_…` / env-dump never reaches a stub sink; redaction
82
+ failure yields the minimal trace, not the raw one; clean traces pass through
83
+ unchanged.
84
+ 4. Document `TRACE_REDACTION` + the "off ≠ prod" note.
85
+ - **No AWS dependency at all** — this hardens the payload *before* it reaches any
86
+ sink, so it is complete independent of the S3 cutover, and the S3 sink
87
+ (ADR-0009) inherits it for free.
88
+
89
+ ## Consequences
90
+
91
+ - **Positive:** the audit store can no longer silently capture credentials/PII;
92
+ closes the leak into the trust artifact itself; a single seam covers all
93
+ sinks; deterministic + testable, no model call, no latency of note.
94
+ - **Cost / caveat:** regex redaction is high-precision but not exhaustive — a
95
+ novel secret format can slip through, so this is defense-in-depth layered with
96
+ ADR-0012 (don't put the service token where it can be printed) and secret
97
+ hygiene (ADR-0014), not a guarantee. Over-eager patterns could redact
98
+ legitimate content (e.g. a gene identifier that looks token-shaped); keep
99
+ patterns anchored/high-entropy and cover with tests. State to the review that
100
+ redaction is best-effort scrubbing, not a proof of secret-free logs.
101
+ - **Honesty note:** this reduces *accidental* capture; it is not a substitute for
102
+ not exposing secrets to the agent in the first place.
103
+
104
+ ## Start-now checklist
105
+ - [x] `src/core/trace_redaction.py` (`redact_trace`, pattern set, size cap).
106
+ - [x] Hook before `persist_trace_safe` (`agent.run()`) + the `save_trace` file
107
+ dump (`WorkflowEngine.save_trace_to_file`, which also covers `agent.save_trace()`).
108
+ - [x] Fail-closed minimal-trace path on redaction error (`redact_trace_safe`).
109
+ - [x] `tests/test_trace_redaction.py` (20 tests, network-free) + `TRACE_REDACTION`
110
+ config doc (module docstring + Decision above).
111
+
112
+ ## Implementation notes (2026-07-02)
113
+ - Patterns landed: anthropic (`sk-ant-…`), hf (`hf_…`), AWS access key (`AKIA…`),
114
+ generic OpenAI `sk-…`, `Bearer …`, GitHub `gh[pousr]_…`, credentialed URL
115
+ (host preserved, `user:pass@` dropped), email, whole-value env-dump drop
116
+ (≥3 `UPPER_SNAKE=value` lines), and an entropy-gated 40+char base64 run for the
117
+ AWS *secret* key shape (`TRACE_REDACTION_EXTRA` adds operator regexes →
118
+ `«REDACTED:custom»`).
119
+ - Entropy gate (≥4.0 bits/char) on the 40+char matcher keeps repetitive/low-entropy
120
+ identifiers (e.g. a long gene-id run) from tripping the secret pattern.
121
+ - Size cap is `TRACE_MAX_FIELD_CHARS` (default 20 000); scrubbing runs *before*
122
+ truncation so a secret straddling the boundary is removed, not half-exposed.
123
+ - Both seams call `redact_trace_safe`, which fails **closed** to a minimal trace
124
+ (`run_id` + timestamp + `redaction_error`, no payload) and never raises, so the
125
+ fail-open sink wrapper still governs crash-safety.
src/agent.py CHANGED
@@ -537,8 +537,14 @@ class CodeAgent:
537
  # wrapped so a logging failure never crashes the run.
538
  try:
539
  from logging_sink import get_log_sink, persist_trace_safe
 
540
  run_id = time.strftime("%Y%m%d_%H%M%S")
541
- persist_trace_safe(get_log_sink(), run_id, self.get_trace())
 
 
 
 
 
542
  except Exception as e: # noqa: BLE001 — logging must never crash a run
543
  print(f"[log_sink] trace persistence skipped: {e}")
544
 
 
537
  # wrapped so a logging failure never crashes the run.
538
  try:
539
  from logging_sink import get_log_sink, persist_trace_safe
540
+ from core.trace_redaction import redact_trace_safe
541
  run_id = time.strftime("%Y%m%d_%H%M%S")
542
+ # ADR-0013: scrub secrets/credentials/PII on a COPY before it reaches
543
+ # any sink — fail-closed to a minimal trace if redaction itself fails,
544
+ # so an unredacted payload is never persisted. The in-memory trace the
545
+ # UI/eval harness reads is untouched.
546
+ redacted = redact_trace_safe(self.get_trace(), run_id)
547
+ persist_trace_safe(get_log_sink(), run_id, redacted)
548
  except Exception as e: # noqa: BLE001 — logging must never crash a run
549
  print(f"[log_sink] trace persistence skipped: {e}")
550
 
src/core/trace_redaction.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic, no-LLM redaction of the audit trace before it is persisted (ADR-0013).
2
+
3
+ The always-on audit trace (ADR-0008) captures the full prompt and the code the
4
+ agent generated and ran, verbatim, into the trace store — and that store *is* the
5
+ audit control. A secret, credential, or personal identifier landing in it is a
6
+ leak into the very artifact meant to be trusted. This module scrubs that payload
7
+ *once*, at a single seam right before it reaches any sink (``local`` / ``hf`` /
8
+ ``s3``), so the sinks stay dumb and every path inherits redaction for free.
9
+
10
+ Design (mirrors the shared-helper pattern of :mod:`src.core.integrity`):
11
+
12
+ - :func:`redact_trace` is a **pure function**: it deep-copies the trace and walks
13
+ every string value, replacing matches with a typed placeholder
14
+ (``«REDACTED:anthropic_key»``, ``«REDACTED:hf_token»``, …). The in-memory trace
15
+ the UI / eval harness reads is never mutated — only the persisted copy.
16
+ - :func:`redact_trace_safe` is the **fail-closed** wrapper the run path calls: if
17
+ redaction itself raises, it returns a *minimal* trace (run_id + timestamp +
18
+ ``redaction_error``) rather than the raw payload — never write an unredacted
19
+ trace, but never crash the run either (the logging sink is fail-open around
20
+ this in turn).
21
+
22
+ Redaction is **best-effort scrubbing, not a proof of secret-free logs**: the
23
+ patterns are high-precision regex, so a novel secret format can slip through.
24
+ It is defense-in-depth layered with not exposing secrets to the agent in the
25
+ first place (ADR-0012 service-token hygiene, ADR-0014 secret scanning) — it
26
+ reduces *accidental* capture, it does not license putting secrets in prompts.
27
+
28
+ Config (all env, all optional):
29
+
30
+ TRACE_REDACTION on (default) | off — ``off`` is local-debug only and
31
+ is NEVER the prod posture.
32
+ TRACE_MAX_FIELD_CHARS per-value size cap (default: 20000)
33
+ TRACE_REDACTION_EXTRA extra regexes, newline- or comma-separated, each
34
+ scrubbed to ``«REDACTED:custom»`` (extensible tuning).
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import copy
39
+ import math
40
+ import os
41
+ import re
42
+ import time
43
+ from typing import Any
44
+
45
+ # --------------------------------------------------------------------------- #
46
+ # Config
47
+ # --------------------------------------------------------------------------- #
48
+ _DEFAULT_MAX_FIELD_CHARS = 20_000
49
+
50
+
51
+ def _redaction_enabled() -> bool:
52
+ """True unless TRACE_REDACTION is explicitly ``off`` (default on).
53
+
54
+ ``off`` is for local debugging only and is never the production posture.
55
+ """
56
+ return os.environ.get("TRACE_REDACTION", "on").strip().lower() != "off"
57
+
58
+
59
+ def _max_field_chars() -> int:
60
+ """Per-value truncation cap. Non-positive / unparsable disables truncation."""
61
+ raw = os.environ.get("TRACE_MAX_FIELD_CHARS")
62
+ if raw is None:
63
+ return _DEFAULT_MAX_FIELD_CHARS
64
+ try:
65
+ val = int(raw)
66
+ except (TypeError, ValueError):
67
+ return _DEFAULT_MAX_FIELD_CHARS
68
+ return val if val > 0 else 0
69
+
70
+
71
+ # --------------------------------------------------------------------------- #
72
+ # Pattern set — deterministic, high-precision. Order matters: the most specific
73
+ # key shapes are scrubbed before the generic `sk-…` / Bearer catch-alls.
74
+ # --------------------------------------------------------------------------- #
75
+ def _placeholder(kind: str) -> str:
76
+ return f"«REDACTED:{kind}»"
77
+
78
+
79
+ # (compiled_regex, kind). Applied in order via re.sub.
80
+ _PATTERNS: list[tuple[re.Pattern, str]] = [
81
+ # Credentialed URL (user:pass@host) — scrub the embedded creds, keep the host
82
+ # so a trace line stays readable. Must run before the generic patterns.
83
+ (re.compile(r"(https?://)[^\s:/@]+:[^\s:/@]+@"), "credential_url"),
84
+ # Anthropic keys (sk-ant-…) — before the generic OpenAI-style sk-…
85
+ (re.compile(r"sk-ant-[A-Za-z0-9_\-]{16,}"), "anthropic_key"),
86
+ # HuggingFace tokens
87
+ (re.compile(r"\bhf_[A-Za-z0-9]{20,}\b"), "hf_token"),
88
+ # AWS access key id
89
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "aws_access_key"),
90
+ # OpenAI-style secret keys (generic sk-…), after sk-ant-… above
91
+ (re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"), "openai_key"),
92
+ # Bearer <token>
93
+ (re.compile(r"[Bb]earer\s+[A-Za-z0-9._\-]{8,}"), "bearer_token"),
94
+ # GitHub / generic ghp_/gho_/ghs_ tokens (cheap, high precision)
95
+ (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"), "github_token"),
96
+ # Email addresses (PII; conservative, on by default). Last, so it never
97
+ # clobbers a token that happens to contain an @.
98
+ (re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b"), "email"),
99
+ ]
100
+
101
+ # Credentialed-URL sub keeps the scheme + host, drops the "user:pass@".
102
+ _CREDENTIAL_URL_RE = _PATTERNS[0][0]
103
+
104
+ # An env line: UPPER_SNAKE=value. Three or more => an env dump; drop the whole value.
105
+ _ENV_LINE_RE = re.compile(r"^\s*[A-Z][A-Z0-9_]{2,}=.+$", re.MULTILINE)
106
+ _ENV_DUMP_MIN_LINES = 3
107
+
108
+ # AWS secret-access-key shape: a 40-or-more-char base64-ish run, standalone (AWS
109
+ # secrets are exactly 40; ≥40 also catches other long secret blobs). Paired with a
110
+ # Shannon-entropy gate so ordinary long text or gene-id runs don't trip it.
111
+ # Anchored on non-base64 boundaries to avoid slicing a longer token.
112
+ _SECRET40_RE = re.compile(r"(?<![A-Za-z0-9/+])[A-Za-z0-9/+]{40,}(?![A-Za-z0-9/+])")
113
+ _SECRET40_MIN_ENTROPY = 4.0 # bits/char; random base64 ≈ 6, English prose ≈ 2–3.
114
+
115
+
116
+ def _shannon_entropy(s: str) -> float:
117
+ """Bits-per-character Shannon entropy of ``s`` (0.0 for empty)."""
118
+ if not s:
119
+ return 0.0
120
+ counts: dict[str, int] = {}
121
+ for ch in s:
122
+ counts[ch] = counts.get(ch, 0) + 1
123
+ n = len(s)
124
+ return -sum((c / n) * math.log2(c / n) for c in counts.values())
125
+
126
+
127
+ def _looks_like_env_dump(s: str) -> bool:
128
+ """True if ``s`` contains several UPPER_SNAKE=value lines (a leaked env)."""
129
+ return len(_ENV_LINE_RE.findall(s)) >= _ENV_DUMP_MIN_LINES
130
+
131
+
132
+ def _extra_patterns() -> list[re.Pattern]:
133
+ """Compile TRACE_REDACTION_EXTRA (newline/comma-separated regexes)."""
134
+ raw = os.environ.get("TRACE_REDACTION_EXTRA")
135
+ if not raw:
136
+ return []
137
+ out: list[re.Pattern] = []
138
+ for piece in re.split(r"[\n,]", raw):
139
+ piece = piece.strip()
140
+ if not piece:
141
+ continue
142
+ try:
143
+ out.append(re.compile(piece))
144
+ except re.error:
145
+ # A bad custom pattern must never break redaction of everything else.
146
+ continue
147
+ return out
148
+
149
+
150
+ def _scrub_string(s: str, max_chars: int, extra: list[re.Pattern]) -> str:
151
+ """Scrub one string value: env-dump drop, pattern subs, high-entropy secrets, cap.
152
+
153
+ Scrubbing runs *before* truncation so a secret straddling the cap boundary is
154
+ still removed rather than half-exposed.
155
+ """
156
+ if not s:
157
+ return s
158
+
159
+ # Whole-value drop for an obvious environment dump.
160
+ if _looks_like_env_dump(s):
161
+ return _placeholder("env_dump")
162
+
163
+ # Credentialed URL: keep scheme+host, drop the user:pass@ segment.
164
+ s = _CREDENTIAL_URL_RE.sub(lambda m: m.group(1) + _placeholder("credential_url") + "@", s)
165
+
166
+ # Remaining fixed-shape secrets / PII.
167
+ for pattern, kind in _PATTERNS[1:]:
168
+ s = pattern.sub(_placeholder(kind), s)
169
+
170
+ # High-entropy 40-char base64 runs (AWS secret-key shape), entropy-gated.
171
+ def _maybe_secret(m: re.Match) -> str:
172
+ tok = m.group(0)
173
+ return _placeholder("high_entropy_secret") if _shannon_entropy(tok) >= _SECRET40_MIN_ENTROPY else tok
174
+
175
+ s = _SECRET40_RE.sub(_maybe_secret, s)
176
+
177
+ # Operator-supplied extra patterns.
178
+ for pattern in extra:
179
+ s = pattern.sub(_placeholder("custom"), s)
180
+
181
+ # Size cap last (defends against a pathological paste bloating the store).
182
+ if max_chars and len(s) > max_chars:
183
+ s = s[:max_chars] + _placeholder("truncated")
184
+
185
+ return s
186
+
187
+
188
+ def _walk(node: Any, max_chars: int, extra: list[re.Pattern]) -> Any:
189
+ """Recursively scrub every string in a nested dict/list structure in place."""
190
+ if isinstance(node, str):
191
+ return _scrub_string(node, max_chars, extra)
192
+ if isinstance(node, dict):
193
+ for k in list(node.keys()):
194
+ node[k] = _walk(node[k], max_chars, extra)
195
+ return node
196
+ if isinstance(node, list):
197
+ for i, v in enumerate(node):
198
+ node[i] = _walk(v, max_chars, extra)
199
+ return node
200
+ if isinstance(node, tuple):
201
+ return tuple(_walk(v, max_chars, extra) for v in node)
202
+ return node
203
+
204
+
205
+ # --------------------------------------------------------------------------- #
206
+ # Public API
207
+ # --------------------------------------------------------------------------- #
208
+ def redact_trace(trace: dict) -> dict:
209
+ """Return a deep-copied, redacted copy of ``trace`` (pure; input unmutated).
210
+
211
+ Walks every string value under ``messages`` and ``trace_logs`` (and any other
212
+ key present) and replaces secrets / credentials / PII with typed placeholders.
213
+ When ``TRACE_REDACTION=off`` the trace is returned unchanged (local debug only).
214
+
215
+ Raises only on a genuinely malformed input; callers on the persist path use
216
+ :func:`redact_trace_safe`, which fails closed to a minimal trace instead.
217
+ """
218
+ if not _redaction_enabled():
219
+ return trace
220
+ redacted = copy.deepcopy(trace)
221
+ return _walk(redacted, _max_field_chars(), _extra_patterns())
222
+
223
+
224
+ def minimal_trace(run_id: "str | None", reason: str = "redaction_error") -> dict:
225
+ """A stripped trace written when redaction fails — carries no raw payload."""
226
+ return {
227
+ "run_id": run_id,
228
+ "execution_time": time.strftime("%Y-%m-%d %H:%M:%S"),
229
+ "redaction_error": reason,
230
+ "messages": [],
231
+ "trace_logs": [],
232
+ }
233
+
234
+
235
+ def redact_trace_safe(trace: dict, run_id: "str | None" = None) -> dict:
236
+ """Redact ``trace``, failing **closed** to a minimal trace on any error.
237
+
238
+ This is the wrapper the always-on persist path and the file dump call: it
239
+ never returns the raw payload if scrubbing did not complete, and it never
240
+ raises (so the surrounding fail-open logging wrapper still governs crashes).
241
+ """
242
+ try:
243
+ return redact_trace(trace)
244
+ except Exception as e: # noqa: BLE001 — never persist a raw trace on redaction failure
245
+ print(f"[trace_redaction] redaction failed, writing minimal trace: {e}")
246
+ return minimal_trace(run_id, reason=f"redaction_error: {type(e).__name__}")
src/managers/workflow/workflow_engine.py CHANGED
@@ -3,6 +3,7 @@ Workflow Engine Manager for CodeAct Agent.
3
  Manages the LangGraph workflow execution.
4
  """
5
 
 
6
  import re
7
  import json
8
  import uuid
@@ -251,6 +252,13 @@ class WorkflowEngine:
251
  "trace_logs": self.trace_logs
252
  }
253
 
 
 
 
 
 
 
 
254
  with open(filepath, 'w', encoding='utf-8') as f:
255
  json.dump(trace_data, f, indent=2, ensure_ascii=False)
256
 
 
3
  Manages the LangGraph workflow execution.
4
  """
5
 
6
+ import os
7
  import re
8
  import json
9
  import uuid
 
252
  "trace_logs": self.trace_logs
253
  }
254
 
255
+ # ADR-0013: redact secrets/credentials/PII before writing to disk, on a
256
+ # copy (in-memory history untouched). Fail-closed to a minimal trace if
257
+ # scrubbing fails — the opt-in file dump is a persist path too.
258
+ from core.trace_redaction import redact_trace_safe
259
+ run_id = os.path.splitext(os.path.basename(filepath))[0]
260
+ trace_data = redact_trace_safe(trace_data, run_id)
261
+
262
  with open(filepath, 'w', encoding='utf-8') as f:
263
  json.dump(trace_data, f, indent=2, ensure_ascii=False)
264
 
tests/test_trace_redaction.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for audit-trace redaction (ADR-0013).
2
+
3
+ Covers the pure :func:`redact_trace` pattern set (each secret / credential / PII
4
+ shape is scrubbed to a typed placeholder; clean traces pass through unchanged;
5
+ the input dict is never mutated), the fail-closed :func:`redact_trace_safe`
6
+ wrapper (a redaction error yields a minimal trace, not the raw payload), the
7
+ size cap, the ``TRACE_REDACTION=off`` escape hatch, and end-to-end that a planted
8
+ secret never reaches a stub sink through ``persist_trace_safe``. Nothing here
9
+ touches the network.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+
18
+ # src/ on path so `import core.trace_redaction` / `import logging_sink` match how
19
+ # agent.py and the sinks import them.
20
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
21
+
22
+ import core.trace_redaction as tr # noqa: E402
23
+ from core.trace_redaction import ( # noqa: E402
24
+ redact_trace,
25
+ redact_trace_safe,
26
+ minimal_trace,
27
+ )
28
+
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # Helpers
32
+ # --------------------------------------------------------------------------- #
33
+ def _trace_with(text: str) -> dict:
34
+ """A minimal trace shaped like get_trace() with `text` in a prompt + a log."""
35
+ return {
36
+ "execution_time": "2026-07-02 10:00:00",
37
+ "config": {"max_steps": 10, "timeout_seconds": 60, "verbose": False},
38
+ "messages": [{"type": "human", "content": text}],
39
+ "trace_logs": [{"step_count": 1, "step_type": "observation", "content": text}],
40
+ }
41
+
42
+
43
+ def _all_strings(node) -> str:
44
+ """Concatenate every string in a nested structure (for leak assertions)."""
45
+ if isinstance(node, str):
46
+ return node
47
+ if isinstance(node, dict):
48
+ return " ".join(_all_strings(v) for v in node.values())
49
+ if isinstance(node, (list, tuple)):
50
+ return " ".join(_all_strings(v) for v in node)
51
+ return ""
52
+
53
+
54
+ @pytest.fixture(autouse=True)
55
+ def _redaction_on(monkeypatch):
56
+ """Default every test to the prod posture (on) unless it overrides."""
57
+ monkeypatch.setenv("TRACE_REDACTION", "on")
58
+ monkeypatch.delenv("TRACE_MAX_FIELD_CHARS", raising=False)
59
+ monkeypatch.delenv("TRACE_REDACTION_EXTRA", raising=False)
60
+
61
+
62
+ # --------------------------------------------------------------------------- #
63
+ # Each pattern is scrubbed
64
+ # --------------------------------------------------------------------------- #
65
+ @pytest.mark.parametrize(
66
+ "secret, kind",
67
+ [
68
+ ("sk-ant-api03-AbCdEf012345678901234567890", "anthropic_key"),
69
+ ("hf_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456", "hf_token"),
70
+ ("AKIAIOSFODNN7EXAMPLE", "aws_access_key"),
71
+ ("sk-proj0123456789ABCDEFXYZ0123456789", "openai_key"),
72
+ ("Bearer eyJhbGciOi.JIUzI1NiIs.InR5cCI6IkpXVCJ9", "bearer_token"),
73
+ ("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456", "github_token"),
74
+ ("jane.doe@example.com", "email"),
75
+ ],
76
+ )
77
+ def test_each_pattern_scrubbed(secret, kind):
78
+ out = redact_trace(_trace_with(f"here is a value {secret} in text"))
79
+ blob = _all_strings(out)
80
+ assert secret not in blob, f"{kind} leaked: {secret}"
81
+ assert f"«REDACTED:{kind}»" in blob
82
+
83
+
84
+ def test_credentialed_url_keeps_host_drops_creds():
85
+ out = redact_trace(_trace_with("connect to https://admin:hunter2@db.internal/x"))
86
+ blob = _all_strings(out)
87
+ assert "hunter2" not in blob
88
+ assert "admin" not in blob
89
+ assert "«REDACTED:credential_url»" in blob
90
+ assert "db.internal" in blob # host preserved for readability
91
+
92
+
93
+ def test_env_dump_whole_value_dropped():
94
+ env_blob = "AWS_SECRET_ACCESS_KEY=abc123\nANTHROPIC_API_KEY=sk-ant-xxx\nHOME=/root\nPATH=/usr/bin"
95
+ out = redact_trace(_trace_with(env_blob))
96
+ logged = out["trace_logs"][0]["content"]
97
+ assert logged == "«REDACTED:env_dump»"
98
+ assert "AWS_SECRET_ACCESS_KEY" not in _all_strings(out)
99
+
100
+
101
+ def test_high_entropy_aws_secret_scrubbed():
102
+ # 40-char high-entropy base64 (AWS secret-access-key shape).
103
+ secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYzEXAMPLEKEY"
104
+ out = redact_trace(_trace_with(f"secret={secret}"))
105
+ assert secret not in _all_strings(out)
106
+ assert "«REDACTED:high_entropy_secret»" in _all_strings(out)
107
+
108
+
109
+ def test_low_entropy_40char_not_scrubbed():
110
+ # A repetitive 40-char run (low entropy) must NOT be treated as a secret —
111
+ # guards against false positives on non-secret identifiers.
112
+ benign = "A" * 40
113
+ out = redact_trace(_trace_with(f"marker {benign} end"))
114
+ assert benign in _all_strings(out)
115
+
116
+
117
+ # --------------------------------------------------------------------------- #
118
+ # Purity + pass-through
119
+ # --------------------------------------------------------------------------- #
120
+ def test_clean_trace_unchanged():
121
+ clean = _trace_with("run a DE contrast on gene TP53 in tcga_paad")
122
+ out = redact_trace(clean)
123
+ assert out == clean
124
+
125
+
126
+ def test_input_not_mutated():
127
+ original = _trace_with("token hf_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456 here")
128
+ before = original["messages"][0]["content"]
129
+ redact_trace(original)
130
+ assert original["messages"][0]["content"] == before # deep-copied, not mutated
131
+
132
+
133
+ def test_gene_symbols_survive():
134
+ out = redact_trace(_trace_with("TP53 KRAS SMAD4 CDKN2A collectri progeny hallmark"))
135
+ for g in ("TP53", "KRAS", "SMAD4", "CDKN2A"):
136
+ assert g in _all_strings(out)
137
+
138
+
139
+ # --------------------------------------------------------------------------- #
140
+ # Config: off + size cap
141
+ # --------------------------------------------------------------------------- #
142
+ def test_redaction_off_passthrough(monkeypatch):
143
+ monkeypatch.setenv("TRACE_REDACTION", "off")
144
+ secret = "hf_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456"
145
+ trace = _trace_with(secret)
146
+ out = redact_trace(trace)
147
+ assert secret in _all_strings(out) # off = raw (local debug only)
148
+
149
+
150
+ def test_size_cap_truncates(monkeypatch):
151
+ monkeypatch.setenv("TRACE_MAX_FIELD_CHARS", "50")
152
+ out = redact_trace(_trace_with("x" * 500))
153
+ logged = out["trace_logs"][0]["content"]
154
+ assert len(logged) < 500
155
+ assert logged.endswith("«REDACTED:truncated»")
156
+
157
+
158
+ def test_extra_pattern_scrubbed(monkeypatch):
159
+ monkeypatch.setenv("TRACE_REDACTION_EXTRA", r"PROJ-\d{4}")
160
+ out = redact_trace(_trace_with("ticket PROJ-1234 filed"))
161
+ blob = _all_strings(out)
162
+ assert "PROJ-1234" not in blob
163
+ assert "«REDACTED:custom»" in blob
164
+
165
+
166
+ # --------------------------------------------------------------------------- #
167
+ # Fail-closed wrapper
168
+ # --------------------------------------------------------------------------- #
169
+ def test_redaction_failure_yields_minimal_trace(monkeypatch):
170
+ def _boom(_trace):
171
+ raise RuntimeError("kaboom")
172
+
173
+ monkeypatch.setattr(tr, "redact_trace", _boom)
174
+ out = redact_trace_safe(_trace_with("sk-ant-secretsecretsecret0123456789"), run_id="run42")
175
+ assert out["run_id"] == "run42"
176
+ assert "redaction_error" in out
177
+ assert out["messages"] == []
178
+ assert out["trace_logs"] == []
179
+ # the raw secret must not have leaked into the minimal trace
180
+ assert "sk-ant" not in _all_strings(out)
181
+
182
+
183
+ def test_minimal_trace_shape():
184
+ m = minimal_trace("abc")
185
+ assert m["run_id"] == "abc"
186
+ assert "execution_time" in m
187
+ assert m["redaction_error"] == "redaction_error"
188
+
189
+
190
+ # --------------------------------------------------------------------------- #
191
+ # End-to-end: a planted secret never reaches a sink
192
+ # --------------------------------------------------------------------------- #
193
+ def test_planted_secret_never_reaches_sink(tmp_path, monkeypatch):
194
+ """Mirror agent.run()'s persist path: redact then persist to a local sink,
195
+ and assert the on-disk trace has no secret."""
196
+ import logging_sink
197
+
198
+ monkeypatch.setenv("LOG_SINK", "local")
199
+ monkeypatch.setenv("LOG_SINK_LOCAL_DIR", str(tmp_path))
200
+
201
+ trace = _trace_with(
202
+ "key sk-ant-api03-PLANTEDSECRET0123456789 and hf_PLANTEDTOKEN0123456789ABCDEFG"
203
+ )
204
+ redacted = redact_trace_safe(trace, "run_e2e")
205
+ sink = logging_sink.get_log_sink()
206
+ location = logging_sink.persist_trace_safe(sink, "run_e2e", redacted)
207
+
208
+ assert location is not None
209
+ written = Path(location).read_text(encoding="utf-8")
210
+ assert "sk-ant-api03-PLANTEDSECRET" not in written
211
+ assert "PLANTEDTOKEN" not in written
212
+ assert "«REDACTED:anthropic_key»" in written