Repository navigation
finding: GET /automation/:name/runs/:runId/screen still discloses record-derived values to any authenticated caller who knows a run id #7968
Description
Observation-class finding, filed unassigned with no pm:queue — for triage to grade and route. Named residual of #7900 (PR pending), deliberately not decided inside that card because closing it needs a different mechanism from the one #7900's maintainer ruling names.
What #7900 closed, and what it left
The 2026-08-12 ruling on #7900 converged the /automation run-state reads on the sys_automation_run object-read grant. GET /:name/runs/:runId and GET /:name/runs now require it. The ruling also asked for the rest of the domain to be audited against the same rule, with a stated reason for anything that stays authenticated-only.
GET /:name/runs/:runId/screen is the one route the audit left authenticated-only, and the reason is on the route in packages/runtime/src/domains/automation.ts:
- It is the end user's surface, not the operator's. The pause exists because the flow is asking this caller to fill a form in; the trigger response already handed them the same
screeninline, and the route exists so a browser refresh does not lose it. Requiring an operator grant here would refuse the screen to the very person the flow paused for — a breakage, not the narrowing the ruling prices in ("operator tooling … now needs the grant"). - Its write sibling already answers on a different axis:
resumeis gated in the engine by the suspension's declaredresumeAuthority(automation: the generic run-resume route needs an authorization gate keyed on the suspended node #3801 / automation:resumeAuthoritydefaults to'any', so every future pausing node ships fail-open — ADR-0044 says this is "tracked separately" and nothing tracks it #5561) — a per-run authority model, not an object grant. Read and write on the same pause answering to two unrelated permissions would be the incoherence 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 exists to remove.
The residual, measured
The route is not inert with respect to record data. AutomationEngine.getSuspendedScreen(runId) returns (await this.loadSuspendedRun(runId))?.screen — the ScreenSpec persisted with the suspended run — and that spec is built with values interpolated against the live flow variables at suspend time (packages/services/service-automation/src/builtin/screen-nodes.ts: interpolate(cfg.defaults, variables, context) and, per field, interpolate(f.defaultValue, variables, context)).
So a flow whose screen prefills from the triggering record (defaultValue: '{record.email}', a defaults map over {record.*}) writes those values into the spec, and the route answers them to any authenticated caller who knows the run id — there is no check anywhere on this path that the run belongs to, or was paused for, the caller. Same shape as the #7900 exposure, one route over and a narrower payload: interpolated screen defaults rather than the whole variable map.
Not claimed: that this is a regression (it is true of origin/main before #7900 as well), or that it exposes any credential class (the #7900 analysis of connector credentials applies here unchanged).
What closing it would take
Not the sys_automation_run grant — that is the mechanism this route was audited out of, for the reasons above. The shape that fits is the one the resume path already has: a per-run authority read gate, i.e. "may this caller see the screen this run is paused on?", answered from the suspension's own resumeAuthority / assignee state rather than from an object permission. That is a design call about the screen-flow runtime (ADR-0019 territory), not an application of the #7900 ruling, which is why it is a separate card.
Possible cheaper interim, if triage wants one before that design lands: scope the route to the run's own trigger identity (ExecutionLogEntry.trigger.userId) with the sys_automation_run grant as the operator override — narrow, decidable, and it does not refuse the end user. Recorded as an option, not a recommendation; the authority model is the real answer.
Evidence
Measured 2026-08-12 while implementing #7900, on origin/main base 4b5702a, reading packages/runtime/src/domains/automation.ts, packages/services/service-automation/src/engine.ts (getSuspendedScreen) and packages/services/service-automation/src/builtin/screen-nodes.ts.
Source
Split out of #7900 — the implementing dev's named residual, recorded in the route's own comment and in that card's PR audit table.
Activity
hotlong commentedon Aug 12, 2026
Triage:
needs-user-decision, routeddomain:cli(route lives inpackages/runtime/src/domains/automation.ts, same lane as #7900), type Bug. Human floor: security/permission boundary — the card's own analysis shows the #7900 mechanism was deliberately ruled out for this route, so closing it is a new access-model decision, not an application of an existing ruling.Question: how should
GET /automation/:name/runs/:runId/screenbe gated, given the object-read grant would refuse the end user the flow paused for?Options:
resumeAuthority/ assignee state (the axisresumealready answers to, automation: the generic run-resume route needs an authorization gate keyed on the suspended node #3801/automation:resumeAuthoritydefaults to'any', so every future pausing node ships fail-open — ADR-0044 says this is "tracked separately" and nothing tracks it #5561). The coherent end state; ADR-0019-class design work.ExecutionLogEntry.trigger.userId) OR thesys_automation_rungrant as operator override. Recorded on the card as an option.Four-lens block:
{record.*}into screens as a matter of course; a structural gate (A or B) prevents the class, documentation (C) does not.Recommendation: B now, A recorded as the direction (B does not preclude A; both refuse the same stranger and admit the same end user). If B is taken, the acceptance test is: stranger with valid auth + run id ⇒ denied; triggering user ⇒ screen; holder of
sys_automation_runread ⇒ screen.本评论来自分诊座位(scheduled session
session_0199Rq2oEnNNRmdhmWwqUwvQ),不构成认领。Generated by Claude Code
hotlong commentedon Aug 12, 2026
Maintainer ruling recorded — Option B adopted now, A recorded as the direction. Provenance: maintainer, 2026-08-12 (live, in this PM session), on the consolidated decision digest: 「接受你的全部建议。」 — adopting the triage recommendation (comment 5265162333).
Binding ruling:
GET /automation/:name/runs/:runId/screenis gated to the run's own trigger identity (ExecutionLogEntry.trigger.userId) OR a holder of thesys_automation_runread grant (operator override). Option A — the per-runresumeAuthorityread gate — stays recorded as the coherent end state (ADR-0019-class design); B does not preclude it.Acceptance (verbatim from the escalation, now binding): stranger with valid auth + run id ⇒ denied; triggering user ⇒ screen; holder of
sys_automation_runread ⇒ screen. Landing:packages/runtime/src/domains/automation.ts(cli lane, same as #7900).本评论来自分诊座位(scheduled session
session_01DLzE5XVRASxFotEU1h9cjH),不构成认领。Generated by Claude Code
hotlong commentedon Aug 12, 2026
Serial block cleared — claimed and dispatched.
domain:cliPM seat (#6024), sessionsession_01B3Kurx8qufrDzNjk4rag7V, branchclaude/issue-7968-screen-route-gating.packages/runtime/src/domains/automation.tsis free: #8125 (#8055), #8177 (#8123) and #8204 (#8054) have all merged. This card was last in that file's serial chain and is the final dispatchable card in this lane's queue.Executing the binding ruling in comment 5266069853 (Option B) exactly as written. I re-read both comments before dispatching rather than working from my own notes — the ruling verified unchanged, and the re-read surfaced the thing the brief most needed:
The obvious gate is the wrong one, and the card says why. An object-read grant on the flow's records — the #7900 mechanism — was deliberately ruled out for this route, because it would refuse the end user the flow paused for. That is the trap here: a reviewer or implementer reaching for the familiar permission check would produce something that passes a "stranger is denied" test and breaks the route's actual purpose. So the brief carries the over-block guard as a first-class requirement, not an afterthought.
Also carried in, from the triage analysis:
resumeAuthority/assignee state, the axisresumealready answers to (automation: the generic run-resume route needs an authorization gate keyed on the suspended node #3801 / automation:resumeAuthoritydefaults to'any', so every future pausing node ships fail-open — ADR-0044 says this is "tracked separately" and nothing tracks it #5561), ADR-0019-class design work. ⛔ Not in scope here. B does not preclude it: both refuse the same stranger and admit the same end user, so A can supersede B later without re-litigating the acceptance.Acceptance, verbatim and binding: stranger with valid auth + run id ⇒ denied; triggering user ⇒ screen; holder of
sys_automation_runread ⇒ screen. The denial must be asserted on its full ADR-0112 envelope (codeandstatus), never on "an error came back".Generated by Claude Code
hotlong commentedon Aug 12, 2026
{ "issue": 7968, "status": "done", "branch": "claude/issue-7968-screen-route-gating", "pr": "https://github.com/objectstack-ai/objectstack/pull/8226", "premise_still_valid": true, "summary": "STEP 1 reproduced the disclosure end-to-end before gating. A real screen flow (registerScreenNodes, field defaults interpolating {record.email}/{record.phone}/{record.salary_band}, triggered on a crm_lead record) suspends with a ScreenSpec carrying the record values, and the real dispatcher answered it to a stranger under 200. Observed payload served to user_stranger (valid auth, no relationship to the run, security service explicitly REFUSING the sys_automation_run grant): {\"success\":true,\"data\":{\"runId\":\"run_da4151a8\",\"screen\":{\"nodeId\":\"collect\",\"title\":\"Confirm Acme Health\",\"description\":\"Contact for ceo@acme-health.example\",\"fields\":[{\"name\":\"email\",\"defaultValue\":\"ceo@acme-health.example\"},{\"name\":\"phone\",\"defaultValue\":\"+1-555-0100\"},{\"name\":\"salary\",\"defaultValue\":\"L7 / 285000 USD\"}]}}}. The same request now answers 403 PERMISSION_DENIED. STEP 2 implements Option B verbatim: the route requires the run's own trigger identity (ExecutionLogEntry.trigger.userId) OR read access to sys_automation_run as operator override; the #7900 grant question became one shared predicate (mayReadRunState) rather than a second copy. The gate runs AFTER getSuspendedScreen so every 404 path stays byte-identical for every caller — recorded on the gate, with its consequence (a stranger can still tell a paused run id from an unknown one) stated rather than hidden. Option A (per-run resumeAuthority read gate) was not built.", "tests": "pnpm --filter @objectstack/runtime test => 'Test Files 148 passed (148) | Tests 2293 passed (2293)' (21 new in automation-screen-read-gate.test.ts). typecheck => clean (tsc --noEmit, no output). check:type-check-debt => 'OK - 33 ledger entr(ies) re-measured, 1966 raw tsc error(s) total, none above its recorded number'; @objectstack/runtime measured exactly at its recorded 227 (absent from the can-be-lowered list), not raised. check:route-envelope => 'check-route-envelope self-test passed' + '9 route module(s) audited: 7 conformant, 1 ratcheted, 1 exempt' + 'Dispatcher domains - 16 audited: 12 helper-only, 4 with declared hand-built responses (0 ratcheted)'; no ratchet needed lowering. node scripts/check-nul-bytes.mjs => 'OK (scanned 7447 text file(s) ... no raw ASCII control bytes)'; targeted grep -naP control-byte self-scan over the four touched files clean. REVERSE VERIFICATION (gate reverted to origin/main, tests unchanged; direction predicted before running and matched exactly: 9 red / 12 green): 'AssertionError: expected undefined to be PERMISSION_DENIED // Object.is equality' (stranger denial, both no-userId and outage variants, getRun-throw, withoutGetRun), 'AssertionError: expected {\"success\":true,\"data\":{\"runId\":\"run_... not to contain Acme Health' (the disclosure pin), 'AssertionError: expected {\"success\":true,... to contain sys_automation_run' (#7450 refusal wording), 'AssertionError: expected \"vi.fn()\" to be called with arguments: [ run_1 ]' (identity read off the run), 'AssertionError: expected [] to have a length of 1 but got +0' (explain consulted). The 12 that stayed GREEN pin behaviour the gate deliberately leaves unchanged and should stay green: the three admit cases (an ungated route serves everyone, so an over-block pin cannot detect a MISSING gate - only a WRONG one), both 404 rows plus the explain-not-asked-on-404 row, the 501, the four non-denials (system/absent/partial/401 floor) and the resume-scope case. Restored: git diff HEAD empty, 0 files differing; pins re-run green (280 passed across the 3 affected files). RECORD-READ-GATE PROBE (step 4, run twice): (a) gating on the sys_automation_run grant alone - the #7900 mechanism - leaves EVERY denial pin green while the over-block guards red: 'AssertionError: expected 403 to be 200 // Object.is equality' on 'serves it whole to the identity the flow paused for, with NO grant at all' and on 'still serves the end user when the permission subsystem is DOWN', plus the identity-read pin. (b) gating on an object-read grant over the flow's own record object (trigger.object) reds the same guards plus 'AssertionError: expected crm_lead to be sys_automation_run'. Both wrong gates are caught ONLY by the over-block direction, which is what makes this ruling non-obvious. Tree restored byte-identical after each probe.", "open_questions": [], "out_of_scope_findings": [] }Notes for the PM, beyond the JSON:
http-dispatcher.test.ts's routing claim ("screenmust not be swallowed by thegetRunroute below it") asserted itself as "getRunwas never called". The gate reads the run to resolve its trigger identity, so that proxy stopped tracking the claim; it is now asserted on the answer — the caller gets the{ runId, screen }envelope, not theExecutionLogEntryshape — which pins the routing claim more directly than the proxy did. Caught by the full-suite run, not by CI.screenrow is retired, with a note pointing at the new file. That table's own rule says changing any of its verdicts has to come through it, so the row's removal is recorded there rather than silently dropped.skip-changesetlabel — this PR ships a changeset (user-visible authorization change).Generated by Claude Code
fix(runtime): gate the paused-run screen read to the run's trigger id…
fix(runtime): the enablement door refuses in its own words (objectsta…
fix(runtime): the resume door checks WHO is resuming — the run's star…