Repository navigation
Commit 073f08b
fix(security)!: a hook that runs as system and an action body write no identity assignment (#25954)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQXftB7pSBetnK4NoZD2gaR)_
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:spec` work, pointed to on #6017; the card stays open for it.
## What changed
The #25251 refusal reads `isSystem` AND `flowRunId`. Two other
app-authored elevations carry `isSystem` and no run, so it did not see
them: a hook's `runAs: 'system'` api, and an action body's `ctx.api` /
`ctx.engine`. Each is now derived with a provenance mark, and the one
predicate reads it.
| Context | Governed write to `sys_user_position`,
`sys_user_permission_set`, `sys_invitation`, `sys_member` | Reads of the
four | Writes to other objects |
|---|---|---|---|
| `runAs: 'system'` hook (sandboxed body or in-process handler) |
**refused, PERMISSION_DENIED** (was: admitted past every gate) |
admitted | admitted |
| action body, `ctx.api` or `ctx.engine` | **refused,
PERMISSION_DENIED** (was: admitted past every gate) | admitted |
admitted |
| `runAs: 'system'` flow | refused, word for word as before | admitted |
admitted |
| `runAs: 'user'` hook | not judged here; the ordinary gates judge the
user it acts for | per the user | per the user |
| platform `sudo()`, bare `{ isSystem: true }` writers | admitted,
unchanged (no mark) | admitted | admitted |
- `@objectstack/core`, new `security/app-automation-elevation.ts`: the
mark is a registry symbol (`Symbol.for`, so the ESM and CJS builds
agree) with `markAppAutomationElevation`, `appAutomationElevationOf` and
`carryAppAutomationElevation`. 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 reasoning `operation-private-keys.ts` gives).
- `objectql` `ScopedContext.withRunAs('system')` builds the
`sudo()`-shaped context plus the `'hook'` mark. `sudo()` itself is
untouched.
- `runtime` `buildActionExecutionContext` adds the `'action'` mark;
`buildActionApi`'s malformed-envelope fallback now goes through the same
builder.
- `objectql` `createContext` carries the mark across
`ExecutionContextSchema.parse`. The parse keeps declared keys only, and
an action body's `ctx.api` is built through it, so without the carry the
body's `ctx.api` writes reached the refusal unmarked (measured:
`ctx.engine` was refused and `ctx.api` was not).
- `plugin-security` `flow-system-identity-write-refusal.ts`: the
predicate is now `isSystem` AND (`flowRunId` OR the mark), in a new
`elevationSource` helper. 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 sets `runAs: 'user'`; an action
body has no `runAs`, so its grant moves to a `runAs: 'user'` flow, which
an action of type `flow` runs as the invoking user; invitations and
memberships use the organization endpoints. No second module, no new
error code.
- `content/docs/permissions/system-context.mdx` row 1b covers the
family; its anchor moves to `#elevationSource`, where the `isSystem`
read now sits (`check-system-context-census` exit 0).
- Comment only: the middleware's call-site comment in
`security-plugin-authz-middleware.ts`.
- Changeset: the four packages `patch` (`Metadata-protocol: none`),
BREAKING banner, ADR-0087 `not-required (no-migration-prescription)`.
## Measured before any code (card condition 1)
On `main` `cb0a2fcf`, real `ObjectQL` + SQL driver + `SecurityPlugin` +
QuickJS, a plain member as the trigger or caller:
- a `runAs: 'system'` hook, as an L2 body and as an in-process handler,
wrote each of the four objects (positions and grants with `granted_by`
null);
- an L2 action body wrote each of the four objects;
- an **L1 expression** action body cannot reach `ctx` at all, so it
writes nothing. Per condition 1 that sub-path is reported as a fork, not
fixed;
- control: the same member's direct writes are refused
`PERMISSION_DENIED`.
Census: no hook, action body or host action handler in objectstack
(`packages/`, `examples/`, `apps/`) or HotCRM writes the four objects;
the writers on `main` are platform code with their own system contexts.
Nothing shipped starts being refused.
## Tests
- New
`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 with `code`, `status`, source and
prescription; the store does not move and the triggering write aborts;
- action body `ctx.api` × four × three verbs, and `ctx.engine` per
object: the same;
- controls: the same hook writes another object; the same body reads the
four and writes another object; the hook under `runAs: 'user'` is
refused by the ordinary gates, not by this refusal; a `sudo()` derived
from a user context and a platform system context insert, update and
delete all four.
- **Red first** (commit `7bb83a96`, before the fix): 40 refusal cases
failed, 10 controls passed.
- `plugin-security` refusal test: the predicate for both marks over
every `GOVERNED_OPERATIONS` verb on each object, run-plus-mark answered
as the flow, the mark without `isSystem` not judged, an unknown mark
value read as none. PR1's cases unchanged and green.
- `core` `app-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`: two `toEqual({ 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 test` per package, each VERDICT `command-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 typecheck` in the same four packages: exit 0 each, including
`check:test-typecheck` where the package has it.
- `eslint --no-inline-config` on the 10 changed TypeScript files: exit
0.
- Derived gates (`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`), and `check:skill-examples`,
`check:dual-build-cjs-loads`, `check:i18n` (full `turbo run build`).
- Ablation (`scripts/ablation-replace.mjs`, one behaviour: the
predicate's read of the mark, `elevationSource`), with the marker proven
in `dist/` by `ablation-dist-preflight` before the readings, because the
runtime suite reads plugin-security from `dist/`:
- runtime pin: 40 failed, 10 passed. The 40 are exactly the hook and
action refusal cases; every control stays green.
- plugin-security pin: 48 failed, 47 passed. The 48 are the two marks ×
four objects × six verbs; PR1's flow cases and the controls stay green.
- Restore: blob equals HEAD, `git diff HEAD` empty, rebuilt, marker
absent from `dist/`, both pins green again (50/50, 95/95).
- A first attempt was a no-op: its marker was a comment, which the build
strips, so the preflight refused it before any reading. The second used
a marker that survives the build.
- Full dogfood battery (owed for the `(narrowing)` arm): running at head
`a8b13e57` when this PR was opened; its counts go in the `os-dev-report`
on #25653.
## Acceptance notes
- Claimed surface, declared departures: `packages/core/src/security/`
(new module, barrel export, test), `objectql` `createContext` (the
carry, beside the claimed `withRunAs`), the middleware call-site
comment, and the two pin updates in `action-body-identity.test.ts`. No
`packages/spec/**`, and `sudo()` is unchanged.
- `buildActionExecutionContext` serves 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 third `executeAction` caller)
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 expose `execute`, so only
host code reaches it. Noted, not changed.
- The mark is an undeclared, in-process context key rather than an
`ExecutionContext` field, because this PR may not touch `packages/spec`.
Declaring it there is an option for the spec half; see the report on
#25653.
- Branch is the harness-assigned `claude/project-thread-hg746g`; the
claim comment is the identity record.
---
_Generated by [Claude Code](https://claude.ai/code)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 9ca36b1 commit 073f08b
12 files changed
Lines changed: 738 additions & 24 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- core/src/security
- objectql/src
- plugins/plugin-security/src
- runtime/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
97 | 97 | | |
98 | 98 | | |
99 | 99 | | |
100 | | - | |
| 100 | + | |
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
453 | 453 | | |
454 | 454 | | |
455 | 455 | | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
456 | 467 | | |
457 | 468 | | |
458 | 469 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
159 | 159 | | |
160 | 160 | | |
161 | 161 | | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
162 | 167 | | |
163 | 168 | | |
164 | 169 | | |
| |||
23095 | 23100 | | |
23096 | 23101 | | |
23097 | 23102 | | |
| 23103 | + | |
| 23104 | + | |
| 23105 | + | |
| 23106 | + | |
23098 | 23107 | | |
23099 | | - | |
| 23108 | + | |
23100 | 23109 | | |
23101 | 23110 | | |
23102 | 23111 | | |
| |||
23390 | 23399 | | |
23391 | 23400 | | |
23392 | 23401 | | |
23393 | | - | |
| 23402 | + | |
23394 | 23403 | | |
23395 | 23404 | | |
23396 | 23405 | | |
23397 | 23406 | | |
23398 | 23407 | | |
| 23408 | + | |
| 23409 | + | |
| 23410 | + | |
| 23411 | + | |
| 23412 | + | |
| 23413 | + | |
23399 | 23414 | | |
23400 | 23415 | | |
23401 | 23416 | | |
| |||
23405 | 23420 | | |
23406 | 23421 | | |
23407 | 23422 | | |
23408 | | - | |
| 23423 | + | |
| 23424 | + | |
| 23425 | + | |
| 23426 | + | |
| 23427 | + | |
| 23428 | + | |
23409 | 23429 | | |
23410 | 23430 | | |
23411 | 23431 | | |
| |||
0 commit comments