feat(logging): ADR-0013 audit-trace redaction (secret/credential/PII scrubbing)
Browse filesDeterministic, 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 +125 -0
- src/agent.py +7 -1
- src/core/trace_redaction.py +246 -0
- src/managers/workflow/workflow_engine.py +8 -0
- tests/test_trace_redaction.py +212 -0
|
@@ -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.
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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__}")
|
|
@@ -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 |
|
|
@@ -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
|