fix(graph): auto-merge only schema-declared keys from llm output

state_updates::apply merged EVERY top-level key of an llm/agent node's JSON
output into graph state whenever the node had an output_schema, and
structured::extract never validated the output against that schema. Any
prompt text able to steer the model — including plan/diff text pasted from a
repo under review — could therefore plant an undeclared key (e.g.
verification_commands) into state and overwrite a caller-declared value
before a downstream script consumed it.

- apply() now takes the node's output_schema and merges only keys present in
  schema.properties; undeclared keys are dropped. No schema, or a schema
  without an object properties map, merges nothing.
- Nodes without output_schema are unchanged (None == the old false path);
  explicit state_updates are unchanged and still see the full output via
  {{output.<key>}}, which remains the author-controlled way to lift a key.
- Callers: llm.rs, agent.rs (x2), rag.rs (None).
- Sweep test bundled_graph_output_schemas_declare_every_key_relied_on: for
  every bundled graph, every consumed state key has a declared writer
  (initial_state, variable, state_updates target, map key, script-emitted key,
  or an output_schema property), and no schema node's field list names a key
  its schema omits — so this restriction cannot silently break a shipped
  graph. It passes with no graph.yaml changes.
- graph.example.yaml documents the declared-keys-only merge; the residual
  disclosed in 665fee9's README/docstring notes is now recorded as closed.
This commit is contained in:
Dark-Alex-17 authored and Dark-Alex-17 committed 2026-09-14 10:50:34 -06:00
1 parent 300c7aeff0
commit 27101909e0
9 files changed
+370 -43

No files matched your search

+3 -4
View File
@@ -139,10 +139,9 @@ as a structured `inputs:` passthrough, not as prompt text.
an invalid declaration: `run_checks` executes **nothing** and records a
`PIPELINE-FAULT: verification_commands declaration invalid — …` marker, which forces DIVERGES —
fail-closed, not degraded to the soft ENVIRONMENT marker.
- Known residual: the engine merges every top-level key of `parse`'s JSON output into state, not
only the keys in its `output_schema`, so an injected instruction that makes the parse LLM emit an
extra `verification_commands` key is not yet blocked at the engine level. Closing that requires
restricting the llm-node auto-merge to `output_schema.properties` (tracked as a follow-up).
- The engine merges only the keys declared under `parse`'s `output_schema.properties` into state,
so an injected instruction that makes the parse LLM emit an extra `verification_commands` key
cannot reach state: the undeclared key is dropped and the declared variable stands.
### Tools
@@ -18,10 +18,10 @@ shell=True. Variables land in state as strings, so a JSON-encoded list is
accepted alongside a real list. A declaration that is not a JSON array of
strings is a caller contract violation, not a runner hiccup: it executes
NOTHING and records a PIPELINE-FAULT (fail-closed into DIVERGES) instead of
degrading to the soft ENVIRONMENT marker. Known residual: the engine merges
every top-level key of an llm node's JSON output into state, so a parse LLM
coaxed into emitting an extra `verification_commands` key could still
overwrite the declared value before this script runs.
degrading to the soft ENVIRONMENT marker. The engine merges only the keys an
llm node's `output_schema.properties` declares, so a parse LLM coaxed into
emitting an extra `verification_commands` key cannot overwrite the declared
value: the undeclared key is dropped before it reaches state.
Budget: commands run sequentially, so TOTAL runtime is bounded by a deadline
(TOTAL_DEADLINE_SECS = 3300s, the run_checks node's 3600s timeout minus
+4 -3
View File
@@ -133,9 +133,10 @@ final report verbatim.
and never extracted by `parse`. The adversary's `run_checks` stage executes
those commands with a shell, which makes the declaration a trust boundary:
the prompt also carries plan text pasted from the repo under review, so
commands must never be inferred from it (residual: the engine still merges
every top-level key of `parse`'s output into state, so an extra
`verification_commands` key emitted by the LLM is not yet blocked).
commands must never be inferred from it, and the engine merges only the keys
`parse`'s `output_schema.properties` declares, so an extra
`verification_commands` key emitted by the LLM is dropped before it reaches
state.
Undeclared stays undeclared
(`'[]'` ⇒ "none declared"); a declaration that is not a JSON array of
strings executes nothing and records a `PIPELINE-FAULT`, which the