Skip to content

fix(security)!: an app-authored hook's inherited elevation, its sudo() and an action it reaches by execute() write no identity assignment - #25982

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-hg746g
Oct 26, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-hg746g

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 runAs and runs under a system context it inherits. It also closes the two neighbouring elevations the order asked to be measured, the in-process ctx.api.sudo() and ObjectRepository.execute().

What changed

Slice 1 refuses writes to the four identity objects from a context with isSystem and either a flowRunId or the app-automation mark. The mark was set only on a hook's runAs: 'system' api and on an action body's context. Three other app-authored elevations carried isSystem with neither, so the refusal did not see them.

Context Governed write to sys_user_position, sys_user_permission_set, sys_invitation, sys_member Writes to other objects
app-authored hook, no runAs, system context inherited (body or handler) refused, PERMISSION_DENIED (was: admitted) admitted
app-authored hook's in-process ctx.api.sudo(), under any runAs, also inside ctx.api.transaction refused (was: admitted) admitted
action reached through ctx.api.object(x).execute(y) refused (was: admitted) admitted
app-authored hook, no runAs, user's context inherited not judged here; the ordinary gates judge the user per the user
hook registered in code (engine.registerHook, no metadata binding), its api and its sudo() admitted, unchanged admitted
platform sudo(), bare { isSystem: true } writers admitted, unchanged admitted

