Repository navigation
fix(runtime): the resume door checks WHO is resuming — the run's starter, or the sys_automation_run read grant (#19987) - #20005
Conversation
…s resuming The resume arm validated the body and called resume() without reading any identity, so an authenticated caller holding another user's run id continued that user's paused run, which then ran under the starter's stored context. The arm now asks the screen read's own question through one shared predicate, isRunStarterOrRunStateReader: the run's trigger identity, OR the sys_automation_run read grant as the operator override, OR a system context. Refused callers get the read twin's 403 PERMISSION_DENIED with the same requirement sentence under the door's own verb, and resume() is never called. The gate sits after the body checks and immediately before the engine, and fails closed on a run getRun cannot resolve. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
…used-run predicate The system-context census row for the automation domain anchored refuseUnrelatedScreenRead, whose isSystem read now lives in the predicate the screen read and the resume door share. The route-ledger note drops the tracker ids check:doc-authoring refuses in runtime strings. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c68c0b3907e30bbf69aa60981ae0934fa4679fc8 && git checkout c68c0b3907e30bbf69aa60981ae0934fa4679fc8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cc4ee77587b121eb3a1fc6a42c12080615e9bdc7 b4cb8a4d13f3ed63ce7b334df86fa2b816d65215 && git checkout -B drift-repro cc4ee77587b121eb3a1fc6a42c12080615e9bdc7 && git merge --no-ff b4cb8a4d13f3ed63ce7b334df86fa2b816d65215
node scripts/docs-audit/affected-docs.mjs --json cc4ee77587b121eb3a1fc6a42c12080615e9bdc7
|
Face review (post-hoc, released)Served-tier: Scope: the changeset (and named docs) of PR #20005, released; this review cannot block anything and exists to find false released prose. Security card: this record stays at doors, roles, codes and statuses; no request-body, header or field spelling and no reproduction steps. Sentence verdictsChangeset
Docs
Docs Still true on origin/main?Yes. FindingNONE. Reviewed-by: VERDICT: CLEAN Generated by Claude Code |
Fixes #19987
Clause-②: no
What this changes
POST /automation/:name/runs/:runId/resumenow checks WHO is resuming. Before, the arm validated the body and calledautomationService.resume(runId, signal)without reading any identity. So an authenticated caller holding another user's run id (u2on a run whosegetRun().trigger.userIdisu1) got200andresumeran. The resumed run then continued under the context STORED on the run (engine.tsconst context = run.context): its data nodes ran asu1, with valuesu2submitted. The screen read on the same pause already refusedu2.The write door now asks the screen read's question (maintainer ruling Option B on #7968, applied to the write by triage's direction
5817076635). Both doors call one predicate,isRunStarterOrRunStateReader(packages/runtime/src/domains/automation.ts):getRun(runId).trigger.userId), ORsys_automation_run(the operator override, asked throughmayReadRunState, the finding: the whole /automation read domain is gated only by "authenticated" — run-detail returns the triggering record's fields without that record's own FLS #7900 question), OREveryone else gets
403 PERMISSION_DENIED, the read twin's code and status, andresume()is never called, so the pause stays parked.refuseUnrelatedScreenReadand the newrefuseUnrelatedResumeare now two thin guard clauses over that one predicate. Each owns only its refusal sentence and WHERE it asks. The read twin's behaviour and message are byte-identical to before.resumeAuthoritytoday, as the ruling asks (read first, per triage's binding limit)resumeAuthorityis the closed enum'any' | 'service'(packages/spec/src/automation/node-executor.zod.ts). It names no person. Measured onorigin/main6bf7f1c0:resumeAuthorityscreen'any'service-automation/src/builtin/screen-nodes.tswait'any'service-automation/src/builtin/wait-node.tsmap'any'service-automation/src/builtin/map-node.tssubflow'any'service-automation/src/builtin/subflow-node.tsapproval'service'plugin-approvals/src/approval-node.tsapproval_revise'service'plugin-approvals/src/approval-revise-node.ts'service'(#5561)engine.tsresolveResumeAuthorityAn approver decides through
ApprovalService, which resumes in process (approval-service.ts:this.automation!.resume!(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true })) and never entershandleAutomationRequest. So no caller this door admitted before is a caller the node names, and triage's limit 「不得拒绝节点自身resumeAuthority已经明确授权的人」 holds by measurement: the set of node-named callers on this door is empty. The node gate still answers, unchanged and word for word, behind the new gate (pinned: an admitted starter on anapprovalpause still gets the engine's own 403 message).The two doors' rules, side by side
POST /automation/:name/runs/:runId/resume(this PR)resume_run(mcp.tsresumeActionRun, unchanged)sys_automation_runread grant, not the starter403 PERMISSION_DENIED404(one not-found for unknown, finished and foreign runs)resumeAuthority)classifyResumeResultclassifyResumeResult(same table)Order of operations, decided by measurement
The gate runs AFTER the body checks and immediately BEFORE
resume().400 VALIDATION_FAILEDis byte-identical for every caller. The gate's lookup is spent only on a request the engine would otherwise receive.resume(). A refused resume consumes nothing.getRuncannot resolve. There is no 404-first step, and this is where the write departs from the read twin. The read twin answers 404 first becausegetSuspendedScreenis an authoritative "is there anything to disclose" check. The write has no such probe short ofresume(), andresume()consumes the pause.getRun'snullis not authoritative. The engine returnsnullfor a run that is parked and resumable when the durable read degrades (engine.tsgetRun: "a run that is parked and resumable reports as if it had never run"). It also returnsnullafter the 1000-entry in-memory ring evicts the entry on a deployment with no suspended-run store (DEFAULT_MAX_EXECUTION_LOG_SIZE, thespliceinrecordLog), and a caller who can start runs can cause that eviction. Lettingnullthrough would open the gate in exactly those states.403instead of the engine's404. As a side effect, a stranger can no longer tell a paused run id from an unknown one here. For every caller the gate admits, every engine answer is unchanged, including the404for unknown and finished runs (both pinned). One cost falls on honest callers: a caller WITHOUT the grant who mistypes a run id now gets403rather than404.Tests
All readings are on HEAD
b4cb8a4d, unless a line says otherwise.automation-resume-caller-gate.test.tswas run against the unfixed arm (base6bf7f1c0) and gaveTests 17 failed | 33 passed (50). The strangeru2onu1's run got200, withresumecalled.packages/runtime/src/domains/automation-resume-caller-gate.test.ts: 50 cases. Every refusal row asserts the envelopecodeANDstatus, and asserts thatresumewas NOT called:detailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450);trigger.userIdrefused;getRuncannot resolve refused;getRunthrow refused, even for the starter;getRun: refused unless the grant admits;explainis never called;explainis asked forreadonsys_automation_runwith the caller's own context;VALIDATION_FAILEDbefore the gate for every caller; anonymous gets401first; no security service, or a partial one, admits;code, the samestatusand the same requirement sentence.automation-screen-read-gate.test.tshad a case pinning the defect itself ("leavesresume's own authority checks alone": stranger not 403,resumecalled). It is replaced by two cases: the same stranger is refused atresume(same code and status,resumenot called), and the triggering user is admitted with no grant. Comments in three sibling suites that said the resume door had no route-level gate are corrected. Their assertions are unchanged and still green.node scripts/ablation-replace.mjs, on committeda99d03a4, the gate's code identical at HEAD). It replacedif (unrelated) return unrelated;with an inert statement: anchor 1 → 0, blob467c4019f123→e8f9a4915391. With the gate removed, the two gate suites wentTests 15 failed | 57 passed (72): every stranger and unresolved row turned red, and every starter, grant and system row stayed green. The direction is red, as expected. Restore: blob == HEAD (467c4019f123) andgit diff HEADempty. The suite imports the arm by relative source path, so nodist/is involved.pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2(ona99d03a4; the later commit changes only a ledger note string and a docs table row):Test Files 276 passed (276),Tests 3893 passed | 1 skipped.b4cb8a4d, the nine suites that read the route ledger or drive the resume arm (route-ledger.conformance,http-dispatcher,discovery-schema-conformance,auth-forward-fault-sanitizationand the five automation gate suites) were re-run:Test Files 9 passed (9),Tests 436 passed (436).--project repo:Test Files 2 passed,Tests 69 passed.pnpm --filter @objectstack/runtime typecheck: exit 0. Output:check:test-typecheck: OK — @objectstack/runtime's test layer compiles, and the new suite is not in the debt ledger.pnpm --filter @objectstack/dogfood exec vitest run test/flow-durable-suspend.dogfood.test.tsagainst the rebuilt runtimedist/(it carriesisRunStarterOrRunStateReader):Tests 11 passed (11). That includes the real-engine resume after a real cold boot.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon HEAD derives 92 commands. All 92 were run with exit codes captured before any pipe, and all exited 0.--ranreconciliation:92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN("a DERIVED zero — all 92 recorded an exit code and none of them is 3"). Two runs needed a prerequisite first.check:dual-build-cjs-loadsandcheck:type-check-debtrefused with exit 3 (nodist/), andcheck:skill-examplesrefused the same way (no client-react.d.ts). A fullturbo run buildthen ran (73 successful, 73 total), and all three were re-run green before the final union.check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:route-ledger-census, and alsocheck:error-status-conformance. All exit 0.pnpm lint(the full union,eslint . --no-inline-config): exit 0 in 112s.Two gates were red on the first pass, and both reds were caused by this diff:
check:system-context-census: theisSystemread moved fromrefuseUnrelatedScreenReadinto the shared predicate. The row incontent/docs/permissions/system-context.mdxis re-anchored and now names the resume door.check:doc-authoring: tracker ids in the route-ledger note string. They are removed from the string.Docs
content/docs/automation/flows.mdx:wait(signal) row no longer says "any caller".Deviations, stated rather than chosen silently
PAUSED_RUN_REQUIREMENT. The verb phrase is this door's own: "Resuming a paused run requires being the identity that triggered the run, or read access to 'sys_automation_run'." A resume refused with "Reading a paused run's screen …" would name an operation the caller did not attempt. That is the misdirection [finding] a refusedPOST /automation/:name/toggleis told "Authoring automation flows requires …" — the shared deny message names a verb the caller did not use #11666 took off the toggle door. If the literal read sentence is wanted instead, it is a one-line change:RESUME_DENY_MESSAGE = SCREEN_READ_DENY_MESSAGE.packages/runtime/src/route-ledger.ts: the resume row'snotenow describes the caller gate.content/docs/permissions/system-context.mdx:check:system-context-censusrefuses the tree until that row re-anchors.Acceptance notes (observations, not filed)
sys_automation_runread. Examples: an integration feeding awaitnode's signal, or support tooling. The changeset says so. No in-repo caller does this: the REST resume callers onmainare the route mount, the client SDK method and the tests.WaitExecutorConfigSchema.webhookUrlPatterndefaults to/api/v1/automation/resume/{executionId}/{nodeId}, a path no route serves, and it has zero runtime readers. It is dormant, not filed. Carrier: none.automation.resumedocblock lists the resume refusals but not this 403. The list is incomplete, not wrong. Not filed. Carrier: none.NOT MEASURED locally (CI owns them)
typecheckran. The public surface is unchanged (no export added or removed), so no importer owes a test.dispatch-gateslists outside its derived total.Generated by Claude Code