Skip to content

Harden IR and emitters against code injection from crafted pipelines - #4

Merged
trevhud merged 1 commit into
mainfrom
feat/ir-identifier-hardening
Jul 3, 2026
Merged

Harden IR and emitters against code injection from crafted pipelines#4
trevhud merged 1 commit into
mainfrom
feat/ir-identifier-hardening

Conversation

@trevhud

@trevhud trevhud commented Jul 3, 2026

Copy link
Copy Markdown
Owner

A crafted pipeline.yaml could inject code at emit time: node ids/signals/impl refs are interpolated verbatim into emitted source and filenames, and description prose is spliced into docstrings/JSDoc where """ or */ breaks out of the comment.

  • IR validators pin id/signal/impl to safe identifier shapes at the boundary (all adapters inherit)
  • safe_docstring_line / safe_block_comment_line escape prose at every splice site (descriptions stay unrestricted prose)
  • resolve_within contains all emitted paths inside the output dir
  • Extended past the original review patch to cover dbos.py's JSON-schema description splices and the new dbos-ts adapter

Gate: 279 fast + 8 slow tests, mypy strict, ruff, sanity — green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VJdgd3mj2jk6dKCLUjdS48

Node id / signal / impl references are interpolated verbatim into
emitted source (def names, signal handlers, import targets) and into
emitted filenames — so the IR now pins them to safe identifier shapes
at the validation boundary, which every adapter inherits. Prose fields
(descriptions) stay unrestricted but are escaped at splice sites:
safe_docstring_line for Python docstrings, safe_block_comment_line for
JSDoc, and resolve_within contains every emitted path inside the
output directory as defense-in-depth.

Originated from the correctness-review pass on PR #1 (the reviewer
applied fixes to the working tree instead of reporting); ported onto
the post-_ts_common/dbos-ts codebase and extended to the gaps it
missed: JSON-schema description splices in dbos.py and all splice/path
sites in the new dbos_ts.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJdgd3mj2jk6dKCLUjdS48
@trevhud
trevhud merged commit 8c4af09 into main Jul 3, 2026
@trevhud
trevhud deleted the feat/ir-identifier-hardening branch July 3, 2026 17:00
trevhud added a commit that referenced this pull request Jul 30, 2026
Follow-up audit to the fan_out work. The recurring failure mode in this
repo is a test that observes something *adjacent* to the behavior, so
this measures the suite instead of trusting it: apply a semantic
regression to an adapter, run the suite, and anything that still passes
is an untested behavior.

18 mutations (retry policy, timeouts, step naming, payload threading,
MCP allowlist narrowing, the ANTHROPIC_API_KEY scrub, invariants #4 and
#7); 15 caught, 3 survived.

**temporal: per-node `timeout:` was ignored and nothing noticed.**
Making `_activity_timeout` return the default unconditionally left the
whole suite green. A step declaring a 30s budget silently got 5m —
five times too long on a wedged call, with nothing in the emitted code
hinting why.

**cloudflare: same gap on HITL gates**, and worse there, because
`waitForEvent` THROWS on timeout: a gate inheriting the 7d default
instead of its declared 1h turns a fast-fail approval window into a
week-long hang, and the reverse fails a legitimately long wait.

Both new tests carry a negative half — asserting only that the declared
value appears would still pass if the adapter emitted it for every
node, which is exactly how the original gap hid.

Writing the cloudflare one immediately surfaced a real emitted-output
bug: `1h` rendered as `"1 hours"`. Cloudflare's WorkflowDuration accepts
either spelling so nothing broke, but emitted code is a reviewed
artifact and "1 hours" reads as a defect. Now singularized.

**The third survivor is an equivalent mutant, not a gap.** Swapping the
tier order in `fan_out_element_param` cannot change the answer:
fan_edge_sources ⊆ edge_sources, and a tier is accepted only when it
singles out exactly one param, so if the broader tier singles one out it
is necessarily the same one. Commented in place so the next sweep
doesn't chase it. The precedence test added alongside is still real —
verified by a mutation that IS observable (always take the
alphabetically-first param), which it catches.

**Also: the temporal e2e's fan was a no-op.** Its
`exclusion_check_sequence` mock returned `passed: []`, so
personalize_email never ran — the same vacuous-fixture bug just fixed in
the three TS suites, missed because that suite passed and I only chased
failures. Its `CAPTURED_PAYLOADS` dict is keyed by node and so keeps
only the last call, structurally unable to observe a fan at all; an
ordered CAPTURE_LOG now sits alongside it. Verified by mutating the
snapshot the e2e actually imports back to batch dispatch: it fails with
"expected 3 activity executions, got 1".

That last point is worth knowing: the temporal e2e imports the committed
`examples/.../runtimes/temporal/workflow.py` snapshot, not fresh
emission, so mutating the adapter does not reach it. The snapshot is
regenerated by the adapter tests' fixtures (which emit into the repo on
purpose, so `git diff` shows emission changes at review).

Audited two more patterns and found no open gaps: value-taking flags
asserted only by presence (`--output-last-message`,
`--append-system-prompt`) turn out to have their values checked
elsewhere, and the remaining empty-collection fixtures are inert
because nothing iterates them.

CLAUDE.md gains a "Testing discipline" section with the five defects of
this shape, the three habits that catch them, and the instruction to
verify a new test by breaking the code rather than by inspection.

1162 fast + 27 slow pass; ruff, strict mypy, sanity-check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBuSDL7Svm7iDzgmhTpDSW
trevhud added a commit that referenced this pull request Jul 30, 2026
* Dispatch fan_out nodes per element on all six runtimes

A `fan_out` node means "invoke once per element of an upstream list".
Only DBOS did that; python, temporal, cloudflare, dbos-ts and inngest
passed the whole list in a single invocation.

That was not a missing feature so much as a runtime leaking into node
semantics. The same pipeline.yaml produced two incompatible contracts
for the code a user fills in: BDR's `personalize_email` judge declares
`contact` as one contact and its Zod/Pydantic schema says so, but five
of six runtimes handed it the entire array. Fill in the stub against
DBOS, re-target Temporal, and it breaks — which invariant #1 (the IR is
runtime-agnostic) exists to prevent.

Which input is the fanned list now resolves in the language-neutral
`_common.fan_out_element_param`, not in a Python-only helper. Two
adapters disagreeing about that would make one pipeline mean two
different things, so it cannot live in a per-language module. Precedence
is unchanged (fan_out edge marker > incoming-edge source > only
node-bound param) and ambiguity is still an emit-time error, never a
guess.

Per runtime: DBOS enqueues a durable step per element (unchanged);
python uses pool.map; temporal gathers one activity execution each;
cloudflare and inngest Promise.all over per-element steps; dbos-ts
allSettled + unwrap. All preserve input order.

Two traps this surfaced, both documented in CLAUDE.md:

- Cloudflare and Inngest key a durable step by NAME, so element steps
  are index-suffixed. A constant name makes Cloudflare serve element 0's
  cached result for all N — the run succeeds, the output has the right
  length, and every entry is identical. DBOS is exempt: it identifies a
  step by execution order.
- A parkable (MCP-bound) fan_out node on Cloudflare runs its elements
  sequentially, same reason a parkable step leaves its parallel wave —
  waitForEvent inside a promise combinator is undocumented and its
  timeout throws, which would reject every sibling.

Also collapses temporal's three dispatch shapes onto one
`_execute_activity_expr`. The wave branch had previously been a separate
copy that silently omitted retry_policy; one renderer means a field
cannot be added to one shape and forgotten in the others.

Emitted code routes the fanned list through a guard (`fanOutList` /
`_fan_out_list`) naming the node and the IR reference. Found by running
it: the first live fan_out run died with "Cannot read properties of
undefined (reading 'map')" — no node, no reference, in generated code
the user never wrote. Python's "'NoneType' object is not iterable" is no
better, so both languages get it; diagnostics are part of the emitted
contract and a guard on one runtime only is its own parity gap.

Testing. tests/test_fan_out_parity.py asserts the contract across all
six, and is built to fail against batch dispatch rather than merely pass
against the new output: each check requires the element param to bind
the loop variable AND the whole-list expression to be absent. Verified
empirically — against origin/main the five broken runtimes fail every
behavioral assertion while every dbos parametrization passes.

The live e2e mocks were themselves masking this. Three TS suites had
`exclusion_check_sequence` returning `passed: []`, so BDR's fan was a
correct no-op that no assertion caught. Mock values now derive from the
IR via `_helpers.fan_out_source_keys`, and the suites assert the fan ran
once per element with each invocation receiving exactly one. Inngest's
memoization check is now fan-aware (== FAN_OUT_ELEMENTS for fan_out
nodes, == 1 otherwise), which is what catches a step-name collapse.

1159 fast + 27 slow pass; ruff, strict mypy, sanity-check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBuSDL7Svm7iDzgmhTpDSW

* Close three test gaps found by mutation-testing the adapters

Follow-up audit to the fan_out work. The recurring failure mode in this
repo is a test that observes something *adjacent* to the behavior, so
this measures the suite instead of trusting it: apply a semantic
regression to an adapter, run the suite, and anything that still passes
is an untested behavior.

18 mutations (retry policy, timeouts, step naming, payload threading,
MCP allowlist narrowing, the ANTHROPIC_API_KEY scrub, invariants #4 and
#7); 15 caught, 3 survived.

**temporal: per-node `timeout:` was ignored and nothing noticed.**
Making `_activity_timeout` return the default unconditionally left the
whole suite green. A step declaring a 30s budget silently got 5m —
five times too long on a wedged call, with nothing in the emitted code
hinting why.

**cloudflare: same gap on HITL gates**, and worse there, because
`waitForEvent` THROWS on timeout: a gate inheriting the 7d default
instead of its declared 1h turns a fast-fail approval window into a
week-long hang, and the reverse fails a legitimately long wait.

Both new tests carry a negative half — asserting only that the declared
value appears would still pass if the adapter emitted it for every
node, which is exactly how the original gap hid.

Writing the cloudflare one immediately surfaced a real emitted-output
bug: `1h` rendered as `"1 hours"`. Cloudflare's WorkflowDuration accepts
either spelling so nothing broke, but emitted code is a reviewed
artifact and "1 hours" reads as a defect. Now singularized.

**The third survivor is an equivalent mutant, not a gap.** Swapping the
tier order in `fan_out_element_param` cannot change the answer:
fan_edge_sources ⊆ edge_sources, and a tier is accepted only when it
singles out exactly one param, so if the broader tier singles one out it
is necessarily the same one. Commented in place so the next sweep
doesn't chase it. The precedence test added alongside is still real —
verified by a mutation that IS observable (always take the
alphabetically-first param), which it catches.

**Also: the temporal e2e's fan was a no-op.** Its
`exclusion_check_sequence` mock returned `passed: []`, so
personalize_email never ran — the same vacuous-fixture bug just fixed in
the three TS suites, missed because that suite passed and I only chased
failures. Its `CAPTURED_PAYLOADS` dict is keyed by node and so keeps
only the last call, structurally unable to observe a fan at all; an
ordered CAPTURE_LOG now sits alongside it. Verified by mutating the
snapshot the e2e actually imports back to batch dispatch: it fails with
"expected 3 activity executions, got 1".

That last point is worth knowing: the temporal e2e imports the committed
`examples/.../runtimes/temporal/workflow.py` snapshot, not fresh
emission, so mutating the adapter does not reach it. The snapshot is
regenerated by the adapter tests' fixtures (which emit into the repo on
purpose, so `git diff` shows emission changes at review).

Audited two more patterns and found no open gaps: value-taking flags
asserted only by presence (`--output-last-message`,
`--append-system-prompt`) turn out to have their values checked
elsewhere, and the remaining empty-collection fixtures are inert
because nothing iterates them.

CLAUDE.md gains a "Testing discipline" section with the five defects of
this shape, the three habits that catch them, and the instruction to
verify a new test by breaking the code rather than by inspection.

1162 fast + 27 slow pass; ruff, strict mypy, sanity-check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBuSDL7Svm7iDzgmhTpDSW

---------

Co-authored-by: Claude Opus 5 (1M context) <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.

1 participant