Repository navigation
fix(security)!: a hook that runs as system and an action body write no identity assignment - #25954
Conversation
…refused writes to the four identity objects Red on main: the 40 refusal pins fail (the writes land), the 10 controls pass. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k
…ites to the four identity objects The #25251 refusal read isSystem AND flowRunId, so two other app-authored elevations — a hook's runAs:'system' api and an action body's context — reached the identity-assignment objects past the D12 gate and the organization doors. Each is now derived with the app-automation elevation mark (@objectstack/core): ScopedContext.withRunAs('system') and buildActionExecutionContext set it, ObjectQL.createContext carries it across its parse, and the one predicate reads it beside flowRunId. sudo() never sets it, so platform writes are unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k
📓 Docs Drift CheckThis PR changes 4 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 57 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 c45bddd47078b11b6ce388f3148aa66e30cd12bb && git checkout c45bddd47078b11b6ce388f3148aa66e30cd12bb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a728ba2acf52c13d1918c2afca7b79bf391e1f5 a8b13e57297024c1c0592ce81ced7d1e295a8df5 && git checkout -B drift-repro 2a728ba2acf52c13d1918c2afca7b79bf391e1f5 && git merge --no-ff a8b13e57297024c1c0592ce81ced7d1e295a8df5
node scripts/docs-audit/affected-docs.mjs --json 2a728ba2acf52c13d1918c2afca7b79bf391e1f5
|
Contract reviewServed-tier: ① Derived judgmentsRead on the net diff CI state at review time. 21 check-runs concluded Accept-set change — what is now refused, and by which door. One door: the data middleware's single call Producers of the mark — exactly the two ruled derivations. (a)
The carry across the parse is necessary and sole. Forgery and leakage from app code. The key is Byte-identity of the flow refusal. Composed from the diff, Per-source prescriptions. Hook: set The Each path on each of the four objects, with code and prescription. The integration file drives hook-body, hook-handler and action-body × four objects × insert/update/delete plus Unmarked system routes that remain, by the ruling's own placement. Read and confirmed, none a defect of this diff: (a) Public surface. Docs census row. Row 1b of Nothing shipped starts being refused. On the head, every file in ② Semver level
③ Boundary flagsDev report: comment 6408799371 on #25653;
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | draft, base main, Part of #25653 (the card stays open for the save-time half in domain:spec; pointer on #6017 6408782475), Clause-②: no (narrowing: …) and Metadata-protocol: none line-initial in body and changeset, four patch bumps with the BREAKING banner. |
| Files (12, +738/−24) | the core mark module + barrel, the objectql createContext carry, the predicate in flow-system-identity-write-refusal.ts, a comment in the authz middleware, the action execution-context builder, tests, one docs line, one changeset: the claim's surface plus the departures the PR body declares. No packages/spec/**. Not governed (check-governed-merges --pr 25954), 762 changed lines. |
| Fix | the one predicate becomes isSystem AND (flowRunId OR mark); the mark is set only at withRunAs('system') and buildActionExecutionContext, so app-authored hook and action-body writes to the four identity objects are refused the way #25251 refuses flows; sudo() and platform system writes unchanged; the flow refusal string is unchanged. |
| Contract review | PASS on this head. Option A (symbol) accepted for this PR, option B carried to the spec half. The L1 expression fork is reported on the card. The security-class out-of-scope finding is escalated to the seat, which carries it as its own card; it does not block this PR. |
| Evidence | red first 40/50 on main; ablation 40 + 48 pins red then restored; 108/108 derived gates exit 0; dogfood 296 files / 2523 tests green (report). |
| Landing | ready + auto-merge through the relay once every check on this head is green or an expected skip. The card keeps pm:dispatched (Part of). |
Generated by Claude Code
Requested by Leehom · project thread
Part of #25653
Clause-②: no (narrowing: app-authored hook and action-script writes to the four identity objects are refused at run time, as #25251 did for flows; the seat runs the contract review before the queue)
Metadata-protocol: none
The run-time half of the card. The save-time half (refusing at registration a hook or action body that names one of the four objects under system elevation, with its ADR-0087 D3 semantic entry) is
domain:specwork, pointed to on #6017; the card stays open for it.What changed
The #25251 refusal reads
isSystemANDflowRunId. Two other app-authored elevations carryisSystemand no run, so it did not see them: a hook'srunAs: 'system'api, and an action body'sctx.api/ctx.engine. Each is now derived with a provenance mark, and the one predicate reads it.sys_user_position,sys_user_permission_set,sys_invitation,sys_memberrunAs: 'system'hook (sandboxed body or in-process handler)ctx.apiorctx.enginerunAs: 'system'flowrunAs: 'user'hooksudo(), bare{ isSystem: true }writers@objectstack/core, newsecurity/app-automation-elevation.ts: the mark is a registry symbol (Symbol.for, so the ESM and CJS builds agree) withmarkAppAutomationElevation,appAutomationElevationOfandcarryAppAutomationElevation. A symbol cannot be spelled by a request body or a stored row, so no caller can claim or shed it. It lives in core because it is the one package objectql, runtime and plugin-security all depend on (the reasoningoperation-private-keys.tsgives).objectqlScopedContext.withRunAs('system')builds thesudo()-shaped context plus the'hook'mark.sudo()itself is untouched.runtimebuildActionExecutionContextadds the'action'mark;buildActionApi's malformed-envelope fallback now goes through the same builder.objectqlcreateContextcarries the mark acrossExecutionContextSchema.parse. The parse keeps declared keys only, and an action body'sctx.apiis built through it, so without the carry the body'sctx.apiwrites reached the refusal unmarked (measured:ctx.enginewas refused andctx.apiwas not).plugin-securityflow-system-identity-write-refusal.ts: the predicate is nowisSystemAND (flowRunIdOR the mark), in a newelevationSourcehelper. A context carrying both a run and a mark is answered as the flow, so a system flow's refusal is byte-identical. Each source names itself and its fix: a hook setsrunAs: 'user'; an action body has norunAs, so its grant moves to arunAs: 'user'flow, which an action of typeflowruns as the invoking user; invitations and memberships use the organization endpoints. No second module, no new error code.content/docs/permissions/system-context.mdxrow 1b covers the family; its anchor moves to#elevationSource, where theisSystemread now sits (check-system-context-censusexit 0).security-plugin-authz-middleware.ts.patch(Metadata-protocol: none), BREAKING banner, ADR-0087not-required (no-migration-prescription).Measured before any code (card condition 1)
On
maincb0a2fcf, realObjectQL+ SQL driver +SecurityPlugin+ QuickJS, a plain member as the trigger or caller:runAs: 'system'hook, as an L2 body and as an in-process handler, wrote each of the four objects (positions and grants withgranted_bynull);ctxat all, so it writes nothing. Per condition 1 that sub-path is reported as a fork, not fixed;PERMISSION_DENIED.Census: no hook, action body or host action handler in objectstack (
packages/,examples/,apps/) or HotCRM writes the four objects; the writers onmainare platform code with their own system contexts. Nothing shipped starts being refused.Tests
runtime/src/app-automation-identity-write-refusal.integration.test.ts(50 cases), real engine, driver, security middleware and QuickJS, store read back past the middleware:runAs: 'system'hook, body and handler × four objects × insert/update/delete: refused withcode,status, source and prescription; the store does not move and the triggering write aborts;ctx.api× four × three verbs, andctx.engineper object: the same;runAs: 'user'is refused by the ordinary gates, not by this refusal; asudo()derived from a user context and a platform system context insert, update and delete all four.7bb83a96, before the fix): 40 refusal cases failed, 10 controls passed.plugin-securityrefusal test: the predicate for both marks over everyGOVERNED_OPERATIONSverb on each object, run-plus-mark answered as the flow, the mark withoutisSystemnot judged, an unknown mark value read as none. PR1's cases unchanged and green.coreapp-automation-elevation.test.ts(6): symbol identity, fresh copy, spread survival, JSON cannot spell it, dropped by the parse and carried across it.runtime/action-body-identity.test.ts: twotoEqual({ isSystem: true })pins now include the mark, plus one case that the builder carries it.Pins describe behaviour only.
Verification
All at head
a8b13e57, under the verify lock:pnpm testper package, each VERDICTcommand-exit 0: core 119 files / 2763 passed; plugin-security 223 files / 4683 passed, 45 skipped; objectql 467 files / 8718 passed; runtime 445 files / 7151 passed, 19 skipped.pnpm typecheckin the same four packages: exit 0 each, includingcheck:test-typecheckwhere the package has it.eslint --no-inline-configon the 10 changed TypeScript files: exit 0.dispatch-gates --commands, 108 families): 108 exit 0, reconciled with--ran(0 NOT-MEASURED, 0 UNRUN). Four answered a prerequisite on the first pass and exit 0 after it was met:check-engine-split-ratio(shallow clone, deepened with--shallow-since), andcheck:skill-examples,check:dual-build-cjs-loads,check:i18n(fullturbo run build).scripts/ablation-replace.mjs, one behaviour: the predicate's read of the mark,elevationSource), with the marker proven indist/byablation-dist-preflightbefore the readings, because the runtime suite reads plugin-security fromdist/:git diff HEADempty, rebuilt, marker absent fromdist/, both pins green again (50/50, 95/95).(narrowing)arm): running at heada8b13e57when this PR was opened; its counts go in theos-dev-reporton Security: a runAs system hook or an action script body reaches the four identity-assignment objects past the delegated-administration gate (the #25251 refusal requires flowRunId) #25653.Acceptance notes
packages/core/src/security/(new module, barrel export, test),objectqlcreateContext(the carry, beside the claimedwithRunAs), the middleware call-site comment, and the two pin updates inaction-body-identity.test.ts. Nopackages/spec/**, andsudo()is unchanged.buildActionExecutionContextserves every handler an action door dispatches, host code handlers included, so a host handler's writes to the four objects are refused too. The census found none.ObjectRepository.execute()(the thirdexecuteActioncaller) elevates with{ ...context, isSystem: true }and is not one of the two ruled derivation sites, so an action reached that way from an unmarked context runs unmarked. The sandbox does not exposeexecute, so only host code reaches it. Noted, not changed.ExecutionContextfield, because this PR may not touchpackages/spec. Declaring it there is an option for the spec half; see the report on Security: a runAs system hook or an action script body reaches the four identity-assignment objects past the delegated-administration gate (the #25251 refusal requires flowRunId) #25653.claude/project-thread-hg746g; the claim comment is the identity record.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k