Repository navigation
fix(security)!: an app-authored hook's inherited elevation, its sudo() and an action it reaches by execute() write no identity assignment - #25982
Conversation
…ok's inherited elevation, its sudo() and execute() (red) Red first: 41 refusal cases fail at this commit, the controls pass. The fix follows. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k
…, and an action it reaches by execute() write no identity assignment A hook bound through the hook binder is app-authored; its api is derived through a new objectql-private key so an inherited system context and its in-process sudo() carry the app-automation mark, and ObjectRepository.execute() marks the action it reaches. Hooks registered in code (platform) are unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k
…'s inherited elevation refusal Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k
…he published-face pin honest The unexported derivation key is the second symbol-keyed member of ScopedContext; a platform wrap still hands the api through by reference. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k
…covers Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k
📓 Docs Drift CheckThis PR changes 2 package(s): 3 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 — 29 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 91e761d521fd6dc1a61c94edef74e712b927be7c && git checkout 91e761d521fd6dc1a61c94edef74e712b927be7c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cdd4eea93811522cca145a0f24858def51443671 d0489d5e4513eec99d83dc16dab8737f61bae5f5 && git checkout -B drift-repro cdd4eea93811522cca145a0f24858def51443671 && git merge --no-ff d0489d5e4513eec99d83dc16dab8737f61bae5f5
node scripts/docs-audit/affected-docs.mjs --json cdd4eea93811522cca145a0f24858def51443671
|
Contract reviewServed-tier: ① Derived judgmentsRead on the net diff CI state at review time. 47 check-runs on Accept-set change: three app-authored elevations are now refused. The door is unchanged: the data middleware's one call to The discriminator: a hook is app-authored when the hook binder bound it. Platform code keeps its writes. The discriminator cannot be spoofed by app-authored metadata. Judged in words. The flag is an argument the binder itself passes, so nothing an author writes in a hook reaches it; Slice-1 behaviour is unchanged. No fixture was loosened. The plugin-security message pin follows the new wording exactly (a tighter regex, not a wider one). The objectql published-face pin moves from one unexported symbol-keyed member to two and adds an explicit assertion that Each new path on each of the four objects, with code and prescription. Inherited elevation: body and handler surfaces, four objects, insert, update and delete, each checked for Routes that remain unmarked, by the ruling's placement. (a) Docs census row 1b now names the inherited elevation, Nothing shipped starts being refused. On the head, no caller of ② Semver level
③ Boundary flagsDev report: comment 6411228574 on #25653;
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | draft, base main, Part of #25653 (slice 2 of the run-time half), Clause-②: no (narrowing: …) and Metadata-protocol: none byte-identical in body and changeset, patch on @objectstack/objectql and @objectstack/plugin-security with the BREAKING banner and ! on the title. |
| Files (10, +412/−64) | objectql hook binder / wrappers / run-as provenance and engine.ts, the plugin-security refusal's message, one runtime integration suite, one docs line, one changeset. No packages/spec/**; not governed (check-governed-merges --pr 25982, 476 changed lines). |
| Fix | the unchanged elevation predicate now also sees an app-authored hook's inherited system context, its sudo() with every derived child, and an action reached through ObjectRepository.execute(); platform code (registerHook without meta, audit builtins, platform sudo()) keeps its writes. The app-authored mark is set by the binder, not by anything an author writes. |
| Evidence | red first, two ablations exactly on the targeted cases, gates reconciled, dogfood green (report); every check-run on the head green or an expected skip. |
| Carried | the two out-of-scope readings (host code calling executeAction directly; a platform hook nested under a marked context) are accepted as host-code class by the review; the module-local mark symbol is non-blocking. Neither PR body nor report carries a reproduction. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready + auto-merge through the relay. The card stays open (Part of). |
Generated by Claude Code
Requested by Leehom · project thread
Part of #25653
Clause-②: no (narrowing: an app-authored hook's inherited or sudo() elevation, and an action reached through ObjectRepository.execute(), are refused the four identity objects at run time, as a runAs: 'system' hook already is)
Metadata-protocol: none
Slice 2 of the card's run-time half. It closes the inherited-context path that slice 1's report (#25954) names among its findings: an app-authored hook that declares no
runAsand runs under a system context it inherits. It also closes the two neighbouring elevations the order asked to be measured, the in-processctx.api.sudo()andObjectRepository.execute().What changed
Slice 1 refuses writes to the four identity objects from a context with
isSystemand either aflowRunIdor the app-automation mark. The mark was set only on a hook'srunAs: 'system'api and on an action body's context. Three other app-authored elevations carriedisSystemwith neither, so the refusal did not see them.sys_user_position,sys_user_permission_set,sys_invitation,sys_memberrunAs, system context inherited (body or handler)ctx.api.sudo(), under anyrunAs, also insidectx.api.transactionctx.api.object(x).execute(y)runAs, user's context inheritedengine.registerHook, no metadata binding), its api and itssudo()sudo(), bare{ isSystem: true }writersThe predicate that tells app-authored hooks from platform hooks is the hook binder. A hook is app-authored when it is bound through
bindHooksToEngine. That is the one door metadata hooks come through: stack and packagehooks:, runtime-authored hooks, and app artifacts. It is also the set the engine already treats as automations: the binder registers each hook withmeta, andskipAutomationsskips exactly those. A platform hook is registered in code withengine.registerHookand nometa. The audit builtins, security, and every plugin's own hooks are registered this way, and their path does not change.objectqlhook-binder.ts: the binder passesappAuthored: truetowrapDeclarativeHook.objectqlhook-wrappers.ts: whenappAuthoredis set, the wrapper derives the hook'sctx.apithrough a new key before it appliesrunAs, so everyrunAskeeps the provenance. The step is synchronous, so an'inherit'hook gains no extraawait. An async (fire-and-forget) hook runs on the detached copy whenever an api was installed. A platform wrap (no flag) still hands the api through by reference.objectqlhook-run-as.ts:APP_AUTHORED_HOOK_APIandderiveAppAuthoredHookApi. The key is a symbol and is not exported from the package entry, the same posture asSEED_USER_GRANTS.objectqlScopedContext:[APP_AUTHORED_HOOK_API]()marks an inheritedisSystemcontext'hook'when it has noflowRunIdand no mark.appAuthoredflag rides every derived child (transaction,beginTransaction,withRunAs, the grant seeding) through onederive()helper.sudo()marks'hook'only on an app-authored api. Platformsudo()is byte-identical.objectqlObjectRepository.execute()marks its elevation'action', asbuildActionExecutionContextalready does for the REST and MCP action doors. The census found no caller ofexecute()in objectstack,examples/,apps/or HotCRM.plugin-security: no predicate change. The hook's subject and prescription now name the three ways a hook is elevated, and the module doc covers them.content/docs/permissions/system-context.mdxrow 1b names the new producers and anchors the newisSystemread (engine.ts#inheritsElevation);check-system-context-censusexits 0.objectqlandplugin-securitypatch,Metadata-protocol: none, BREAKING banner, ADR-0087not-required (no-migration-prescription).Measured before any code (the order's question)
On
main59d9181e, with a realObjectQL, the SQL driver,SecurityPluginand QuickJS:ctx.api.sudo()? Yes, from an in-process handler. Its writes carriedisSystemand no mark. A sandboxed body does not reach it, because the sandbox api exposes neithersudonorexecute.ObjectRepository.execute()? Yes, from an in-process handler, throughctx.api.object(x).execute(y). The action it reached ran with an elevated, unmarkedapi.sudo()was already refused, because the action context's mark rides the spread.Both reaches were measured as reachable, so both are fixed here.
Census: no hook, action body or host handler in objectstack (
packages/,examples/,apps/) or HotCRM writes the four objects, callssudo()from app code, or callsexecute(). Nothing shipped starts being refused.Tests
runtime/src/app-automation-identity-write-refusal.integration.test.ts: 50 new cases beside slice 1's 50. They use the same real stack and read the store back past the middleware.runAs(body and handler) under a platform system write, × four objects × insert/update/delete, is refused with code, status, subject and prescription. The store does not move and the triggering write aborts. 24 cases.sudo()under a user's write, × four × three verbs, is refused. One more case covers it insidectx.api.transactionand underrunAs: 'user'. 13 cases.execute()is refused on each of the four objects. 4 cases.sudo(), and the declarative wrapper with no binding all write each of the four objects.cb6bb395, before the fix): 41 refusal cases failed and 59 passed (slice 1's 50 plus the 9 controls).objectql/src/hook-run-as.test.ts: 4 new cases.sudo()is marked under everyrunAs.plugin-securityrefusal test: the hook row's message pin follows the new wording.Pins describe behaviour only.
Verification
Head
d0489d5e. Its tree is identical to7d29c9ab, where every reading below was taken: the commit messages were reworded before the first push and the files did not change. All readings ran under the verify lock.pnpm test, each with VERDICTcommand-exit 0:4a7683e2. Only objectql and plugin-security test files, the docs page and the changeset changed after it. Runtime reads both of those packages fromdist/, anddist/was built from the fix commit.pnpm typecheckin objectql, plugin-security and runtime exits 0 each, includingcheck:test-typecheck.eslint --no-inline-configon the 8 changed TypeScript files exits 0.check-system-context-censusexits 0.dispatch-gates --commandson the real diff, 106 families): all 106 exit 0 on the first pass. Reconciled with--ran: 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN.scripts/ablation-replace.mjs): one behaviour per run, each marker proven in objectqldist/byablation-dist-preflightbefore the readings.appAuthored: trueforced tofalse). Runtime pin: 37 failed, 63 passed. The 37 are exactly the inherited-context andsudo()cases; theexecute()cases, slice 1's cases and every control stay green. objectql pin: 1 failed (the binder case), 28 passed.execute()'s mark ('action'forced to none). Runtime pin: 4 failed, 96 passed. The 4 are exactly theexecute()cases. objectql pin: 29 passed.git diff HEADis empty andgit status --porcelainis clean. After the rebuild, both markers are absent fromdist/(preflight--absentexits 0) and the runtime pin is 100/100 again.(narrowing)arm): it was running at this tree when 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
ScopedContextin objectql, plusObjectRepository.execute()as the order's measurement asked. The refusal module changed its wording only. Nopackages/spec/**, no new error code, and the predicate is unchanged.flowRunIdcontext is still answered as the flow's, and a context that already carries a mark keeps it.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