Skip to content

fix(safe-yaml): deep nesting and control characters are named refusals, not a 500 (#3324) - #3418

Merged
vybe merged 3 commits into
devfrom
feature/3324-safe-yaml-named-errors
Oct 9, 2026
Merged

vybe merged 3 commits into
devfrom
feature/3324-safe-yaml-named-errors

Conversation

@vybe

@vybe vybe commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

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

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

Trinity Agent (trinity) and others added 3 commits October 8, 2026 19:20
)

load_hardened_yaml let two failure classes escape its HardenedYamlError
contract, so deploy_system (which catches only ManifestError/ValueError)
answered an unnamed 500:

- Control characters: PyYAML's Reader checks printability in __init__,
  and `loader(text)` sat above the try that maps YAMLError ->
  `{kind}_yaml_invalid`. Construction now sits inside the try, and the
  finally only disposes an instance that was actually built.
- Deep nesting: no depth bound, so ~330 flow levels (6 KB) or 500 block
  levels (~125 KB), both under the byte cap, exhausted the stack. A
  compose_node depth gate (DEFAULT_MAX_DEPTH = 64, per-call `max_depth`)
  now refuses `{kind}_too_deep` before recursing. An `except
  RecursionError` backstop maps recursion the gate cannot see (a `<<`
  merge chain walked by flatten_mapping) to the same code.

The agent-server copy is byte-identical (Invariant #5).

Tests: removed the two #3324 strict-xfail markers in TestDocumentShape
(the deep test now pins `k_too_deep`); new ent314 tests cover the 64/65
boundary, block form, per-call max_depth, the manifest codes through
parse_manifest, and the merge-chain budget + backstop. Before the fix
they failed on RecursionError / ReaderError (and on the missing API),
and they pass after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… leaf (#3324)

Review I1: the compose_node counter incremented for every node, so
DEFAULT_MAX_DEPTH = 64 admitted only 63 collection levels for any
document ending in a scalar. Count only mapping/sequence starts; add a
leaf-bearing 64/65 boundary test for flow and block style. Both copies
byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the measured figure (#3324)

Review I2: measured on PyYAML 6.0.3 with the compose_node override,
4 frames per level, ~265 at depth 64, ~725 of 1000 left for the caller
(a 64-level doc parses with the caller 725 frames deep). Comment only;
both copies byte-identical.

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 11a2f79 into dev Oct 9, 2026
27 of 28 checks passed
webmixgamer added a commit that referenced this pull request Oct 9, 2026
Brings the branch up to ed59049 (12 commits, incl. #3406 public-link
sessions, #3422 `user-invocable:` in the library, #3418 safe_yaml).

One conflict, in docs/user-docs/automation/skills-and-playbooks.md: this
PR's prose kept, with the `user-invocable:` key spelling (the hyphen is
the only spelling the agent server reads). The Skills flow's Run table
now names the listing field and the frontmatter key apart.

PublicChat.vue merged cleanly with #3406 (this PR's "/ for skills"
placeholder kept).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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