From 6d3e1c82eb380736b6dc6c1ad42d29e18cf03383 Mon Sep 17 00:00:00 2001 From: "Trinity Agent (trinity)" Date: Thu, 8 Oct 2026 19:20:44 -0400 Subject: [PATCH 1/3] fix(safe-yaml): named refusals for deep nesting and control chars (#3324) 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 --- docker/base-image/agent_server/safe_yaml.py | 48 ++++++++- src/backend/utils/safe_yaml.py | 48 ++++++++- tests/unit/test_ec_input_hardening_edges.py | 10 +- tests/unit/test_ent314_hardened_yaml.py | 102 ++++++++++++++++++++ 4 files changed, 190 insertions(+), 18 deletions(-) diff --git a/docker/base-image/agent_server/safe_yaml.py b/docker/base-image/agent_server/safe_yaml.py index d712557a35..61d976f769 100644 --- a/docker/base-image/agent_server/safe_yaml.py +++ b/docker/base-image/agent_server/safe_yaml.py @@ -82,6 +82,13 @@ # alias were written out — exactly the quantity that explodes. DEFAULT_MAX_EXPANDED_NODES = 100_000 +# Bounds NESTING DEPTH (#3324). PyYAML's composer recurses ~3 Python frames per +# level, so 6 KB of `[` (~330 levels) exhausted the default 1000-frame stack +# well under the byte cap and escaped as a bare RecursionError. The deepest +# document Trinity actually loads is ~9 levels (`dashboard.yaml`); 64 is ~6x +# that while leaving ~800 frames for the caller's own stack. +DEFAULT_MAX_DEPTH = 64 + class AliasPolicy(enum.Enum): """How a document is allowed to use YAML anchors/aliases.""" @@ -111,6 +118,7 @@ def _make_loader( kind: str, alias_policy: AliasPolicy, max_expanded_nodes: int, + max_depth: int = DEFAULT_MAX_DEPTH, error_cls: type, ): """Build a one-shot SafeLoader subclass carrying this call's policy. @@ -124,6 +132,7 @@ def __init__(self, stream): super().__init__(stream) self._expanded_nodes = 0 self._anchor_cost: dict = {} + self._depth = 0 def fetch_alias(self): # Second, earlier gate for REJECT: refuse at the SCANNER, before an @@ -138,6 +147,20 @@ def fetch_alias(self): return super().fetch_alias() def compose_node(self, parent, index): + # Checked BEFORE recursing, so the refusal is named rather than a + # stack exhaustion inside the composer. + self._depth += 1 + try: + if self._depth > max_depth: + raise error_cls( + f"{kind}_too_deep", + f"{kind} nests deeper than {max_depth} levels.", + ) + return self._compose_node_budgeted(parent, index) + finally: + self._depth -= 1 + + def _compose_node_budgeted(self, parent, index): if self.check_event(yaml.events.AliasEvent): if alias_policy is AliasPolicy.REJECT: raise error_cls( @@ -204,6 +227,7 @@ def load_hardened_yaml( alias_policy: AliasPolicy, max_bytes: int = DEFAULT_MAX_BYTES, max_expanded_nodes: int = DEFAULT_MAX_EXPANDED_NODES, + max_depth: int = DEFAULT_MAX_DEPTH, error_cls: Optional[type] = None, ) -> Any: """Parse author-controlled YAML with size, alias and duplicate-key guards. @@ -214,14 +238,14 @@ def load_hardened_yaml( ``manifest_duplicate_key``). Callers that already publish codes MUST pass the prefix they published. alias_policy: see `AliasPolicy` — required, never defaulted. - max_bytes / max_expanded_nodes: the two budgets. + max_bytes / max_expanded_nodes / max_depth: the three budgets. error_cls: raise this instead of `HardenedYamlError` (a subclass, so an existing consumer's `except` keeps working during migration). Raises: `error_cls` (default `HardenedYamlError`) with a `code` of - ``{kind}_too_large`` / ``_alias_not_permitted`` / ``_alias_budget_exceeded`` - / ``_duplicate_key`` / ``_yaml_invalid``. Rejects rather than truncates, + ``{kind}_too_large`` / ``_too_deep`` / ``_alias_not_permitted`` / + ``_alias_budget_exceeded`` / ``_duplicate_key`` / ``_yaml_invalid``. Rejects rather than truncates, so a hostile document fails loudly instead of being silently reinterpreted. """ err = error_cls or HardenedYamlError @@ -236,6 +260,7 @@ def load_hardened_yaml( kind=kind, alias_policy=alias_policy, max_expanded_nodes=max_expanded_nodes, + max_depth=max_depth, error_cls=err, ) # Drive the loader directly instead of `yaml.load(text, Loader=loader)`. @@ -248,15 +273,28 @@ def load_hardened_yaml( # dismiss a critical alert on a security PR, take the analyzable path: no # `yaml.load` call exists here, and the SafeLoader ancestry is still pinned # by test (test_the_hardened_loader_is_a_safeloader_not_a_full_loader). - instance = loader(text) + # + # Construction sits INSIDE the try (#3324): PyYAML's Reader checks + # printability eagerly in `__init__`, so a NUL raises `ReaderError` (a + # `YAMLError`) from here, and must map to `_yaml_invalid` like any other. + instance = None try: + instance = loader(text) return instance.get_single_data() except err: raise except yaml.YAMLError as e: raise err(f"{kind}_yaml_invalid", f"YAML parse error: {e}") + except RecursionError: + # Backstop for recursion the compose-depth gate cannot see — e.g. a + # `<<` merge chain walked by `flatten_mapping` at compose depth 2. + raise err( + f"{kind}_too_deep", + f"{kind} nests or merges too deeply to parse.", + ) finally: - instance.dispose() + if instance is not None: + instance.dispose() # --- Per-document policies ------------------------------------------------- diff --git a/src/backend/utils/safe_yaml.py b/src/backend/utils/safe_yaml.py index d712557a35..61d976f769 100644 --- a/src/backend/utils/safe_yaml.py +++ b/src/backend/utils/safe_yaml.py @@ -82,6 +82,13 @@ # alias were written out — exactly the quantity that explodes. DEFAULT_MAX_EXPANDED_NODES = 100_000 +# Bounds NESTING DEPTH (#3324). PyYAML's composer recurses ~3 Python frames per +# level, so 6 KB of `[` (~330 levels) exhausted the default 1000-frame stack +# well under the byte cap and escaped as a bare RecursionError. The deepest +# document Trinity actually loads is ~9 levels (`dashboard.yaml`); 64 is ~6x +# that while leaving ~800 frames for the caller's own stack. +DEFAULT_MAX_DEPTH = 64 + class AliasPolicy(enum.Enum): """How a document is allowed to use YAML anchors/aliases.""" @@ -111,6 +118,7 @@ def _make_loader( kind: str, alias_policy: AliasPolicy, max_expanded_nodes: int, + max_depth: int = DEFAULT_MAX_DEPTH, error_cls: type, ): """Build a one-shot SafeLoader subclass carrying this call's policy. @@ -124,6 +132,7 @@ def __init__(self, stream): super().__init__(stream) self._expanded_nodes = 0 self._anchor_cost: dict = {} + self._depth = 0 def fetch_alias(self): # Second, earlier gate for REJECT: refuse at the SCANNER, before an @@ -138,6 +147,20 @@ def fetch_alias(self): return super().fetch_alias() def compose_node(self, parent, index): + # Checked BEFORE recursing, so the refusal is named rather than a + # stack exhaustion inside the composer. + self._depth += 1 + try: + if self._depth > max_depth: + raise error_cls( + f"{kind}_too_deep", + f"{kind} nests deeper than {max_depth} levels.", + ) + return self._compose_node_budgeted(parent, index) + finally: + self._depth -= 1 + + def _compose_node_budgeted(self, parent, index): if self.check_event(yaml.events.AliasEvent): if alias_policy is AliasPolicy.REJECT: raise error_cls( @@ -204,6 +227,7 @@ def load_hardened_yaml( alias_policy: AliasPolicy, max_bytes: int = DEFAULT_MAX_BYTES, max_expanded_nodes: int = DEFAULT_MAX_EXPANDED_NODES, + max_depth: int = DEFAULT_MAX_DEPTH, error_cls: Optional[type] = None, ) -> Any: """Parse author-controlled YAML with size, alias and duplicate-key guards. @@ -214,14 +238,14 @@ def load_hardened_yaml( ``manifest_duplicate_key``). Callers that already publish codes MUST pass the prefix they published. alias_policy: see `AliasPolicy` — required, never defaulted. - max_bytes / max_expanded_nodes: the two budgets. + max_bytes / max_expanded_nodes / max_depth: the three budgets. error_cls: raise this instead of `HardenedYamlError` (a subclass, so an existing consumer's `except` keeps working during migration). Raises: `error_cls` (default `HardenedYamlError`) with a `code` of - ``{kind}_too_large`` / ``_alias_not_permitted`` / ``_alias_budget_exceeded`` - / ``_duplicate_key`` / ``_yaml_invalid``. Rejects rather than truncates, + ``{kind}_too_large`` / ``_too_deep`` / ``_alias_not_permitted`` / + ``_alias_budget_exceeded`` / ``_duplicate_key`` / ``_yaml_invalid``. Rejects rather than truncates, so a hostile document fails loudly instead of being silently reinterpreted. """ err = error_cls or HardenedYamlError @@ -236,6 +260,7 @@ def load_hardened_yaml( kind=kind, alias_policy=alias_policy, max_expanded_nodes=max_expanded_nodes, + max_depth=max_depth, error_cls=err, ) # Drive the loader directly instead of `yaml.load(text, Loader=loader)`. @@ -248,15 +273,28 @@ def load_hardened_yaml( # dismiss a critical alert on a security PR, take the analyzable path: no # `yaml.load` call exists here, and the SafeLoader ancestry is still pinned # by test (test_the_hardened_loader_is_a_safeloader_not_a_full_loader). - instance = loader(text) + # + # Construction sits INSIDE the try (#3324): PyYAML's Reader checks + # printability eagerly in `__init__`, so a NUL raises `ReaderError` (a + # `YAMLError`) from here, and must map to `_yaml_invalid` like any other. + instance = None try: + instance = loader(text) return instance.get_single_data() except err: raise except yaml.YAMLError as e: raise err(f"{kind}_yaml_invalid", f"YAML parse error: {e}") + except RecursionError: + # Backstop for recursion the compose-depth gate cannot see — e.g. a + # `<<` merge chain walked by `flatten_mapping` at compose depth 2. + raise err( + f"{kind}_too_deep", + f"{kind} nests or merges too deeply to parse.", + ) finally: - instance.dispose() + if instance is not None: + instance.dispose() # --- Per-document policies ------------------------------------------------- diff --git a/tests/unit/test_ec_input_hardening_edges.py b/tests/unit/test_ec_input_hardening_edges.py index cec80371a9..3a9730d6ea 100644 --- a/tests/unit/test_ec_input_hardening_edges.py +++ b/tests/unit/test_ec_input_hardening_edges.py @@ -175,10 +175,6 @@ def test_a_leading_bom_is_stripped(self): first key (`'\\ufeffname'` would make `data["name"]` a KeyError).""" assert _load_yaml("\ufeffname: x\n") == {"name": "x"} - @pytest.mark.xfail(strict=True, reason=( - "BUG: NUL / C0 control chars raise yaml.ReaderError from the loader's " - "constructor, OUTSIDE the try that maps YAMLError to `_yaml_invalid` — " - "#3324")) @pytest.mark.parametrize("ch", ["\x00", "\x07", "\x1b", "\x7f", "\ufffe"], ids=["Y8-nul", "Y9-bel", "Y9-esc", "Y9-del", "Y9-fffe"]) def test_control_characters_are_a_named_parse_error(self, ch): @@ -211,9 +207,6 @@ def test_printable_whitespace_variants_still_parse(self, ch): else _load_yaml('a: 1\nb: "x\ty"\n')) assert isinstance(out, dict) and out["a"] == 1 - @pytest.mark.xfail(strict=True, reason=( - "BUG: deep flow nesting raises RecursionError (not HardenedYamlError) " - "well under the byte cap — #3324")) def test_deep_nesting_is_a_named_refusal(self): """Y10. 6 KB of `[` — 2.3% of the 256 KiB cap — exhausts the Python stack inside PyYAML's recursive composer. The loader bounds bytes and @@ -226,8 +219,9 @@ def test_deep_nesting_is_a_named_refusal(self): 1483, ent#128) — i.e. callers are compensating for the loader.""" doc = "[" * 3000 + "]" * 3000 assert len(doc.encode()) < SY.DEFAULT_MAX_BYTES - with pytest.raises(HYE): + with pytest.raises(HYE) as exc: _load_yaml(doc) + assert exc.value.code == "k_too_deep" def test_moderate_nesting_parses(self): """Y11 — the depth gap must not be 'fixed' by a cap that breaks real diff --git a/tests/unit/test_ent314_hardened_yaml.py b/tests/unit/test_ent314_hardened_yaml.py index ee440dedd5..f71c7cb2d4 100644 --- a/tests/unit/test_ent314_hardened_yaml.py +++ b/tests/unit/test_ent314_hardened_yaml.py @@ -409,3 +409,105 @@ def test_the_named_consumers_are_actually_on_the_shared_loader(): # Either the raw loader or a policy wrapper from the same module — the # property is "parses through utils/safe_yaml", not one symbol name. assert "utils.safe_yaml" in text, f"{rel} is not on the shared loader" + + +# --------------------------------------------------------------------------- +# #3324 — deep nesting and control characters are NAMED refusals +# --------------------------------------------------------------------------- + +def _flow_nest(depth: int) -> str: + """`depth` nested flow sequences, innermost empty: `[[...]]`.""" + return "[" * depth + "]" * depth + + +def _block_nest(depth: int) -> str: + """`depth` nested block mappings at a 1-space indent, ending in a scalar.""" + lines = [" " * i + "a:" for i in range(depth - 1)] + lines.append(" " * (depth - 1) + "a: 1") + return "\n".join(lines) + "\n" + + +def _merge_chain(links: int) -> str: + """A `<<` merge chain the ROOT merges last, so `flatten_mapping` walks the + whole chain recursively before anything is flattened — at a compose depth + of 2. Distinct keys, so the duplicate-key guard stays out of the way.""" + lines = ["a0: &a0 {k0: 0}"] + for i in range(1, links): + lines.append(f"a{i}: &a{i} {{<<: *a{i - 1}, k{i}: {i}}}") + lines.append(f"<<: *a{links - 1}") + return "\n".join(lines) + "\n" + + +def test_depth_at_the_limit_parses_and_one_more_is_refused(): + from utils.safe_yaml import DEFAULT_MAX_DEPTH, load_template_yaml + + assert DEFAULT_MAX_DEPTH == 64 + out = load_template_yaml(_flow_nest(DEFAULT_MAX_DEPTH)) + for _ in range(DEFAULT_MAX_DEPTH - 1): + assert isinstance(out, list) and len(out) == 1 + out = out[0] + assert out == [] + + with pytest.raises(HardenedYamlError) as exc: + load_template_yaml(_flow_nest(DEFAULT_MAX_DEPTH + 1)) + assert exc.value.code == "template_too_deep" + + +def test_deep_block_nesting_under_the_byte_cap_is_a_named_refusal(): + """Block form, not only flow: 500 levels at a 1-space indent is ~125 KB — + under the 256 KiB cap — and exhausted the stack before #3324.""" + doc = _block_nest(500) + assert len(doc.encode()) < 256 * 1024 + with pytest.raises(HardenedYamlError) as exc: + load_hardened_yaml(doc, kind="template", alias_policy=AliasPolicy.BUDGET) + assert exc.value.code == "template_too_deep" + + +def test_max_depth_is_a_per_call_bound(): + doc = _flow_nest(10) + load_hardened_yaml(doc, kind="k", alias_policy=AliasPolicy.REJECT, max_depth=10) + with pytest.raises(HardenedYamlError) as exc: + load_hardened_yaml(doc, kind="k", alias_policy=AliasPolicy.REJECT, max_depth=9) + assert exc.value.code == "k_too_deep" + + +def test_deep_manifest_is_a_named_manifest_error(): + """`deploy_system` catches only ManifestError/ValueError around + `parse_manifest`; anything else was an unnamed 500 (#3324).""" + from services.system_service import ManifestError, parse_manifest + + with pytest.raises(ManifestError) as exc: + parse_manifest("name: s\nagents: " + _flow_nest(3000) + "\n") + assert exc.value.code == "manifest_too_deep" + + +def test_control_character_in_a_manifest_is_a_named_manifest_error(): + from services.system_service import ManifestError, parse_manifest + + with pytest.raises(ManifestError) as exc: + parse_manifest("name: s\x00\nagents: {}\n") + assert exc.value.code == "manifest_yaml_invalid" + + +def test_merge_chain_is_refused_by_the_default_budget_before_recursion(): + """`flatten_mapping` recurses down a `<<` chain outside compose depth. At + every caller's budget today the expansion gate refuses it first.""" + with pytest.raises(HardenedYamlError) as exc: + load_hardened_yaml( + _merge_chain(1500), kind="template", alias_policy=AliasPolicy.BUDGET + ) + assert exc.value.code == "template_alias_budget_exceeded" + + +def test_recursion_the_depth_gate_cannot_see_is_still_a_named_refusal(): + """The backstop: lift the budget so the merge chain reaches + `flatten_mapping`'s recursion; it must surface as `_too_deep`, never as a + bare RecursionError.""" + with pytest.raises(HardenedYamlError) as exc: + load_hardened_yaml( + _merge_chain(1500), + kind="template", + alias_policy=AliasPolicy.BUDGET, + max_expanded_nodes=10**9, + ) + assert exc.value.code == "template_too_deep" From 462797e94cbae43bdb5a97afa92812b5a835272a Mon Sep 17 00:00:00 2001 From: "Trinity Agent (trinity)" Date: Thu, 8 Oct 2026 20:05:31 -0400 Subject: [PATCH 2/3] fix(safe-yaml): depth limit counts nested collections, not the scalar 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 --- docker/base-image/agent_server/safe_yaml.py | 12 ++++++++++-- src/backend/utils/safe_yaml.py | 12 ++++++++++-- tests/unit/test_ent314_hardened_yaml.py | 20 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/docker/base-image/agent_server/safe_yaml.py b/docker/base-image/agent_server/safe_yaml.py index 61d976f769..96c68917bb 100644 --- a/docker/base-image/agent_server/safe_yaml.py +++ b/docker/base-image/agent_server/safe_yaml.py @@ -86,7 +86,8 @@ # level, so 6 KB of `[` (~330 levels) exhausted the default 1000-frame stack # well under the byte cap and escaped as a bare RecursionError. The deepest # document Trinity actually loads is ~9 levels (`dashboard.yaml`); 64 is ~6x -# that while leaving ~800 frames for the caller's own stack. +# that while leaving ~800 frames for the caller's own stack. A level is one +# nested mapping or sequence; the scalar leaf inside the deepest is not one. DEFAULT_MAX_DEPTH = 64 @@ -148,7 +149,14 @@ def fetch_alias(self): def compose_node(self, parent, index): # Checked BEFORE recursing, so the refusal is named rather than a - # stack exhaustion inside the composer. + # stack exhaustion inside the composer. Only collection starts + # count: a scalar or alias leaf does not nest, so `max_depth` + # means that many nested mappings/sequences around any leaf. + nests = self.check_event( + yaml.events.SequenceStartEvent, yaml.events.MappingStartEvent + ) + if not nests: + return self._compose_node_budgeted(parent, index) self._depth += 1 try: if self._depth > max_depth: diff --git a/src/backend/utils/safe_yaml.py b/src/backend/utils/safe_yaml.py index 61d976f769..96c68917bb 100644 --- a/src/backend/utils/safe_yaml.py +++ b/src/backend/utils/safe_yaml.py @@ -86,7 +86,8 @@ # level, so 6 KB of `[` (~330 levels) exhausted the default 1000-frame stack # well under the byte cap and escaped as a bare RecursionError. The deepest # document Trinity actually loads is ~9 levels (`dashboard.yaml`); 64 is ~6x -# that while leaving ~800 frames for the caller's own stack. +# that while leaving ~800 frames for the caller's own stack. A level is one +# nested mapping or sequence; the scalar leaf inside the deepest is not one. DEFAULT_MAX_DEPTH = 64 @@ -148,7 +149,14 @@ def fetch_alias(self): def compose_node(self, parent, index): # Checked BEFORE recursing, so the refusal is named rather than a - # stack exhaustion inside the composer. + # stack exhaustion inside the composer. Only collection starts + # count: a scalar or alias leaf does not nest, so `max_depth` + # means that many nested mappings/sequences around any leaf. + nests = self.check_event( + yaml.events.SequenceStartEvent, yaml.events.MappingStartEvent + ) + if not nests: + return self._compose_node_budgeted(parent, index) self._depth += 1 try: if self._depth > max_depth: diff --git a/tests/unit/test_ent314_hardened_yaml.py b/tests/unit/test_ent314_hardened_yaml.py index f71c7cb2d4..efabb2b520 100644 --- a/tests/unit/test_ent314_hardened_yaml.py +++ b/tests/unit/test_ent314_hardened_yaml.py @@ -453,6 +453,26 @@ def test_depth_at_the_limit_parses_and_one_more_is_refused(): assert exc.value.code == "template_too_deep" +@pytest.mark.parametrize("nest", [ + pytest.param(lambda d: "[" * d + "1" + "]" * d, id="flow"), + pytest.param(_block_nest, id="block"), +]) +def test_depth_counts_collections_not_the_scalar_leaf(nest): + """The bound is on nested COLLECTIONS: 64 of them around a scalar leaf + parse, 65 are refused. The leaf-less `_flow_nest` boundary above cannot + tell this apart from a counter that also counts the leaf (#3324 review I1).""" + from utils.safe_yaml import DEFAULT_MAX_DEPTH + + load_hardened_yaml( + nest(DEFAULT_MAX_DEPTH), kind="template", alias_policy=AliasPolicy.BUDGET + ) + with pytest.raises(HardenedYamlError) as exc: + load_hardened_yaml( + nest(DEFAULT_MAX_DEPTH + 1), kind="template", alias_policy=AliasPolicy.BUDGET + ) + assert exc.value.code == "template_too_deep" + + def test_deep_block_nesting_under_the_byte_cap_is_a_named_refusal(): """Block form, not only flow: 500 levels at a 1-space indent is ~125 KB — under the 256 KiB cap — and exhausted the stack before #3324.""" From 8dfa40a37a2790a244265c5d58203f712288aa1d Mon Sep 17 00:00:00 2001 From: "Trinity Agent (trinity)" Date: Thu, 8 Oct 2026 20:06:01 -0400 Subject: [PATCH 3/3] fix(safe-yaml): correct the depth-limit comment's frame arithmetic to 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 --- docker/base-image/agent_server/safe_yaml.py | 12 +++++++----- src/backend/utils/safe_yaml.py | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docker/base-image/agent_server/safe_yaml.py b/docker/base-image/agent_server/safe_yaml.py index 96c68917bb..b9aee2c859 100644 --- a/docker/base-image/agent_server/safe_yaml.py +++ b/docker/base-image/agent_server/safe_yaml.py @@ -82,11 +82,13 @@ # alias were written out — exactly the quantity that explodes. DEFAULT_MAX_EXPANDED_NODES = 100_000 -# Bounds NESTING DEPTH (#3324). PyYAML's composer recurses ~3 Python frames per -# level, so 6 KB of `[` (~330 levels) exhausted the default 1000-frame stack -# well under the byte cap and escaped as a bare RecursionError. The deepest -# document Trinity actually loads is ~9 levels (`dashboard.yaml`); 64 is ~6x -# that while leaving ~800 frames for the caller's own stack. A level is one +# Bounds NESTING DEPTH (#3324). PyYAML's composer recurses per level, so 6 KB +# of `[` (~330 levels) exhausted the default 1000-frame stack well under the +# byte cap and escaped as a bare RecursionError. With the `compose_node` +# override below it is 4 Python frames per level (measured, PyYAML 6.0.3): +# ~265 frames at 64, leaving ~725 of the default 1000 for the caller's own +# stack. The deepest document Trinity actually loads is ~9 levels +# (`dashboard.yaml`); 64 is ~6x that. A level is one # nested mapping or sequence; the scalar leaf inside the deepest is not one. DEFAULT_MAX_DEPTH = 64 diff --git a/src/backend/utils/safe_yaml.py b/src/backend/utils/safe_yaml.py index 96c68917bb..b9aee2c859 100644 --- a/src/backend/utils/safe_yaml.py +++ b/src/backend/utils/safe_yaml.py @@ -82,11 +82,13 @@ # alias were written out — exactly the quantity that explodes. DEFAULT_MAX_EXPANDED_NODES = 100_000 -# Bounds NESTING DEPTH (#3324). PyYAML's composer recurses ~3 Python frames per -# level, so 6 KB of `[` (~330 levels) exhausted the default 1000-frame stack -# well under the byte cap and escaped as a bare RecursionError. The deepest -# document Trinity actually loads is ~9 levels (`dashboard.yaml`); 64 is ~6x -# that while leaving ~800 frames for the caller's own stack. A level is one +# Bounds NESTING DEPTH (#3324). PyYAML's composer recurses per level, so 6 KB +# of `[` (~330 levels) exhausted the default 1000-frame stack well under the +# byte cap and escaped as a bare RecursionError. With the `compose_node` +# override below it is 4 Python frames per level (measured, PyYAML 6.0.3): +# ~265 frames at 64, leaving ~725 of the default 1000 for the caller's own +# stack. The deepest document Trinity actually loads is ~9 levels +# (`dashboard.yaml`); 64 is ~6x that. A level is one # nested mapping or sequence; the scalar leaf inside the deepest is not one. DEFAULT_MAX_DEPTH = 64