Skip to content

fix(sanitizer): the depth cap fails closed — nested secrets no longer escape redaction (#3312) - #3413

Merged
vybe merged 1 commit into
devfrom
feature/3312-sanitizer-depth-fail-closed
Oct 9, 2026
Merged

vybe merged 1 commit into
devfrom
feature/3312-sanitizer-depth-fail-closed

Conversation

@vybe

@vybe vybe commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #3312 — independent fix, one of ten small bug fixes in the trinity-pm chain-easy-1008 run. Base is dev. Draft until the operator merges.

What

  • src/backend/utils/credential_sanitizer.py and docker/base-image/agent_server/utils/credential_sanitizer.py: past max_depth, sanitize_dict and sanitize_list return the redaction placeholder instead of the raw subtree. The cap now fails closed.
  • sanitize_json_string (both copies) and the agent's sanitize_subprocess_line catch RecursionError from json.loads and fall back to the text sanitizer, so very deep input no longer raises.
  • Tests: the strict-xfail on test_secret_below_max_depth_is_still_redacted is removed; new cases cover 2,000-level structures and a 200k-deep JSON string on both copies; both test_max_depth_protection tests now assert the secret is gone (they only checked the type before); the property test holds at any depth.

Rulings carried (orchestrator, on the operator's behalf — plan file)

  • TD-1: past the cap the subtree becomes the placeholder string (no uncapped walker) — as recommended.
  • TD-2: the RecursionError catches ship here, not in a follow-up — as recommended; AC 2 needs them.

Review + security

/review reading pass (claude-fable-5-1, report-only): MERGEABLE, no blocking findings. The fix was proven by mutation: the dev copies leak a secret nested 14 deep and raise on 200k-deep JSON; the branch copies redact, identically in both files. The four touched function bodies are token-identical across the copies; sanitize_text and the #3335 logic are byte-identical to dev; every reader of the sanitized structure indexes at most five levels, and the placeholder first appears at level 11. /cso --diff: no findings (no Docker pass, no independent verifier — coverage gaps recorded in the review file).

Evidence sweep (claude-opus-5-5): GREEN. 22 test files reference credential_sanitizer; 1,029 passed shuffled with seed 12345. Two reds fail identically on dev and are not from this branch: test_ent615_token_free_remotes.py::…no_repository_as_traversable_and_absent, and test_cb_probe_execution_close.py (needs a live backend). git merge-tree against dev f6bedcd is clean.

Tests

Targeted: 571 + 12 + 60 passed on the engineer. Not run here: the full unit island (CI).

Before merge

  • Visible change: a tool input nested more than about six levels shows the placeholder in the transcript where the deep subtree was.
  • The agent-server copy changes, so agents pick it up with the next base-image rebuild.
  • No open PR shares files with this branch.

Handoffs

  • Agent-service callers' own json.loads (claude_code.py:372, codex_runtime.py:1282, headless_executor) do not catch RecursionError, so a very deep stream-json line is dropped, not leaked. Outside this issue's acceptance criteria.
  • Review I1: tuple, set and bytes values are never walked at any depth in either copy. Pre-existing, unreachable from the JSON entry points.

🤖 Generated with Claude Code

…3312)

sanitize_dict / sanitize_list returned the raw subtree once depth exceeded
max_depth, so a secret nested 12+ containers deep (a stream-json tool input
with ~6 levels of its own nesting) was persisted unredacted. Past the cap the
subtree is now replaced with REDACTION_PLACEHOLDER; recursion stays bounded.

sanitize_json_string (both copies) and the agent's sanitize_subprocess_line
also catch RecursionError from json.loads on pathologically deep input and
fall back to the linear sanitize_text pass instead of raising.

Tests: strict-xfail removed from test_secret_below_max_depth_is_still_redacted;
new 2000-level dict/list cases and a 200k-deep JSON string case (backend
edges + agent copy, incl. sanitize_subprocess_line); both
test_max_depth_protection now assert the secret is absent; the property test
drops its depth<=11 assume. Mutation: with the fix reverted, 6 backend tests
(both S12 params, both deep-object params, deep-JSON, backend
max_depth_protection) and 5 agent tests (max_depth_protection + the 4
TestDepthFailsClosed cases) went red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@trinity-ability trinity-ability left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: batch validated on train/20261009-0735 (#3424)

@vybe
vybe merged commit 6be3a95 into dev Oct 9, 2026
22 checks passed
vybe added a commit that referenced this pull request Oct 9, 2026
…s, not a 500 (#3324) (#3418)

Fixes #3324 — independent fix, one of ten small bug fixes in the trinity-pm `chain-easy-1008` run. Base is `dev`. **Draft until the operator merges.**

## What
`src/backend/utils/safe_yaml.py` and its agent-server mirror `docker/base-image/agent_server/safe_yaml.py`, byte-identical (Invariant #5):
- **Control characters:** the loader is constructed inside the `try`, so PyYAML's `ReaderError` (NUL, other C0 controls, DEL, U+FFFE) becomes `{kind}_yaml_invalid` instead of escaping. A failed construction cannot turn into an `UnboundLocalError`.
- **Depth:** a counter in the existing `compose_node` override refuses a document with `{kind}_too_deep` before recursing past `DEFAULT_MAX_DEPTH = 64` nested collections. Flow nesting (`[[[…`) and block nesting are both covered; block nesting also crashed under the byte cap and was not in the issue.
- **Backstop:** `RecursionError` is caught and raised as `{kind}_too_deep`, for recursion the counter cannot see (merge-key chains).

`deploy_system` needs no change: `ManifestError` subclasses `HardenedYamlError`, so it now answers a named 400.

Tests: the two `#3324` strict-xfail markers in `TestDocumentShape` are removed; new cases in `test_ent314_hardened_yaml.py` cover the 64 / 65 boundary with a scalar leaf in flow and block style, deep block nesting, merge chains, and a manifest refused with `manifest_too_deep` / `manifest_yaml_invalid`.

## Rulings carried (orchestrator, on the operator's behalf — plan file)
- T1: the `except RecursionError` backstop plus tests; no override of PyYAML's `flatten_mapping` — as recommended.
- T2: depth limit 64 — as recommended. The deepest document the loader reads today is 9–11 levels; the crash point was about 329.

## Review + security
`/review` reading pass (claude-fable-5-1, report-only): **MERGEABLE**, no critical findings. Every exit of `load_hardened_yaml` is a named error or a return, confirmed by running the branch copy; the counter is restored in a `finally`; the branch loader parses all 102 tracked YAML files with zero refusals; every caller catches the error type or broader and none branches on a closed set of codes. `/cso --diff` (diff-scoped): no finding.

Fixed after review:
- `462797e9` — I1: the counter counted the scalar leaf, so "64" meant 63 for a document ending in a scalar. It now counts nested mappings and sequences only: 64 with a scalar leaf parse, 65 are refused.
- `8dfa40a3` — I2: the comment's frame arithmetic now states the measured figure (4 frames per level after the fix above, about 265 frames at depth 64).

Not fixed, on purpose: I3, two comments in the sibling properties test that still mention the removed xfail — left out to keep the merge surface with #3413 small.

Evidence sweep after the fix (claude-opus-5-5): **GREEN**. All 15 unit files that use the loader ran shuffled (seed 12345), one file per process, every run exit 0. `cmp` of the two copies is empty after each commit. `git merge-tree` is clean against `dev` f6bedcd and against #3413's head.

## Tests
690 passed across the 15 files. Not run here: the full unit island (CI).

## Before merge
- The agent-server copy changes, so agents pick it up with the next base-image rebuild.
- #3413 edits a different class in `tests/unit/test_ec_input_hardening_edges.py`; a test-merge of the two heads is clean, in either order.

## Handoffs
- New reason codes callers may now see: `{kind}_too_deep`. The existing byte and alias limits and their codes are unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants