Skip to content

fix(runtime): gate the /automation definition writes on manage_metadata - #10242

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-10145-automation-write-manage-metadata-gate
Aug 20, 2026
Merged

fix(runtime): gate the /automation definition writes on manage_metadata#10242
os-elon merged 2 commits into
mainfrom
claude/issue-10145-automation-write-manage-metadata-gate

Conversation

@os-elon

@os-elonos-elon commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Fixes#10145

⛔ This PR is parked OUTSIDE the merge queue, deliberately

This is an Urgent, measured, cross-tenant security fix, and it is not queued. The card carries needs:contract-review because the change narrows the accept set on published REST/SDK surface (Clause-② — callers who write flows today will start getting 403). Under the enqueue gate that label may only be cleared by a seat at contract-review tier that is not the dispatching one, so: draft only — not flipped ready, no auto-merge armed, label not cleared, green CI notwithstanding. Saying so out loud is the point: a silent wait on an open cross-tenant hole is worse than a loud one.

The defect, re-derived at head (not taken from the card)

The card is stamped at framework 8798cd2; this branch measured it at 33024d905. It still reproduces, and the reproduction is in the diff: with packages/runtime/src/domains/automation.ts reverted to origin/main and the new pin run, the unentitled principal is answered 200 on all three writes.

× POST /automation (createFlow): 403 PERMISSION_DENIED, and the registry is untouched
× PUT /automation/:name (updateFlow): 403 PERMISSION_DENIED, and the registry is untouched
× DELETE /automation/:name (deleteFlow): 403 PERMISSION_DENIED, and the registry is untouched
× refuses BEFORE the body is validated — a malformed definition is still not a 422
× an unrelated authoring-adjacent capability is not enough
× fires BEFORE the service probe, so 403-vs-501 leaks no deployment capability
AssertionError: expected 200 to be 403
Tests 6 failed | 13 passed (19)

The 13 that pass unfixed are the half the fix must not move: the anonymous 401 floor, the entitled-caller positive controls, and the whole execution-route audit block.

A flow definition is authored metadata registered at environment scope, not organization scope, so an ungated write is a privilege escalation and a cross-tenant one on any multi-organization deployment. The domain's own GET / audit note already said where this belongs — "Flow definitions are metadata and are governed on the metadata plane (/meta, ADR-0106)" — and settled the READ posture while leaving the WRITE half of that same sentence unstated. This is that half.

The fix

packages/runtime/src/domains/automation.ts — one predicate plus one guard clause, both modelled on the domain's existing isRunStateRead / refuseUngrantedRunRead pair rather than invented:

Placed with the anonymous floor and the run-state read gate, ahead of the automation-service probe and ahead of every body check, so (a) a 501-vs-403 cannot fingerprint whether a deployment mounts automation, (b) nothing is registered or unregistered before the refusal — "delete first, refuse second" is exactly what the report measured — and (c) the definition contract is not enumerable by probing 422s from outside the authoring cohort.

The write set was derived here, and execution was deliberately left out

Measured from the registrar in domains/automation.ts and the ledger rows, not taken from the dispatch:

routeverdict
POST /automationregisterFlowgated — authors a definition
PUT /automation/:nameregisterFlowgated — authors a definition
DELETE /automation/:nameunregisterFlowgated — destroys a definition
POST /automation/:name/trigger, POST /automation/trigger/:nameexecuteungated — executes, does not author; this is a member's own record-action door
POST /automation/:name/runs/:runId/resumeresumeungated — executes, and already fail-closed on the suspended node's resumeAuthority (#3801 / #5561). A metadata capability in front of it would refuse the very user the flow paused for — the mistake #7968 records for the screen read
POST /automation/:name/toggletoggleFlowungated — mutates engine enablement, not a definition. Arguably an authoring write; filed separately rather than folded into a security fix
GET surfacesuntouched; run-state reads keep their sys_automation_run grant (#7900)

Both directions are pinned. The stays ungated block in the new test is the audit that forces any future change to those four verdicts through this file.

Pin sweep — whole repo, one pass

Every pin that drives an automation write, including the ones left alone and why:

Changed (caller only — every mechanism, assertion and expected value untouched):

pinwhat it is aboutchange
domains/automation-body-validation.test.tswhich malformed bodies are refused, with which field codesCTX gains manage_metadata
domains/automation-put-post-error-parity.test.tsPOST/PUT refusal-class parityCTX gains manage_metadata
domains/automation-register-error-class.test.ts400-vs-500 for a rejected definition; unknown-flow 404CTX gains manage_metadata
http-dispatcher.test.ts (3 routing cases)which service method a write path reaches, with which argumentsnew FLOW_AUTHOR caller; AUTHED_CALLER stays on every execution route
http-dispatcher.test.ts (#4058 stub-slot row)a stub slot answers 501 and is never calledcaller is per row now; only the POST / row authors

Each was written when any authenticated session could register a flow — i.e. the { userId: 'user_1' } stub encoded exactly the premise this gate destroys. Kept verbatim, deliberately: every existing refusal assertion for a genuinely illegal shape (malformed bodies, unknown keys, unknown flow names) and every anonymous-401 assertion — dispatcher-plugin.anonymous-gate.integration.test.ts and qa/dogfood/showcase-anonymous-deny-surfaces.dogfood.test.ts both assert anonymous DELETE /automation/:name is 401, and the new gate sits behind that floor so they are unaffected.

Found and NOT changed, with the reason:

  • packages/client/src/client.test.tsautomation.create/update/delete against a mocked fetch; asserts URL and method, no server semantics.
  • packages/adapters/hono/src/hono.test.ts — automation coverage is GET only.
  • packages/verify/src/automation-*.test.ts, domains/automation-trigger-*, -resume-*, -toggle-unknown-flow, -screen-read-gate, -run-* — execution and read surfaces, outside the write set by design.
  • packages/qa/dogfood/test/flow-*.dogfood.test.ts, showcase-declarative-mcp — register flows through the app stack, not over HTTP; they GET and trigger only.
  • packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts — resolves mounted patterns; drives no automation write.
  • packages/spec/src/api/automation-api.zod.test.ts — contract shape only; the spec declares no capability field for any route, so there is nothing to declare there.
  • packages/qa/dogfood/test/authz-conformance.matrix.ts — its discover() probe for this domain matches shouldDenyAnonymous( only, so the new gate adds no key and the completeness ratchet is unmoved. Its anonymous-deny-automationrow prose now under-describes the domain (the /packages sibling row names its per-route capability gates in the same field); filed rather than edited, being outside this card's declared file surface.

route-ledger.ts was READ, never written

The card cites route-ledger.ts:317,329,330 as evidence. At head those line numbers have drifted off the write rows entirely (317 is a continuation of the legacy-trigger row; 329/330 are GET .../runs and GET /:name); the real rows are 319, 331, 332. No ledger change was needed and none was made — the row interface carries no capability field, so the gate is expressible without it. The one thing the ledger could gain is a note naming the new gate, the way the resume row documents resumeAuthority; that file is under a hard serial with #10177, so it is filed for sequencing rather than taken.

Verification

Gates re-derived with node scripts/pm/dispatch-gates.mjs against the real diff (7 paths, three-dot vs merge base). It named five families the dispatch list could not — check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs — all five .changeset-triggered, and all five run. Results and the commit they were measured at are in the report comment on #10145.

Generated by Claude Code

…ta (#10145)
POST /automation, PUT /automation/:name and DELETE /automation/:name
registered without any capability check, so any authenticated caller could
author, modify and deregister flow definitions. Flow metadata is registered at
environment scope, not organization scope, so on a walled multi-organization
deployment the write crossed the tenant wall: a plain tenant org owner holding
organization_admin (and answered 403 by /meta, /ai/tools and /packages) deleted
a shipped flow that then read 404 for the actor, for an unrelated tenant and for
the platform admin.
The three definition writes now demand `manage_metadata` — ADR-0066 D1's
authoring capability, the same key `PUT /meta/:type/:name` and every
state-changing `/packages` route already carry. One predicate
(`isFlowAuthoringWrite`), placed with the domain's anonymous floor and run-state
read gate and ahead of both the service probe and body validation, so a refused
caller writes nothing and cannot fingerprint whether automation is mounted.
Execution routes are deliberately out of the write set: trigger (both shapes)
and resume run flows rather than author them — resume is already fail-closed on
resumeAuthority — and toggle mutates engine enablement, filed separately rather
than folded into a security fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
… as an author (#10145)
The `POST /` row of the #4058 stub-slot pin is a definition write, so the
manage_metadata gate — which sits ahead of the service probe on purpose —
answers it 403 before the 501 that row exists to pin. The caller is per row now:
the write row authors, the execution rows keep the ordinary caller. Every
assertion is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
@github-actions

github-actionsBot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 28 documentable anchor(s).

43 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 923c42470344b29a86f4bae37850e4305292c3ee.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 923c42470344b29a86f4bae37850e4305292c3eepackageMentionDocs.

Which tree this was computed on

This run read content/docs from 9a070afb7f9e81fa7c65e586d1958e5b61025389 — the merge of head c8e6be2532d0d408640c9e461f0b457dfd0ba57b into base 923c42470344b29a86f4bae37850e4305292c3ee, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9a070afb7f9e81fa7c65e586d1958e5b61025389 && git checkout 9a070afb7f9e81fa7c65e586d1958e5b61025389
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 923c42470344b29a86f4bae37850e4305292c3ee c8e6be2532d0d408640c9e461f0b457dfd0ba57b && git checkout -B drift-repro 923c42470344b29a86f4bae37850e4305292c3ee && git merge --no-ff c8e6be2532d0d408640c9e461f0b457dfd0ba57b
node scripts/docs-audit/affected-docs.mjs --json 923c42470344b29a86f4bae37850e4305292c3ee

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 923c42470344b29a86f4bae37850e4305292c3ee → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-elon@claude