The 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 package hooks:, runtime-authored hooks, and app artifacts. It is also the set the engine already treats as automations: the binder registers each hook with meta, and skipAutomations skips exactly those. A platform hook is registered in code with engine.registerHook and no meta. The audit builtins, security, and every plugin's own hooks are registered this way, and their path does not change.

  • objectql hook-binder.ts: the binder passes appAuthored: true to wrapDeclarativeHook.
  • objectql hook-wrappers.ts: when appAuthored is set, the wrapper derives the hook's ctx.api through a new key before it applies runAs, so every runAs keeps the provenance. The step is synchronous, so an 'inherit' hook gains no extra await. 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.
  • objectql hook-run-as.ts: APP_AUTHORED_HOOK_API and deriveAppAuthoredHookApi. The key is a symbol and is not exported from the package entry, the same posture as SEED_USER_GRANTS.
  • objectql ScopedContext:
    • [APP_AUTHORED_HOOK_API]() marks an inherited isSystem context 'hook' when it has no flowRunId and no mark.
    • A private appAuthored flag rides every derived child (transaction, beginTransaction, withRunAs, the grant seeding) through one derive() helper.
    • sudo() marks 'hook' only on an app-authored api. Platform sudo() is byte-identical.
  • objectql ObjectRepository.execute() marks its elevation 'action', as buildActionExecutionContext already does for the REST and MCP action doors. The census found no caller of execute() 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.mdx row 1b names the new producers and anchors the new isSystem read (engine.ts#inheritsElevation); check-system-context-census exits 0.
  • Changeset: objectql and plugin-security patch, Metadata-protocol: none, BREAKING banner, ADR-0087 not-required (no-migration-prescription).

Measured before any code (the order's question)

On main 59d9181e, with a real ObjectQL, the SQL driver, SecurityPlugin and QuickJS:

  • Does app-authored code reach ctx.api.sudo()? Yes, from an in-process handler. Its writes carried isSystem and no mark. A sandboxed body does not reach it, because the sandbox api exposes neither sudo nor execute.
  • Does it reach ObjectRepository.execute()? Yes, from an in-process handler, through ctx.api.object(x).execute(y). The action it reached ran with an elevated, unmarked api.
  • An action host handler's own 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, calls sudo() from app code, or calls execute(). 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.
    • A hook with no 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.
    • A hook's in-process sudo() under a user's write, × four × three verbs, is refused. One more case covers it inside ctx.api.transaction and under runAs: 'user'. 13 cases.
    • An action reached through execute() is refused on each of the four objects. 4 cases.
    • Controls, 9 cases:
      • the inheriting hook still writes another object;
      • under a user's write, the ordinary gates refuse the plain member, and this refusal does not;
      • a hook registered in code, its sudo(), and the declarative wrapper with no binding all write each of the four objects.
    • Red first (commit 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.
    • An inherited system context is marked, but a run's context and a user's are not.
    • sudo() is marked under every runAs.
    • A platform wrap keeps the api by reference and marks nothing.
    • The binder binds hooks as app-authored.
    • The published-face pin now counts two unexported symbol-keyed members.
  • plugin-security refusal test: the hook row's message pin follows the new wording.

Pins describe behaviour only.

Verification

Head d0489d5e. Its tree is identical to 7d29c9ab, 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 VERDICT command-exit 0:
    • objectql: 469 files, 8734 passed;
    • plugin-security: 223 files, 4683 passed, 45 skipped;
    • runtime: 447 files, 7232 passed, 19 skipped;
    • core: 119 files, 2763 passed.
    • Runtime and core ran at the fix commit 4a7683e2. Only objectql and plugin-security test files, the docs page and the changeset changed after it. Runtime reads both of those packages from dist/, and dist/ was built from the fix commit.
  • pnpm typecheck in objectql, plugin-security and runtime exits 0 each, including check:test-typecheck.
  • eslint --no-inline-config on the 8 changed TypeScript files exits 0.
  • check-system-context-census exits 0.
  • Derived gates (dispatch-gates --commands on 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.
  • Ablation (scripts/ablation-replace.mjs): one behaviour per run, each marker proven in objectql dist/ by ablation-dist-preflight before the readings.
    • A, the discriminator (the binder's appAuthored: true forced to false). Runtime pin: 37 failed, 63 passed. The 37 are exactly the inherited-context and sudo() cases; the execute() cases, slice 1's cases and every control stay green. objectql pin: 1 failed (the binder case), 28 passed.
    • B, execute()'s mark ('action' forced to none). Runtime pin: 4 failed, 96 passed. The 4 are exactly the execute() cases. objectql pin: 29 passed.
    • Restore: blob equals HEAD for both files, git diff HEAD is empty and git status --porcelain is clean. After the rebuild, both markers are absent from dist/ (preflight --absent exits 0) and the runtime pin is 100/100 again.
  • Full dogfood battery (owed for the (narrowing) arm): it was running at this tree when this PR was opened. Its counts go in the os-dev-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.

Acceptance notes

  • Claimed surface: the hook wrapper, binder and ScopedContext in objectql, plus ObjectRepository.execute() as the order's measurement asked. The refusal module changed its wording only. No packages/spec/**, no new error code, and the predicate is unchanged.
  • The mark is the slice 1 symbol. A flowRunId context is still answered as the flow's, and a context that already carries a mark keeps it.
  • Branch is the harness-assigned 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

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/plugin-security, touching 13 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-handling-server.mdx (via ScopedContext (symbol, a top-level class))
  • content/docs/automation/hook-bodies.mdx (via ScopedContext (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via ScopedContext (symbol, a top-level class), inheritsElevation (symbol, a const object of class ScopedContext))
What this run could not see
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 29 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 cdd4eea93811522cca145a0f24858def51443671 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 91e761d521fd6dc1a61c94edef74e712b927be7c — the merge of head d0489d5e4513eec99d83dc16dab8737f61bae5f5 into base cdd4eea93811522cca145a0f24858def51443671, 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 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

⚠️ 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 cdd4eea93811522cca145a0f24858def51443671 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d0489d5e4513eec99d83dc16dab8737f61bae5f5
Local-runs: none
Scope: packages/objectql/src/engine.ts, packages/objectql/src/hook-binder.ts, packages/objectql/src/hook-run-as.ts, packages/objectql/src/hook-run-as.test.ts, packages/objectql/src/hook-wrappers.ts, packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts, packages/plugins/plugin-security/src/flow-system-identity-write-refusal.test.ts, packages/runtime/src/app-automation-identity-write-refusal.integration.test.ts + packages/objectql/src/plugin.ts (the second wrapDeclarativeHook door, the audit builtins), packages/runtime/src/app-artifact-handlers.ts (ql.bindHooks, wrapHandler), packages/runtime/src/stored-metadata-reader-seam.ts (deriveThroughSeam), packages/runtime/src/sandbox/body-runner.ts (buildSandboxApiSource, the action-body factory), packages/runtime/src/sandbox/quickjs-runner.ts (the READ and WRITE method lists), packages/runtime/src/action-execution.ts (buildActionExecutionContext, unchanged), packages/core/src/security/app-automation-elevation.ts (unchanged), packages/spec/src/data/hook.zod.ts (HookContext.ql, HookContext.api), AGENTS.md (the semver rule); the prose faces (content/docs/permissions/system-context.mdx, .changeset/25653-app-hook-inherited-elevation-identity-write-refusal.md) read with them. No path is a remainder.

① Derived judgments

Read on the net diff origin/main...origin/pr-25982 (five commits: the red pin cb6bb395a2 alone, the fix 4a7683e2a3, then docs and changeset ec46d1e142, objectql pins 1a96cdaa6e, plugin-security pin d0489d5e45), the cited code on the head, card #25653 (body and all eight comments, with the slice-1 contract review 6408962867 on PR #25954 that the claim's Thread-read names), the PR body and file list, and the check-runs on the head.

CI state at review time. 47 check-runs on d0489d5e: 35 concluded success (TypeScript Type Check, Lint & Repo Gates, Build Core, Build Docs, Test Core 1, 2, 3, 5, 6, 7 and 8 of 8, Type Check · workspace / source gates / consumer gates / debt ledger, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates (which holds check:system-context-census), Lint · CI, release & engine gates, the three scoped lints, Check Changeset, Governed Surface Queue Guard, Spec property liveness, Dogfood Regression Gate, Check Documentation Links, Flag docs affected by code changes, the three claim guards, Auto Label, Check PR Size, filter), 11 skipped (UI matrix stubs and opt-in harnesses), and ONE still in_progress: Test Core (4/8). So every typecheck, lint, changeset and census family has concluded green on the head; one test shard has not. This record judges the diff and the concluded gates; the dev's local counts (objectql 8734, plugin-security 4683, runtime 7232, core 2763 passed; runtime pin 100/100; two ablations each turning exactly its own rows red) are the dev's claim, not a gate verdict.

Accept-set change: three app-authored elevations are now refused. The door is unchanged: the data middleware's one call to assertFlowSystemIdentityWriteAllowed ahead of the isSystem bypass, and the predicate elevationSource (isSystem AND (flowRunId OR the core mark)) is byte-identical to slice 1. What moves is who carries the mark. New producers, each read on the head: (1) ScopedContext[APP_AUTHORED_HOOK_API]() marks 'hook' on a context that is isSystem with no flowRunId and no mark of its own — the hook inherited a platform system write's elevation; a run's context stays the run's, a marked context keeps its mark, a user's context is not elevated and gets none; (2) ScopedContext.sudo() marks 'hook' when the api's private appAuthored flag is set and the elevated copy carries no mark yet, and the flag rides every child the new derive() helper builds (transaction joined and owned, beginTransaction, withRunAs both arms, the 'user' grant seeding); (3) ObjectRepository.execute() marks its { ...this.context, isSystem: true } elevation 'action', the same mark buildActionExecutionContext gives the REST and MCP doors, and hands that context to both executionContext and the api it builds for the handler. Refused set: the six governed verbs on exactly sys_user_position, sys_user_permission_set, sys_invitation, sys_member from any of those contexts; reads of the four and writes to every other object stay admitted (pinned: the inheriting hook still updates qa_note). RIGHT, and the shape the slice-1 ruling (③ item 3) asked for: one predicate, one module, no new error code.

The discriminator: a hook is app-authored when the hook binder bound it. bindHooksToEngine wraps every hook it binds with appAuthored: true (a constant in the binder's own call, not a value read from the hook) and registers it with meta: hook. ObjectQL.registerHook stores meta on the entry; triggerHooks and dispatchUnscopedMultiWriteHooks skip an entry under skipAutomations exactly when entry.meta is set, and opensAfterAbortUnit reads entry.meta the same way. So "bound through the binder" and "the set skipAutomations keys on" are one set by construction, as the PR body says. Platform hooks are registered in code with no meta: the audit, approvals, auth, email and security plugins' engine.registerHook(...) calls carry object, priority, packageId and nothing else. The one other wrapDeclarativeHook caller on the head, packages/objectql/src/plugin.ts (the audit builtins), passes no appAuthored and registers with no meta: the platform wrap the PR body describes, unchanged. The runtime's app artifacts reach the binder through ql.bindHooks (app-artifact-handlers.ts), which is the engine's own proxy to bindHooksToEngine; stack and package hooks: go the same way. One door holds. RIGHT.

Platform code keeps its writes. ScopedContext.appAuthored defaults to false, and only [APP_AUTHORED_HOOK_API]() ever sets it true; buildHookApi, createContext, ObjectRepository.execute()'s own new ScopedContext(...) and every other construction leave it false, so their sudo() is the old { ...ctx, isSystem: true } with no mark. Bare { isSystem: true } writers carry neither a run nor a mark. Pinned end to end on the real stack: a hook registered in code writes each of the four from its inherited system context, from its sudo(), and through the declarative wrapper with no binding (by_inherit, by_sudo, by_wrapped all land); the objectql pin asserts a platform wrap hands the api through by reference (toBe) and marks neither path. RIGHT (card condition 2's pin, condition 4's fourth pin).

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; hookRunAs admits only the three declared values, and each keeps the provenance: 'inherit' derives through the symbol method and marks an inherited elevation; 'user' derives on the app-authored base and derive() keeps the flag, so its sudo() marks; 'system' is marked directly; 'user' with no triggering user yields UnscopedHookApi, which has object and transaction only, both refusing, and no sudo. The wrapper replaces ctx.api before the handler runs and keeps the original only in its own closure for the restore, so neither a handler nor a body sees the pre-derivation api; a fire-and-forget hook runs on a detached copy whenever an api was installed, so the synchronous restore cannot hand it back. A sandboxed body gets only ctx.api built from engineCtx.api (the derived one), with the fixed READ and WRITE lists plus transaction, and arguments cross as JSON, which cannot spell either symbol. The runtime's stored-metadata seam forwards sudo, withRunAs, transaction and beginTransaction by calling the wrapped api's own method and wrapping what it returns, so a seam over a marked api derives marked children and never rebuilds a context. The mark only ever narrows (its sole reader refuses and admits nothing). RIGHT.

Slice-1 behaviour is unchanged. elevationSource is untouched; 'flow' still wins whenever flowRunId is set, so the system-flow refusal text stays byte-identical; withRunAs('system') still marks 'hook' (now through derive(), same envelope); buildActionExecutionContext and the core module did not change; the createContext carry is still the one parse site (engine.ts:22721). The hook arm's SUBJECT and ASSIGNMENT_PRESCRIPTION strings widened to name the three ways a hook is elevated; the flow and action strings and MEMBERSHIP_PRESCRIPTION are unchanged, and slice 1's runtime expectRefused regexes still match the widened text. RIGHT.

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 APP_AUTHORED_HOOK_API is not exported from the package entry: a deliberate, honest update. The runtime integration file is additive apart from one import; slice 1's fifty cases and its CONTROL blocks are untouched. The new pin was committed red first (cb6bb395a2 carries the test file alone). RIGHT.

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 PERMISSION_DENIED, 403, the hook subject and the per-object prescription, the store unchanged and the triggering write aborted (24 cases). sudo(): four objects by three verbs under a user's write, plus inside ctx.api.transaction and under runAs: 'user' (13). execute(): insert on each of the four (4). Controls: the inheriting hook still writes another object; under a user's write the ordinary gates refuse the plain member and the elevation refusal does not (not.toMatch(/governed identity writes/)); the code-registered hook family lands (9). transfer, restore and purge have no engine verb and stay covered at the predicate in plugin-security. RIGHT (card condition 4, first and second pins).

Routes that remain unmarked, by the ruling's placement. (a) ObjectQL.executeAction called directly by host code hands the handler whatever context the caller built (the report's first finding). (b) HookContext.ql hands an in-process handler the engine itself, whose doors take a caller-supplied context; a context built there is the host's own, unmarked, and is admitted exactly as platform code's is. The sandbox context does not carry ql. Both are host code constructing its own system context, the class slice 1 placed outside the ruled sites; neither is a defect of this diff. (c) APP_AUTHORED_HOOK_API is a module-local Symbol(), not Symbol.for, the same posture as SEED_USER_GRANTS: a bindHooksToEngine imported from a different build than the engine's would make deriveAppAuthoredHookApi return the api as is, which here fails open to an unmarked api rather than to a narrower one. The runtime's door is ql.bindHooks on the instance, which cannot mismatch, and the dual-build case has no caller on the head. Noted; non-blocking.

Docs census row 1b now names the inherited elevation, sudo() and execute() as producers and anchors engine.ts#inheritsElevation, which resolves to the const inheritsElevation declaration inside [APP_AUTHORED_HOOK_API](), the one new isSystem read; Lint · repo & tooling gates (the census gate) concluded success. RIGHT.

Nothing shipped starts being refused. On the head, no caller of ObjectRepository.execute() exists in packages/, apps/ or examples/ (every .execute( hit is a driver, raw-SQL or flow-engine method); slice 1's census of the four objects in app hooks and HotCRM stands, and this slice adds no object to the refused set. The hooks-calling-sudo() half of the census is the dev's claim.

② Semver level

  • The changeset .changeset/25653-app-hook-inherited-elevation-identity-write-refusal.md bumps @objectstack/objectql and @objectstack/plugin-security at patch: exactly the two packages the diff moves outside tests and prose (runtime changed a test file only; core did not change). RIGHT.
  • 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) is byte-identical between the PR body and the changeset, and Metadata-protocol: none is on both. The claim 6409338002 carries the seat's shorthand of the same arm. The arm is right: a served-set narrowing (a write once admitted is now refused), BREAKING by rule; none is right because nothing an author can write changes at save time (the save-time refusal and its ADR-0087 D3 entry stay with the domain:spec half, pointer on [PM seat] domain:spec — 🟢 os-support-ai · 由 os-support-ai 账户的项目负责 #6017).
  • Carriers: the PR title is fix(security)!:; the changeset carries the BREAKING banner naming the audience (an app-authored hook that writes the four while elevated, census none) and exactly one ADR-0087 marker, not-required (no-migration-prescription), in the gate's vocabulary; no authorable key is removed or renamed, so there is no migration to carry. Check Changeset and Governed Surface Queue Guard concluded success.
  • Level: by AGENTS.md "the level follows the kind alone ... none takes patch; a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition". patch with the banner and the disposition is exactly that. The face: no governed-surface path is in the file list; the (narrowing) arm decides, so the review is owed at CONTRACT_REVIEW_TIER on the narrowing face.

③ Boundary flags

Dev report: comment 6411228574 on #25653; status: done, premise_still_valid: true, open_questions: [].

  1. The slice-1 escalation (6408962867 ③ item 3). It asked that the inherited-elevation finding, the execute() third-caller note and the in-process sudo() route be filed as a card or put to the maintainer before the card closes. The claim's Ruling-ref records the maintainer's answer (a security finding is dispatched for a fix directly, no new card, no advisory), and this slice closes all three named routes. Consistent; the escalation is discharged.
  2. Report finding 1: ObjectQL.executeAction called directly by host code. Confirmed by reading: executeAction looks the handler up and calls it with the context it was given. Only host code reaches it; the sandbox exposes no execute. Accepted as outside the ruled sites, with HookContext.ql added to the same class in ①. Carrier stays the seat; nothing blocks.
  3. Report finding 2: a platform hook nested under an app-authored hook's marked context is refused too. Confirmed by construction: buildHookApi(opCtx.context) wraps the nested write's own context, and the mark rides that spread exactly as flowRunId does, the posture slice 1 accepted for runAs: 'system' hooks. Census found no platform hook writing the four through its inherited api. Accepted.
  4. Claimed surface and deviations. The claim lists slice 1's surface plus "the objectql hook derivation"; hook-binder.ts, hook-wrappers.ts and hook-run-as.ts are that derivation, and ObjectRepository.execute() is the measurement the order asked for and the PR body names in Acceptance notes. No packages/spec/** touched, no new error code, the predicate unchanged, sudo() on platform code byte-identical. The dev's "tree identical to 7d29c9ab" cannot be checked here (that commit is not on the fetched ref); it is the dev's claim and does not matter to the verdict, which is on the head. The runtime and core suites ran at the fix commit 4a7683e2: verified that only two test files, the docs page and the changeset changed between it and the head.
  5. Slice-1 ③ item 1 (symbol vs declared field). Not reopened by the report. This slice adds a second, objectql-private symbol with the same posture; the honest cost named in slice 1 (a future producer that rebuilds a context by parse or key-pick drops the mark fail-open) now also covers a derivation reached through the wrong build (① item c). Still the spec half's question; non-blocking.
  6. Slice-1 ③ item 4 disclosure gap. This changeset's audience line names app-authored hooks and not a host-code action handler dispatched by an action door; the gap carried from slice 1 stays open for the spec half or a follow-up. Non-blocking.
  7. No reproduction. The PR body's "Measured before any code" names which doors an in-process handler can reach, in the card's own words; it gives no steps, no payload and no body text. The report is the same. The test file is the ruled pin. Neither carries a reproduction, so there is no FAIL reason here.
  8. Gates not yet concluded. Test Core (4/8) was in_progress when this record was rendered. The verdict is on the diff and the concluded gates; the owning seat does not queue until every check is green, and a red in that shard reopens this record on the same head.
  9. Remainder. Part of #25653 (the Part-of guard concluded success); the card stays open for the save-time refusal in domain:spec. Consistent with the claim and with slice 1.

Implemented-by: claude/project-thread-hg746g
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 26, 2026 08:19
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 26, 2026 08:19
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-26T08:20Z

Head d0489d5e45, report 6411228574 (on #25653), claim 6409338002 (slice 2), contract review 6411402965 (PASS, narrowing face).

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

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants