Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adr/0066-unified-authorization-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Extend `requiredPermissions` (today only on `App`/nav, **[existing]**) to **Obje
### D4 — Dual-surface action gates [new]
An action declaring `requiredPermissions` is enforced in **one place, two surfaces**: the ActionRunner hides/disables it in the UI **and** the server rejects the call when the caller lacks the capability. Removes the "UI-gated but server-open" footgun (and the inverse). Server enforcement is the source of truth; UI gating is derived from the same declaration.

**Scope of the server half (clarified 2026-07, framework#3923; api clause amended 2026-10-29, Ruling-ref: 6499781492).** "The server rejects the call" means the PLATFORM ACTION DOOR — `POST /api/v1/actions/<object>/<action>` and the MCP `run_action` bridge, which enforce one gate (`packages/runtime/src/action-execution.ts#actionPermissionError`) and dispatch `type: 'script'`, `type: 'flow'` and the declarative `operation: 'update'` write. A `type: 'api'` action whose `target` is a same-origin platform path — the declared `target`, before interpolation, starts with exactly one `/` and goes on past it (`/` alone, `//host` and `/\host` are not), as the server card's `isSameOriginApiTarget` judges it (PR #27649; once the symbol is on `main`, card #27694 rewrites this citation into a symbol anchor on `isSameOriginApiTarget` in `packages/runtime/src/action-execution.ts`; the door holds the interpolated path to the same test before it forwards) — is dispatched by that door too: the route and the bridge run the anonymous baseline, this D4 gate and the ADR-0126 §8 activation consult, then forward the call in-process to the declared `target` as the caller — the request assembled from the declaration (`method`, `recordIdParam`, `bodyShape`, `bodyExtra`, the placeholders in `target`), the caller's cookies and auth headers carried. The target's `Set-Cookie` is returned on the REST route's response; the MCP bridge relays only the bearer-lane rotation headers (`set-auth-token`, `set-admin-session-token`), and no cookie value enters a tool result. The door gates the calls it dispatches, and only those: the `target` endpoint stays directly reachable — nothing routes a direct call to it through the door — so it MUST still enforce the declared capability itself, and the door's gate is the added check: defence in depth, not a replacement. An api action whose `target` is not a same-origin platform path stays browser-only and ungoverned: the door does not dispatch it, the MCP bridge does not list it, and the activation write door (`POST /api/v1/actions/_activation/<object>/<action>`, `packages/runtime/src/domains/actions.ts#handleActionActivationWrite`) refuses to flip it, with a reason, rather than record a switch nothing consults. The UI half is correspondingly *every* action surface, not just the engine: `record_header`/`record_more` (`page:header`) and `list_item` (the grid row menu) filter their own action lists and were fixed to apply the same gate (objectui), alongside `ActionEngine.getActionsForLocation`. All of them fail OPEN when the client does not know the caller's capabilities — unknown is not denied.
**Scope of the server half (clarified 2026-07, framework#3923; api clause amended 2026-10-29, Ruling-ref: 6499781492).** "The server rejects the call" means the PLATFORM ACTION DOOR — `POST /api/v1/actions/<object>/<action>` and the MCP `run_action` bridge, which enforce one gate (`packages/runtime/src/action-execution.ts#actionPermissionError`) and dispatch `type: 'script'`, `type: 'flow'` and the declarative `operation: 'update'` write. A `type: 'api'` action whose `target` is a same-origin platform path — the declared `target`, before interpolation, starts with exactly one `/` and goes on past it (`/` alone, `//host` and `/\host` are not), as `packages/runtime/src/action-execution.ts#isSameOriginApiTarget` judges it (PR #27649; the door holds the interpolated path to the same test before it forwards) — is dispatched by that door too: the route and the bridge run the anonymous baseline, this D4 gate and the ADR-0126 §8 activation consult, then forward the call in-process to the declared `target` as the caller — the request assembled from the declaration (`method`, `recordIdParam`, `bodyShape`, `bodyExtra`, the placeholders in `target`), the caller's cookies and auth headers carried. The target's `Set-Cookie` is returned on the REST route's response; the MCP bridge relays only the bearer-lane rotation headers (`set-auth-token`, `set-admin-session-token`), and no cookie value enters a tool result. The door gates the calls it dispatches, and only those: the `target` endpoint stays directly reachable — nothing routes a direct call to it through the door — so it MUST still enforce the declared capability itself, and the door's gate is the added check: defence in depth, not a replacement. An api action whose `target` is not a same-origin platform path stays browser-only and ungoverned: the door does not dispatch it, the MCP bridge does not list it, and the activation write door (`POST /api/v1/actions/_activation/<object>/<action>`, `packages/runtime/src/domains/actions.ts#handleActionActivationWrite`) refuses to flip it, with a reason, rather than record a switch nothing consults. The UI half is correspondingly *every* action surface, not just the engine: `record_header`/`record_more` (`page:header`) and `list_item` (the grid row menu) filter their own action lists and were fixed to apply the same gate (objectui), alongside `ActionEngine.getActionsForLocation`. All of them fail OPEN when the client does not know the caller's capabilities — unknown is not denied.

> **Amended (2026-10-29) — the api clause above reverses the browser-only half of the 2026-07 clarification (ruling of record 6499781492 on #27506; Prime Directive #13: a recorded decision is reversed by an ADR edit, never by a changeset).** As clarified on 2026-07 (framework#3923) this paragraph read: "A `type: 'api'` action whose `target` is a self-authored endpoint is fetched by the browser directly and never passes through that route, so the declaration has no server-side consumer there; that endpoint must re-check the capability itself." Its first half — fetched by the browser directly, never passes through that route, no server-side consumer — recorded the gap as the rule and is superseded, not deleted. Its last clause stands: the endpoint must still re-check the capability itself, now beside the door's gate (the paragraph above). Under the maintainer's design goal, a capability a human reaches by clicking and an agent cannot reach through the action surface is a defect, and the unhonoured ADR-0126 switch on a `type: 'api'` action is one symptom of it. The maintainer, verbatim and untranslated: 「我们的平台设计目标是 agent 和人类使用同一套系统」 / 「同意」. The paragraph above also drops `modal` from the dispatched types: the door answers a `modal` action 400 as a client-side type (`packages/runtime/src/action-execution.ts#headlessActionTypeError`), and it has since gained the declarative update. **Landing, in three steps.** This record is step 1. Step 2 is the server card #27506: the door and the bridge accept a declared same-origin `type: 'api'` action, `packages/runtime/src/action-execution.ts#isHeadlessInvokableAction` admits it (and the `list_actions` filter in `packages/runtime/src/domains/mcp.ts#buildMcpBridge` is widened beside it), and the activation write door refuses an off-origin one. That does not put every api action in front of an agent through MCP: `list_actions` and `run_action` keep the `ai.exposed` opt-in and the fail-closed `sys_*` guard (`packages/runtime/src/domains/mcp.ts#buildMcpBridge` for the listing, `packages/runtime/src/action-execution.ts#invokeBusinessAction` for the call, both through `packages/runtime/src/action-execution.ts#isSystemObjectName`), so `sys_user.ban_user` and the other api actions on platform `sys_*` objects stay off MCP and reach an agent through REST `POST /api/v1/actions/<object>/<action>` — a gap this amendment records and does not close; card #27681 carries it. Step 3 is the ui card #27611: the console's api handlers send a same-origin click to the door instead of fetching `target`. Until step 2 lands the door still refuses `type: 'api'` — `packages/runtime/src/action-execution.ts#isHeadlessInvokableAction` admits `script`, `flow` and the declarative update only, `packages/runtime/src/action-execution.ts#SERVER_DISPATCHED_ACTION_TYPES` is `script`, `flow`, and `packages/runtime/src/action-execution.ts#headlessActionTypeError` answers an api action 400 — so no agent can invoke a `type: 'api'` action today, and the console still fetches `target` directly.

Expand Down
10 changes: 4 additions & 6 deletions packages/spec/src/ui/action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1611,12 +1611,10 @@ const actionObject = () => strictObject({
* activation switch refuses to flip it, and nothing checks this declaration
* server-side.
*
* Landing, as of 2026-10-29: ADR-0066 records the decision; the server half
* is the ruling's step 2 (#27506). Until it lands the door still refuses
* `type: 'api'` (`isHeadlessInvokableAction` admits `script`, `flow` and the
* declarative update only) and the console fetches `target` directly. Once
* the server half (#27649) lands, card #27694 removes this paragraph's door
* clause; its console clause goes when step 3 (#27611) lands.
* Landing, as of 2026-10-29: ADR-0066 records the decision; the server half,
* the ruling's step 2 (#27506), is on `main` (#27649). Until step 3 (#27611)
* lands the console fetches `target` directly; card #27694 removes this
* paragraph when it does.
*/
requiredPermissions: z.array(z.string()).optional().describe('[ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (`POST /actions/:object/:action` and MCP `run_action`) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a `type: api` action whose declared `target` is a same-origin platform path (exactly one leading `/`, followed by neither `/` nor a backslash, with a path after it) under that door; its `target` endpoint stays directly reachable, so it MUST still enforce this capability itself, the door\'s gate being the added check. A `type: api` action with an off-origin `target` is browser-only and not gated server-side.'),

Expand Down
Loading