Skip to content

fix(automation): one chokepoint for the resume signal — output reopened the hole inputs had just closed (#3879) - #3880

Merged
os-zhuang merged 1 commit into
mainfrom
claude/run-resume-auth-gate-w67lvk
Jul 28, 2026
Merged

fix(automation): one chokepoint for the resume signal — output reopened the hole inputs had just closed (#3879)#3880
os-zhuang merged 1 commit into
mainfrom
claude/run-resume-auth-gate-w67lvk

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #3879. Third pass on the same seam (#3801#3853 → here), and the reason it needed a third is the point of the PR.

The hole

#3853 guarded signal.variables at the route and passed signal.output through unchecked. But output keys are merged under the suspended node's id (engine.ts:2214):

variables.set(`${run.nodeId}.${key}`, value);

and for a run parked on a map node run.nodeId is the map node — so the same forgery lands through the other field:

POST .../runs/{parentRunId}/resume
{ "output": { "$mapItemDone": true, "$mapItemOutput": { "result": "FORGED via output" } } }
→ { started: 2, results: [ { "result": "FORGED via output" } ] }

Scope, stated precisely. The #3853 map gate still held: a batch whose pending item sits on an approval is refused before any of this, so the approval bypass stayed closed. The residual was forging the recorded result of an item on an ungated screen/wait pause — map-state corruption, not a decision bypass. output also can't reach bare $runId/$record (it is always node-prefixed).

Why this isn't a third patch

Two escapes with one shape is a design signal. The seam had three open-coded writers into one variable map — output prefixed, variables bare, and the engine's own map handoff — so "guard the field that was exploited" was always going to invite the next field.

  • applyResumeSignal is the one place a resume signal reaches the variable map. Both fields are collected into a single write list — already in final, prefixed form — checked, then applied. A new signal field is covered by construction rather than by remembering.
  • All-or-nothing, and checked before the suspension is consumed: a rejected signal applies nothing (not even legitimate keys sent alongside) and the run stays parked, so the real continuation still lands.
  • The engine owns the rule; the transport maps the verdict. resume returns { success: false, code: 'invalid_signal' }; the route answers 400 and its own copy of the check is deleted. The SDK and any future adapter inherit it — implemented in one transport it protected exactly one transport, and one field of it.
  • Engine-built signals are exempt via a module-private symbol (ENGINE_BUILT_SIGNAL), stamped by bubbleToParent and the subflow output mapping — the only legitimate writers of the handoff keys, unreachable from a transport. Deliberately not RESUME_AUTHORITY_SERVICE: that marker answers "the owning service authorized this decision", and a service still has no business writing engine internals. Two questions, two markers.

This corrects #3853's placement argument in the ADR: "strict at the untrusted boundary" is right about where a rule binds, not where it lives.

Consumer impact

AutomationResult.code gains 'invalid_signal' alongside 'forbidden' — a switch over it needs a new arm; a plain read doesn't. Authoring is unchanged: ordinary variables pass, $ mid-name (price$) and dotted names (collect.note) included; only $… or a .$ segment is refused.

Tests

resume-authority-gate.test.ts (19): the forgery is refused through both fields via it.each — the point being that they are one rule, not two — with $mapState unchanged and the pause still live; the legitimate engine bubble still writes the handoff (proving the guard didn't break the mechanism it protects); a bare $runId rewrite on an ordinary screen resume is refused and the legitimate key alongside it is not applied (all-or-nothing); ordinary author names still pass. http-dispatcher.test.ts (200) now asserts the route forwards both fields unfiltered and maps invalid_signal → 400 — the rule is tested where it lives, not twice.

Verified: service-automation (35 files), plugin-approvals (12), spec (262), runtime (50 files / 716 tests) green; eslint --no-inline-config clean; check:docs, check:api-surface, check:doc-authoring, tsc --noEmit on spec all clean.

Separately

While verifying this I checked the run read routes: GET /automation/:name/runs/:runId and .../screen have no per-run authorization — access stops at environment membership, so any member can read any run's screen or step log by id. I have not judged whether that's acceptable (run ids are uuids; membership may be the intended boundary), so it is not claimed as a vulnerability here and nothing in this PR changes it. Flagging for a product call.


Generated by Claude Code

…ened the hole `inputs` had just closed (#3879)

#3853 guarded `signal.variables` at the route. That closed one of two
equivalent paths into the same variable map and left the other open:
`signal.output` keys are merged under `${run.nodeId}.${key}`, and for a run
parked on a `map` node `run.nodeId` IS the map node — so

  { "output": { "$mapItemDone": true, "$mapItemOutput": … } }

writes exactly the `<mapNodeId>.$mapItemDone` the `inputs` guard had refused,
making the map record a result for an item nobody decided. Demonstrated with a
repro, then fixed.

Scope: the #3853 map gate still held, so a batch whose pending item sits on an
`approval` was refused before any of this — the approval bypass stayed closed.
The residual was forging the recorded result of an item on an ungated pause.

Two escapes with one shape is a design signal, not two bugs. The seam had three
open-coded writers into one variable map (`output` prefixed, `variables` bare,
the engine's own map handoff), so "guard the field that was exploited" was
always going to invite the next field. Structural fix:

- applyResumeSignal is the ONE place a resume signal reaches the variable map.
  Both fields become a single write list (already in final, prefixed form),
  checked, then applied — a new signal field is covered by construction.
- All-or-nothing, and checked before the suspension is consumed: a rejected
  signal applies nothing and the run stays parked, so the real continuation
  still lands.
- The engine owns the rule; the transport maps the verdict. resume returns
  code 'invalid_signal', the route answers 400. The SDK and any future adapter
  inherit it. This corrects #3853's placement argument: "strict at the
  untrusted boundary" is right about where a rule BINDS, not where it LIVES.
- Engine-built signals (subflow output mapping, map item handoff) are exempt
  via a module-private symbol — deliberately not RESUME_AUTHORITY_SERVICE,
  which answers a different question and does not license writing internals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gvN32u1EiuvY9uQEMJiMR
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 28, 2026 2:03pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/runtime, packages/services, @objectstack/spec.

113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via packages/runtime, @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via packages/services, @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/runtime, packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime, packages/services, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 14:30
@os-zhuang
os-zhuang merged commit 93f267f into main Jul 28, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/run-resume-auth-gate-w67lvk branch July 28, 2026 14:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

automation: signal.output reopens the reserved-variable forgery #3853 closed on inputs

2 participants