From 1b689744e58c234db6b08ea2c646db9be88c4591 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Wed, 5 Aug 2026 23:35:27 -0600 Subject: [PATCH 01/17] docs: spec for Tier-3 supervised/four-eyes split + web approvals inbox Co-Authored-By: Claude Fable 5 --- ...tier3-supervised-four-eyes-split-design.md | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md diff --git a/docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md b/docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md new file mode 100644 index 000000000..18895a92d --- /dev/null +++ b/docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md @@ -0,0 +1,248 @@ +# Tier-3 Supervised / Four-Eyes Split + Web Approvals Inbox — Design + +**Date:** 2026-08-05 +**Status:** Approved (Todd), Codex xhigh advisor review incorporated +**Supersedes/extends:** `2026-07-18-action-intents-approval-layer-design.md` (which assumed a web approvals queue that was never built), `2026-07-27-tier3-plan-mode-approval-parity-design.md` + +## 1. Problem + +Tier-3 AI tool calls create durable action intents fanned out to every holder of +`approvals:decide` **excluding the requester** whenever any other approver +exists (four-eyes). In practice: + +- The only proactive notification is an Expo push to the mobile app, which is + not publicly distributable yet. Self-hosted shops have no way to receive it. +- There is **no web approvals surface at all** — `/approvals` 404s. The i18n + copy points at an "Approvals area" that does not exist. +- `CHAT_EXPIRY_MS` (5 min) equals the SDK approval wait budget, so a chat + intent approved after the turn times out is already expired — durable late + release effectively never fires for chat. +- Consequence: for any org with ≥2 admins and no mobile app, **every Tier-3 + action is undecidable and expires**. Reported by a self-hosted customer + 2026-08-05. + +Deeper model error: the current design treats the requesting human as the +actor, demanding a *different* human approve. The correct model: **the AI is +the actor; the requesting human is the approver.** A technician doing regular +work on a PC must not need a second person; a second person is only warranted +for a small set of high-stakes actions. + +## 2. Approval model + +Two approval scopes within tier 3 (the numeric tier stays 3; **tier 4 keeps +its existing meaning: blocked** — `aiGuardrails.ts` blocklist. No renumbering): + +| Scope | Who decides | Ceremony | Fan-out | +|---|---|---|---| +| `supervised` (default for tier 3) | The requester | Plain Approve/Deny click in chat | Single approval row, owned by the requester | +| `four_eyes` (explicit list) | Any `approvals:decide` holder **other than** the requester | Existing WebAuthn-capable decide flow (web inbox / mobile) | Rows for all eligible approvers, requester excluded | + +- `supervised` is gated on nothing new: the requester already passed the + tool's RBAC check (`TOOL_PERMISSIONS`) — if your role lets you do it by + hand, you can approve the AI doing it. Durable intent + `ai_tool_executions` + audit row are retained; audit records `approvalMethod: 'supervised_self'`. +- WebAuthn is **not intrinsically required** for supervised, but the design + must not forbid it: a later partner assurance policy may escalate supervised + approvals to step-up (forward-compatible hook, out of scope for v1). +- **Sole-operator fallback stays** for `four_eyes`: when no *other* eligible + approver exists, the requester's row is fanned to them and requires WebAuthn + L3 (unchanged current behavior). +- Session `approvalMode` (per_step / auto_approve / plans) is orthogonal and + unchanged; supervised approval replaces only the four-eyes fan-out, not the + Tier-2 machinery. +- **MCP: unchanged.** Both scopes fail closed over MCP + (`MCP_APPROVAL_REQUIRED`), preserving the 2026-08-02 ruling. + +## 3. Classification + +### 3.1 Mechanism + +- New guardrails tables: `TIER3_FOUR_EYES_ACTIONS: Record` + and `TIER3_FOUR_EYES_TOOLS: Set` (whole-tool rule — required because + `s1_isolate_device` discriminates on a boolean `isolate`, not a string + `action`; whole-tool entries also cover single-purpose tools). +- `checkGuardrails` returns `approvalScope: 'supervised' | 'four_eyes'` + alongside `tier`. Unclassified tier-3 surfaces default to **`four_eyes`** + (fail-safe), but: +- **Exhaustiveness contract test**: iterate the tool registry; every tool or + per-action pair whose effective tier is 3 MUST appear in exactly one of + `TIER3_SUPERVISED_*` / `TIER3_FOUR_EYES_*` explicit classifications. CI + fails on any unclassified surface, so the fail-safe default can never be + silently relied on. (Pattern: `aiGuardrails.readonly.contract.test.ts`.) + +### 3.2 Starting four-eyes set + +Everything currently tier 3 becomes `supervised` **except**: + +- **Financial / externally binding:** `manage_invoices` issue, record_payment, + void_payment; `manage_contracts` activate, cancel; `manage_quotes` send. +- **Tenant shape:** `manage_organizations` create_org; update_org **status + changes only** (rename/plain field edits stay supervised — handler splits + the action); `manage_tickets` move_org. +- **Identity / account control (M365 + Google):** password and 2SV resets, + mail forwarding/delegates, mailbox permissions, user offboarding/disable, + device wipe. (Absent from the original proposal; adopted from advisor + review — these act on human identities, not devices.) +- **Destroys or rewinds state:** `manage_hyperv_checkpoints` delete, apply; + `restore_as_vm` and DR-plan executions; `manage_patches` rollback; + snapshot/database restore tools. +- **Surveillance-grade access:** computer control and unattended remote + session creation. +- **Containment release:** S1 unisolate and threat rollback. (Isolate and + quarantine stay **supervised** — urgent protective containment must not + wait on a colleague; they also carry S1-side MFA.) + +Explicitly supervised (the customer's "regular work on a PC"): +`execute_command`, `run_script`, `file_operations` read/write/delete/mkdir/ +rename, `registry_operations`, `manage_services` start/stop/restart, +`manage_processes` kill, `manage_patches` install, scheduled tasks, startup +items, disk cleanup, agent upgrades, backup triggers, policy/deployment +mutations, monitor/group/automation management. + +Deferred (v2, recorded as follow-ups): dynamic escalation to four_eyes by +target count / fleet percentage / protected registry paths or services; +partner-configurable escalation list (additive on the same enforcement +point); partner policy requiring step-up on supervised. + +## 4. Intent layer changes + +### 4.1 Fan-out (`intentService.ts`, `intentApprovers.ts`) + +- `createIntent` receives `approvalScope` from guardrails and persists it on + the intent (new immutable columns: `approval_scope`, + `classification_version`). Live pre-migration intents backfill as + `four_eyes` / version 0. +- `supervised`: single approval row for the requester. No push fan-out. +- `four_eyes`: current fan-out, **now filtered by `users.status = 'active'`** + — today disabled/invited users count as eligible approvers, inflating + four-eyes and suppressing the sole-operator fallback (confirmed bug; fix + ships with this work and gets its own test). +- Expiry split (advisor-confirmed trap: single `expires_at` reaps intents + approved at 59:59 before the worker claims them): + - `approval_expires_at`: supervised chat 5 min (unchanged UX); + four_eyes chat **60 min**; MCP 24 h (unchanged). + - Execution lease (`release_by`): stamped atomically when an approval wins; + bounded (minutes). Reaper expires on `approval_expires_at` for pending + and `release_by` for approved. +- **Content pinning for four_eyes** (TOCTOU, advisor-confirmed): arguments + bind mutable references (script body, quote/invoice contents, org state + resolve at execution). Four-eyes intents pin an effect digest at creation — + script content hash, quote/invoice revision, target state/version — and the + release worker revalidates; any drift fails the release with + `content_changed`. Supervised intents skip pinning (requester approves + within the same 5-minute window they asked in). +- **Durable-executable contract**: some tools are `session_required` in the + release worker. Contract test: every four_eyes-classified tool MUST be + durably executable (or explicitly carved out with a session-window-only + expiry). + +### 4.2 Decide path (`routes/approvals.ts`) + +- Supervised, requester-owned row: approve/deny with **no assertion**; the + handler verifies `approval_scope = 'supervised'` AND + `intent.requestedByUserId = userId` AND live RBAC for the underlying tool + action. Everything else keeps the existing assurance gates (four_eyes + approver L1+; sole-operator self-approve L3). +- **Atomic decide** (advisor-confirmed defect): approval-row CAS, intent + transition, sibling expiry, outbox insert, and audit projection move into + one transaction; the endpoint returns success only if the intent transition + committed. The existing integration test asserting HTTP 200 with a + still-pending intent after fault injection is updated to assert rollback. + `report-suspicious` gets the same treatment. +- Live authorization on reads (advisor-confirmed): `GET /pending` joins the + intent, returns only rows whose intent is still `pending_approval`, and + re-checks current org access + `approvals:decide` (four_eyes) or requester + identity (supervised). Add pagination and a count-only endpoint for the + badge. A demoted approver must stop seeing request arguments immediately. +- Mount path: expose the same router at a transport-neutral path + (`/api/v1/approvals`), keeping `/api/v1/mobile/approvals` as an alias for + shipped mobile clients. + +## 5. Web approvals inbox + +- New page `apps/web/src/pages/approvals/index.astro` + `ApprovalsPage` + island: pending list (grouped requester/tool/target/org, countdown), decide + buttons, report-suspicious, decided-history tab (requester sees own intents + + outcomes, covering late results). +- Four-eyes decide reuses `lib/intentApprovals.ts` (WebAuthn ceremony + + fallback). Supervised rows normally never appear here (decided in chat), + but if opened, render with plain confirm. +- Sidebar entry + badge: `badgeKind` pattern from Deletion Requests + (`Sidebar.tsx`), count from the new count endpoint, `99+` clamp. + Visible to users holding `approvals:decide` **or** having ≥1 pending row + (supervised requesters must be able to reach their own history — do not + hide behind `approvals:decide` alone). +- Register in `lib/routeScope.ts`; add locale keys to every locale (parity + suites `localeParity.test.ts` fail otherwise). +- In-chat four-eyes card: passive waiting state with the real + `approval_expires_at` countdown; on late execution, the session shows the + result when reopened (worker already persists it; the history tab is the + guaranteed surface). + +## 6. Notifications + +- **Web:** new event published on intent lifecycle (created / decided / + expired) as an **IDs-only invalidation hint** — `{intentId, status}`; + never arguments or summaries. Advisor-confirmed constraints: + - Event name must satisfy the eventWs subscription regex (first namespace + segment cannot contain `_`): use `approval.intent.updated`. + - New first-class `audienceUserId` on the publish path: dispatcher filters + by `ClientEntry.userId` (today informational only), the event is + **excluded from webhook and plugin `*` fan-out and the global channel** + (a WS-only filter is not a privacy boundary), and it bypasses the + site-filter drop (no `siteId`). + - Client treats events as refetch triggers: fetch count/list after + subscribe-ack, on reconnect, on focus, and on every lifecycle event. +- **Mobile:** existing Expo push unchanged (now correctly scoped to + four_eyes intents only — supervised intents no longer push). +- **No email in v1** (revisit if wait-time data shows approvers miss + requests). + +## 7. Copy + +- `ai.json` `pendingApproverDescription` → "This action needs approval by + another administrator — they can approve it from the Approvals page or the + Breeze mobile app." (and the page now exists). +- Supervised card copy: plain confirm language ("Approve this action"), no + mobile-app mention. +- Sweep `settings.json` / `common.json` approver-device strings for + mobile-app-only framing where the web inbox is now the primary surface. + +## 8. Testing contracts + +- Guardrails: exhaustive classification contract test (§3.1); scope + regression tests for the starting set; `s1_isolate_device` whole-tool rule. +- Intent service: users.status filter; supervised single-row fan-out; + sole-operator preserved under four_eyes; expiry/lease split (approval at + deadline-minus-ε executes; lease overrun fails); content-pin drift fails + release; idempotent replay within the 1-h window returns the existing + intent. +- Decide route: atomicity fault-injection (rollback, not 200+pending); + supervised requester plain decide; four_eyes requester 403; demoted + approver loses read access; duplicate clicks; digest mismatch. +- eventWs: audience filtering (other users in org receive nothing), regex + acceptance, webhook/plugin exclusion. +- Web: inbox page tests, badge count degrade-to-hidden, locale parity, route + scope; `AiApprovalDialog` supervised/four_eyes/sole-operator variants. +- Integration: end-to-end four_eyes chat intent approved at +30 min executes + via release worker and surfaces in history. + +## 9. Rollout + +1. Migration: `approval_scope`, `classification_version`, + `approval_expires_at`, `release_by`, effect-digest column; backfill live + intents as `four_eyes`/v0. Idempotent, same-day `-a-`/`-b-` ordering if + split. +2. API + guardrails + worker changes behind the classification (no flag — + supervised is strictly less restrictive than today only for the requester, + and strictly more deliverable for four_eyes). +3. Web inbox + events + copy. +4. Release notes: self-hosters get working approvals without the mobile app; + document the new four-eyes action list and the `approvals:decide` meaning + change ("second pair of eyes"). + +## 10. Out of scope + +Dynamic/bulk escalation; partner-configurable four-eyes list; partner +step-up policy on supervised; email notifications; MCP-initiated durable +intents; renumbering blocked tier 4. From 053ab69930e89a4093800e19a35840cb9a0bb401 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Wed, 5 Aug 2026 23:44:56 -0600 Subject: [PATCH 02/17] docs: backend implementation plan for tier-3 supervised/four-eyes split Co-Authored-By: Claude Fable 5 --- ...8-05-tier3-supervised-four-eyes-backend.md | 393 ++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-tier3-supervised-four-eyes-backend.md diff --git a/docs/superpowers/plans/2026-08-05-tier3-supervised-four-eyes-backend.md b/docs/superpowers/plans/2026-08-05-tier3-supervised-four-eyes-backend.md new file mode 100644 index 000000000..d54225b4f --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-tier3-supervised-four-eyes-backend.md @@ -0,0 +1,393 @@ +# Tier-3 Supervised/Four-Eyes Split — Backend Core Implementation Plan (Plan 1 of 3) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split Tier-3 AI approvals into `supervised` (requester approves with a plain click) and `four_eyes` (second human), fix the confirmed intent-layer defects (non-atomic decide, users.status filter, single-deadline expiry, content TOCTOU), and expose a transport-neutral approvals API ready for the web inbox (Plan 2). + +**Spec:** `docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md` — read it first; §2–§4 are this plan. + +**Architecture:** `checkGuardrails` gains an `approvalScope` field driven by explicit classification tables with an exhaustiveness contract test. `intentService` persists the scope, fans out a single requester-owned row for supervised, filters approvers by `users.status`, and splits expiry into `approval_expires_at` + `release_by`. The decide route gets a supervised plain-decide branch and becomes atomic. Four-eyes intents pin an effect digest revalidated at release. + +**Tech Stack:** Hono, Drizzle, Postgres (hand-written SQL migrations), Vitest (unit + integration configs), BullMQ. + +## Global Constraints + +- Migrations: `YYYY-MM-DD-.sql`, idempotent (`IF NOT EXISTS` / `DO $$`), NO inner `BEGIN;`/`COMMIT;`, never edit a shipped migration. Same-day dependents use `-a-`/`-b-` infixes. +- `action_intents` has `org_id` → it is in `CORE_ORG_CASCADE_DELETE_ORDER`, so **every new column MUST be classified in `CORE_TENANT_EXPORT_POLICY`** (`services/tenantExportPolicyRegistry.ts`) in the same PR. +- `pnpm --filter @breeze/api test` does NOT run the RLS/integration contract suites; run `vitest.integration.config.ts` + `vitest.config.rls.ts` explicitly before PR (needs local Postgres; see `integration_suite_needs_fsync_off_tmpfs_locally`). +- Tier 4 keeps its existing meaning (blocked). Do not renumber anything. +- MCP behavior unchanged: effective tier 3 (both scopes) → `MCP_APPROVAL_REQUIRED`. +- All new i18n-visible strings are Plan 3; this plan is API-only. +- Commit after every green task; messages end with the standard co-author trailer. + +--- + +### Task 1: Guardrails classification tables + `approvalScope` + +**Files:** +- Modify: `apps/api/src/services/aiGuardrails.ts` (tables near `TIER3_ACTIONS` ~line 165; `GuardrailCheck` + `checkGuardrails` ~lines 858–955) +- Test: `apps/api/src/services/aiGuardrails.approvalScope.contract.test.ts` (new) + +**Interfaces:** +- Produces: `GuardrailCheck.approvalScope?: 'supervised' | 'four_eyes'` (set whenever `tier === 3`); exported `TIER3_FOUR_EYES_ACTIONS: Record`, `TIER3_FOUR_EYES_TOOLS: Set`, `TIER3_SUPERVISED_ACTIONS: Record`, `TIER3_SUPERVISED_TOOLS: Set`, and `resolveApprovalScope(toolName: string, action: string | undefined): 'supervised' | 'four_eyes'`. + +- [ ] **Step 1: Write the failing contract test.** Model it on `aiGuardrails.readonly.contract.test.ts`. Assertions: + +```ts +import { describe, it, expect } from 'vitest'; +import { + TIER3_ACTIONS, TIER3_FOUR_EYES_ACTIONS, TIER3_FOUR_EYES_TOOLS, + TIER3_SUPERVISED_ACTIONS, TIER3_SUPERVISED_TOOLS, + checkGuardrails, resolveApprovalScope, +} from '../aiGuardrails'; +import { getToolTier, getAllRegisteredToolNames } from '../aiTools'; + +describe('tier-3 approval scope classification', () => { + it('classifies every per-action tier-3 pair in exactly one scope', () => { + for (const [tool, actions] of Object.entries(TIER3_ACTIONS)) { + for (const action of actions) { + const inFourEyes = TIER3_FOUR_EYES_ACTIONS[tool]?.includes(action) ?? false; + const inSupervised = TIER3_SUPERVISED_ACTIONS[tool]?.includes(action) ?? false; + expect(inFourEyes !== inSupervised, `${tool}:${action} must be in exactly one scope table`).toBe(true); + } + } + }); + + it('classifies every base-tier-3 tool in exactly one whole-tool scope set', () => { + for (const tool of getAllRegisteredToolNames()) { + if (getToolTier(tool) !== 3) continue; + const inFourEyes = TIER3_FOUR_EYES_TOOLS.has(tool); + const inSupervised = TIER3_SUPERVISED_TOOLS.has(tool); + expect(inFourEyes !== inSupervised, `${tool} must be in exactly one whole-tool scope set`).toBe(true); + } + }); + + it('scope tables reference only real tier-3 surfaces', () => { + for (const [tool, actions] of Object.entries(TIER3_FOUR_EYES_ACTIONS)) { + for (const a of actions) expect(TIER3_ACTIONS[tool] ?? []).toContain(a); + } + for (const tool of TIER3_FOUR_EYES_TOOLS) expect(getToolTier(tool)).toBe(3); + }); + + it('defaults unclassified to four_eyes (fail-safe)', () => { + expect(resolveApprovalScope('some_future_unclassified_tool', undefined)).toBe('four_eyes'); + }); + + it('s1_isolate_device is whole-tool four-eyes-exempt via supervised set', () => { + // boolean `isolate` discriminator — cannot be action-classified (spec §3.1) + expect(TIER3_SUPERVISED_TOOLS.has('s1_isolate_device')).toBe(true); + }); + + it('checkGuardrails surfaces the scope on tier-3 results', () => { + const fourEyes = checkGuardrails('manage_invoices', { action: 'issue' }); + expect(fourEyes.tier).toBe(3); + expect(fourEyes.approvalScope).toBe('four_eyes'); + const supervised = checkGuardrails('manage_services', { action: 'restart' }); + expect(supervised.tier).toBe(3); + expect(supervised.approvalScope).toBe('supervised'); + const tier2 = checkGuardrails('manage_patches', { action: 'approve' }); + expect(tier2.approvalScope).toBeUndefined(); + }); +}); +``` + +If `getAllRegisteredToolNames` does not exist in `aiTools.ts`, add it in this task: `export function getAllRegisteredToolNames(): string[]` returning the registry's tool-name keys (the same map `getToolTier` reads). + +- [ ] **Step 2: Run it — must fail** (`pnpm --filter @breeze/api test -- aiGuardrails.approvalScope`), with "TIER3_FOUR_EYES_ACTIONS is not exported". + +- [ ] **Step 3: Implement.** In `aiGuardrails.ts`, next to `TIER3_ACTIONS`: + +```ts +// Spec 2026-08-05 §3: within tier 3, `four_eyes` requires a SECOND human +// (approvals:decide holder other than the requester); everything else is +// `supervised` — the requesting human approves their own AI action with a +// plain click, gated on their existing RBAC. Unclassified tier-3 surfaces +// resolve four_eyes (fail-safe); the contract test forbids relying on that. +export const TIER3_FOUR_EYES_ACTIONS: Record = { + manage_invoices: ['issue', 'record_payment', 'void_payment'], + manage_contracts: ['activate', 'cancel'], + manage_quotes: ['send'], + manage_organizations: ['create_org', 'update_org'], // update_org: status-split is Task 9 + manage_tickets: ['move_org'], + manage_hyperv_checkpoints: ['delete', 'apply'], + manage_patches: ['rollback'], +}; +export const TIER3_FOUR_EYES_TOOLS = new Set([ + // populated from the registry sweep in Step 4: restore/DR executors, + // M365/Google identity mutators, computer-control/unattended-remote, + // S1 unisolate/threat-rollback multiplexer entries that are whole-tool. +]); +export const TIER3_SUPERVISED_ACTIONS: Record = { + // complement of TIER3_FOUR_EYES_ACTIONS within TIER3_ACTIONS — spelled + // out explicitly; the contract test enforces exact-one membership. + file_operations: ['read', 'write', 'delete', 'mkdir', 'rename'], + manage_services: ['start', 'stop', 'restart'], + security_scan: ['quarantine', 'remove', 'restore'], + // ... every remaining TIER3_ACTIONS pair +}; +export const TIER3_SUPERVISED_TOOLS = new Set([ + 'execute_command', 'run_script', 's1_isolate_device', + // ... every remaining base-tier-3 tool +]); + +export function resolveApprovalScope( + toolName: string, + action: string | undefined, +): 'supervised' | 'four_eyes' { + if (action && TIER3_FOUR_EYES_ACTIONS[toolName]?.includes(action)) return 'four_eyes'; + if (action && TIER3_SUPERVISED_ACTIONS[toolName]?.includes(action)) return 'supervised'; + if (TIER3_FOUR_EYES_TOOLS.has(toolName)) return 'four_eyes'; + if (TIER3_SUPERVISED_TOOLS.has(toolName)) return 'supervised'; + return 'four_eyes'; // fail-safe; contract test keeps this unreachable for real tools +} +``` + +Add `approvalScope?: 'supervised' | 'four_eyes'` to `GuardrailCheck`, and set `approvalScope: resolveApprovalScope(toolName, action)` in both tier-3 return branches of `checkGuardrails` (the `TIER3_ACTIONS` escalation return and the `baseTier >= 3` return — only when the effective tier is exactly 3, NOT for blocked tier 4). + +- [ ] **Step 4: Registry sweep to fill the supervised/four-eyes sets.** Run the contract test; it fails once per unclassified surface. Classify each per spec §3.2 (identity/restore/containment-release/computer-control → four_eyes; device work → supervised). Sweep `aiToolsM365.ts` / `aiToolsGoogle.ts` action lists for password/2SV reset, forwarding, delegates, offboarding, wipe → those actions go in `TIER3_FOUR_EYES_ACTIONS` under their tool names; S1 unisolate/rollback likewise. Iterate until the contract test passes with zero unclassified surfaces. + +- [ ] **Step 5: Run the full guardrails test file set** (`pnpm --filter @breeze/api test -- aiGuardrails`) — all green, including the pre-existing readonly contract test. + +- [ ] **Step 6: Commit** (`feat(ai): classify tier-3 tools into supervised vs four-eyes approval scopes`). + +--- + +### Task 2: Migration + Drizzle schema for scope, deadlines, digest + +**Files:** +- Create: `apps/api/migrations/2026-08-05-intent-approval-scope-and-deadlines.sql` +- Modify: `apps/api/src/db/schema/actionIntents.ts` +- Modify: `apps/api/src/services/tenantExportPolicyRegistry.ts` + +**Interfaces:** +- Produces columns on `action_intents`: `approval_scope text NOT NULL DEFAULT 'four_eyes'` (CHECK `IN ('supervised','four_eyes')`), `classification_version integer NOT NULL DEFAULT 0`, `approval_expires_at timestamptz` (backfilled from `expires_at`), `release_by timestamptz`, `effect_digest char(64)`. Drizzle fields: `approvalScope`, `classificationVersion`, `approvalExpiresAt`, `releaseBy`, `effectDigest`. + +- [ ] **Step 1: Write the migration** (idempotent, no inner transaction): + +```sql +-- Spec 2026-08-05 tier3-supervised-four-eyes-split §4.1. +ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS approval_scope text NOT NULL DEFAULT 'four_eyes'; +ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS classification_version integer NOT NULL DEFAULT 0; +ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS approval_expires_at timestamptz; +ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS release_by timestamptz; +ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS effect_digest char(64); + +DO $$ BEGIN + ALTER TABLE action_intents ADD CONSTRAINT action_intents_approval_scope_chk + CHECK (approval_scope IN ('supervised','four_eyes')); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- Backfill: pre-split rows are legacy four-eyes (spec §9.1); their approval +-- deadline is the old single deadline. +UPDATE action_intents SET approval_expires_at = expires_at + WHERE approval_expires_at IS NULL; +``` + +Note: `approval_scope`/`classification_version` land on rows via the DEFAULTs — matching the backfill rule (legacy = `four_eyes`/v0). The immutability trigger (`action_intents_immutable_trg`) must not block `release_by` stamping: check the trigger's column list in `2026-07-18-action-intents.sql` — if it blocks all UPDATEs outside status transitions, extend its allowlist in THIS migration (`DROP TRIGGER IF EXISTS` + recreate with `release_by`, `approval_expires_at` writable). Do not edit the shipped 07-18 file. + +- [ ] **Step 2: Update Drizzle schema** — add the five fields to `actionIntents` in `actionIntents.ts` with `.$type<'supervised' | 'four_eyes'>()` on `approvalScope` (text + CHECK pattern already documented in that file's header). + +- [ ] **Step 3: Export-policy registration.** In `tenantExportPolicyRegistry.ts`, find the `action_intents` `tablePolicy` entry and add all five columns to `included` (identifiers/timestamps/digest — no secrets, no open containers). + +- [ ] **Step 4: Apply + verify.** `export DATABASE_URL=postgresql://breeze:breeze@localhost:5432/breeze && pnpm db:migrate && pnpm db:check-drift` — drift check green. Re-run `pnpm db:migrate` — re-application is a no-op. + +- [ ] **Step 5: Run the export-policy + cascade integration suites** (`pnpm --filter @breeze/api exec vitest run -c vitest.integration.config.ts tenant-export-policy tenantCascade`) — green. + +- [ ] **Step 6: Commit** (`feat(ai): action_intents approval scope, split deadlines, effect digest columns`). + +--- + +### Task 3: Approver resolver filters `users.status` + +**Files:** +- Modify: `apps/api/src/services/actionIntents/intentApprovers.ts` (org-member query ~line 78, partner-axis query below it) +- Test: `apps/api/src/services/actionIntents/intentApprovers.test.ts` (extend; follow its existing Drizzle-mock pattern — see `breeze-testing` skill) + +**Interfaces:** +- Consumes/produces: `resolveIntentApprovers(orgId)` signature unchanged; result now excludes users whose `users.status !== 'active'`. + +- [ ] **Step 1: Failing test.** In the existing test file, add a case seeding an org with one active admin and one `disabled` admin (mirror the file's existing seed/mock helpers) asserting the disabled user is absent from the result. Also assert the sole-operator implication: with requester active + only disabled others, `resolveIntentApprovers` returns only the requester. +- [ ] **Step 2: Run — fails** (disabled user present). +- [ ] **Step 3: Implement.** Both candidate queries (org members, partner-axis members) gain an `innerJoin(users, eq(users.id, .userId))` + `eq(users.status, 'active')` condition. Check `users.ts:15` for the exact status union before writing the literal. +- [ ] **Step 4: Run intentApprovers tests — green.** +- [ ] **Step 5: Commit** (`fix(ai): exclude non-active users from intent approver fan-out`). + +--- + +### Task 4: `intentService` — scope-aware creation, fan-out, deadlines + +**Files:** +- Modify: `apps/api/src/services/actionIntents/intentService.ts` (tier gate ~215, `computeExpiresAt` ~175, fan-out branch ~408–470, push loop ~495–512) +- Test: `apps/api/src/services/actionIntents/intentService.test.ts` (extend) + +**Interfaces:** +- Consumes: `guardrail.approvalScope` (Task 1), new columns (Task 2), filtered approvers (Task 3). +- Produces: `createIntent(input)` persists `approvalScope`, `classificationVersion: 1`, `approvalExpiresAt`; supervised → exactly one approval row for the requester, `requesterApprovalRequestId` set, `fanOutUserIds: [requesterId]`, **no push**; four_eyes → existing behavior + 60-min chat approval deadline. Constant `CLASSIFICATION_VERSION = 1` exported. + +- [ ] **Step 1: Failing tests** (extend the file's existing harness): + +```ts +it('supervised intent fans out a single requester-owned row and skips push', async () => { + const snap = await createIntentWith({ approvalScope: 'supervised' }); // use file's builder + expect(snap.requesterApprovalRequestId).toBeDefined(); + expect(snap.fanOutUserIds).toEqual([REQUESTER_ID]); + expect(pushSpy).not.toHaveBeenCalled(); +}); + +it('four_eyes chat intent gets a 60-minute approval deadline; supervised keeps 5', async () => { + const fe = await createIntentWith({ approvalScope: 'four_eyes', source: 'chat' }); + const sv = await createIntentWith({ approvalScope: 'supervised', source: 'chat' }); + expect(msUntil(fe.approvalExpiresAt)).toBeCloseTo(60 * 60 * 1000, -4); + expect(msUntil(sv.approvalExpiresAt)).toBeCloseTo(5 * 60 * 1000, -4); +}); + +it('four_eyes with no other active approver keeps sole-operator fallback', async () => { /* existing sole-operator asserts, now under scope four_eyes */ }); +``` + +- [ ] **Step 2: Run — fails.** +- [ ] **Step 3: Implement.** + - `computeExpiresAt(source, approvalScope)`: chat+supervised → 5 min; chat+four_eyes → 60 min (`FOUR_EYES_CHAT_EXPIRY_MS = 60 * 60 * 1000`); mcp_api → 24 h unchanged. Write to `approvalExpiresAt`; keep legacy `expiresAt` written with the same value for rolling-upgrade compat (old reaper reads it) — note removal as Plan 3 cleanup. + - Creation values: `approvalScope: input.guardrail.approvalScope ?? 'four_eyes'`, `classificationVersion: CLASSIFICATION_VERSION`. + - Fan-out: `if (approvalScope === 'supervised')` short-circuit before the eligible-approver branch — insert one row via the existing `approvalRowFor(requesterId)`, set `requesterApprovalRequestId`, skip the push loop (`if (creation.intent.approvalScope === 'four_eyes')` guard around the push block). Four-eyes path unchanged. +- [ ] **Step 4: Run intentService tests — green.** +- [ ] **Step 5: Commit** (`feat(ai): scope-aware intent creation — supervised self fan-out, split deadlines`). + +--- + +### Task 5: Reaper + release worker honor the deadline split + +**Files:** +- Modify: `apps/api/src/jobs/intentExpiryReaper.ts` (~lines 121–185) +- Modify: `apps/api/src/jobs/intentReleaseWorker.ts` (claim CAS ~242–257) +- Modify: `apps/api/src/routes/approvals.ts` (fan-in block ~846–940: stamp `release_by` on approval win) +- Test: `apps/api/src/jobs/intentExpiryReaper.test.ts`, `apps/api/src/jobs/intentReleaseWorker.test.ts` (extend) + +**Interfaces:** +- Produces: `RELEASE_LEASE_MS = 10 * 60 * 1000` (exported from `intentService.ts`). Pending intents expire on `approval_expires_at`; approved intents expire on `release_by`; the approve fan-in stamps `release_by = now() + RELEASE_LEASE_MS` in the same CAS that flips the intent to `approved`. + +- [ ] **Step 1: Failing tests.** + - Reaper: an intent `pending_approval` past `approvalExpiresAt` → expired; an intent `approved` with `releaseBy` in the future but `approvalExpiresAt` in the past → **NOT** expired (this is the 59:59 trap); `approved` past `releaseBy` → expired. + - Worker: claim CAS succeeds when `releaseBy` future even if `approvalExpiresAt` past; refuses past `releaseBy`. +- [ ] **Step 2: Run — fails.** +- [ ] **Step 3: Implement.** Reaper `where` clauses split by status: `pending_approval AND approval_expires_at < now()` vs `approved AND release_by < now()` (fall back to `expires_at` when `release_by IS NULL` — legacy rows). Worker `requireNotExpired` check compares `release_by ?? expires_at`. Approve fan-in (Task 6 makes it atomic; here just add the field): the intent-transition UPDATE gains `releaseBy: new Date(Date.now() + RELEASE_LEASE_MS)`. +- [ ] **Step 4: Run both job test files — green.** +- [ ] **Step 5: Commit** (`fix(ai): split intent expiry into approval deadline + release lease`). + +--- + +### Task 6: Atomic decide + supervised plain-decide branch + +**Files:** +- Modify: `apps/api/src/routes/approvals.ts` (assurance gate ~657–680, decision write ~681–860, fan-in ~846–940, report-suspicious ~274–440) +- Test: `apps/api/src/routes/approvalsDecideAtomicity.integration.test.ts` (update expectations), `apps/api/src/routes/approvals.test.ts` (extend) + +**Interfaces:** +- Consumes: intent columns (Task 2), `RELEASE_LEASE_MS` (Task 5). +- Produces: `POST /:id/approve` accepts a supervised requester decide with **no WebAuthn assertion**; all decide writes (approval-row CAS, intent transition + `release_by`, sibling expiry, outbox insert, audit projection) run in ONE `db.transaction`; HTTP 200 only after the intent transition commits. Error codes unchanged (`digest_mismatch`, `not_sole_approver`, 409/410 semantics). + +- [ ] **Step 1: Failing unit tests** (`approvals.test.ts`, existing mock harness): + +```ts +it('supervised requester approves with no assertion', async () => { + // row: approval_scope=supervised, approval owned by requester + const res = await app.request(`/approvals/${rowId}/approve`, { method: 'POST', headers: authFor(REQUESTER), body: '{}' }); + expect(res.status).toBe(200); +}); +it('supervised row rejects a NON-requester decide even with approvals:decide', async () => { + const res = await app.request(`/approvals/${rowId}/approve`, { method: 'POST', headers: authFor(OTHER_ADMIN), body: '{}' }); + expect(res.status).toBe(403); +}); +it('four_eyes rows keep the assurance gate', async () => { /* existing assertions unchanged, re-labeled */ }); +it('supervised approve re-checks live RBAC for the underlying tool action', async () => { + // revoke devices:execute from requester between create and decide → 403 +}); +``` + +- [ ] **Step 2: Failing integration test.** In `approvalsDecideAtomicity.integration.test.ts` (~line 269), invert the current expectation: inject the fan-in fault and assert the endpoint now returns **500** AND the approval row is still `pending` (rolled back) AND a retry succeeds. Delete the "200 with pending intent" assertion. +- [ ] **Step 3: Implement.** + - **Branch order** in the approve handler: (1) load row + linked intent; (2) if `intent.approvalScope === 'supervised'`: require `intent.requestedByUserId === userId` (else 403 `not_requester`), require live RBAC via the same `TOOL_PERMISSIONS` check used at execution (import `checkToolPermission` or its equivalent from the guardrails/permissions module), skip the assertion/assurance ladder entirely; (3) else: existing four-eyes/sole-operator logic untouched. + - **Atomicity**: wrap the approval-row CAS → intent CAS (+ `release_by`) → sibling-expiry → outbox insert → `ai_tool_executions` mirror → audit insert in one `db.transaction(async (tx) => ...)`, threading `tx` through the helpers that currently take `db`. The current post-commit push dispatch stays OUTSIDE the transaction (#1105 — never hold a txn across network I/O). On any throw: transaction rolls back, respond 500 with a retryable error body. Apply the same wrap to `report-suspicious`'s intent-rejection block (~:288–320). + - Audit `approvalMethod`: supervised decides record `'supervised_self'` (extend the `AiApprovalMethod` union in `aiAgentSdk.ts` ~line 225). +- [ ] **Step 4: Run** `approvals.test.ts` (unit) — green. Run the atomicity integration test against local Postgres — green. +- [ ] **Step 5: Commit** (`feat(ai): supervised plain-decide branch + atomic decide transaction`). + +--- + +### Task 7: Effect-digest pinning for four-eyes intents + +**Files:** +- Create: `apps/api/src/services/actionIntents/effectDigest.ts` +- Modify: `apps/api/src/services/actionIntents/intentService.ts` (creation), `apps/api/src/jobs/intentReleaseWorker.ts` (revalidation ~300–330) +- Test: `apps/api/src/services/actionIntents/effectDigest.test.ts` (new), `apps/api/src/jobs/intentReleaseWorker.test.ts` (extend) + +**Interfaces:** +- Produces: `computeEffectDigest(toolName, args, dbContext): Promise` — SHA-256 hex over tool-specific materialized content; `null` = tool has no pinnable effect (digest check skipped). Resolvers (v1): `run_script` → script body hash (`scripts.content` by `scriptId`); `manage_quotes:send` → quote `updated_at` + line-item hash; `manage_invoices:issue|record_payment|void_payment` → invoice `updated_at`; `manage_contracts:activate|cancel` → contract `updated_at`; `manage_organizations:update_org` → current org `status`. Everything else → `null`. +- Worker failure mode: recomputed digest ≠ stored `effect_digest` → intent `failed` with `errorCode: 'content_changed'` (never executes). + +- [ ] **Step 1: Failing unit tests** for `computeEffectDigest`: same content → same digest; changed script body → different digest; unpinnable tool → `null`. Use the Drizzle mock pattern from `breeze-testing`. +- [ ] **Step 2: Failing worker test:** approved four-eyes `run_script` intent whose script content changed after creation → release fails `content_changed`, no execution, audit row records the code. +- [ ] **Step 3: Implement.** `effectDigest.ts` — a `Record Promise>` resolver map keyed `tool` or `tool:action`, hashed with `createHash('sha256')`. `createIntent`: when scope is `four_eyes`, compute inside the creation transaction and store `effectDigest`. Worker: after the existing digest/tier revalidation (~:310), recompute; mismatch → CAS to `failed`/`content_changed`. Supervised intents: skip (spec §4.1). +- [ ] **Step 4: Run both test files — green.** +- [ ] **Step 5: Commit** (`feat(ai): pin four-eyes intent effect digests; fail release on drift`). + +--- + +### Task 8: `/pending` live authz, pagination, count, neutral mount + +**Files:** +- Modify: `apps/api/src/routes/approvals.ts` (`GET /pending` ~46–85; new `GET /pending/count`) +- Modify: `apps/api/src/index.ts` (~1038: add `app.route('/api/v1/approvals', approvalRoutes)` alongside the existing `/api/v1/mobile/approvals` mount) +- Test: `apps/api/src/routes/approvals.test.ts` (extend) + +**Interfaces:** +- Produces: `GET /pending?limit&cursor` — joins `action_intents`, returns only rows whose intent is `pending_approval` and (four_eyes: caller still holds `approvals:decide` + org access; supervised: caller is the requester); response `{ items, nextCursor }`, items capped at 50. `GET /pending/count` → `{ count }` (same filters, no arguments in payload). Both mounted at `/api/v1/approvals/*`; `/api/v1/mobile/approvals/*` alias preserved. + +- [ ] **Step 1: Failing tests:** demoted approver (permission revoked after fan-out) gets `[]` and count 0; supervised requester sees own row; pagination cursor walks a 3-row seed with `limit=2`; count endpoint returns bare integer count; both paths reachable under `/api/v1/approvals`. +- [ ] **Step 2: Run — fails.** +- [ ] **Step 3: Implement.** Join + filters as above (permission re-check via the same helper `resolveIntentApprovers` uses — `PERMISSIONS.APPROVALS_DECIDE` through `hasPermission`); keyset pagination on `(createdAt, id)`; count via `count(*)` with identical predicates. Mount addition in `index.ts` is one line. +- [ ] **Step 4: Run route tests — green.** +- [ ] **Step 5: Commit** (`feat(api): live-authorized paginated approvals list + count; transport-neutral mount`). + +--- + +### Task 9: Chat path wiring + `update_org` status split + durable-executable contract + +**Files:** +- Modify: `apps/api/src/services/aiAgentSdk.ts` (intent branch ~900–1000: pass scope; supervised bridge; push guard ~1304–1320) +- Modify: `apps/api/src/services/aiToolsOrgs.ts` (~349: split `update_org`) +- Modify: `apps/api/src/services/aiGuardrails.ts` (dynamic escalation for `update_org` with `status` present) +- Test: `apps/api/src/services/aiAgentSdk.approvalWait.test.ts` (extend), `apps/api/src/services/aiGuardrails.approvalScope.contract.test.ts` (extend), new `apps/api/src/jobs/intentReleaseWorker.durable.contract.test.ts` + +**Interfaces:** +- Consumes: everything above. +- Produces: chat SSE approval payload gains `approvalScope` and (supervised) `selfApprovalRequestId` — the shape `AiApprovalDialog` already consumes for sole-operator, so the web card renders actionable buttons without Plan-2 work; plain decide is authorized server-side by Task 6. `update_org` with a `status` argument resolves `four_eyes`; without it, `supervised`. Contract test: every four-eyes-classified tool must NOT be `session_required` in the release worker. + +- [ ] **Step 1: Failing tests.** + - `aiAgentSdk`: supervised tier-3 call emits an approval event carrying `selfApprovalRequestId` + `approvalScope: 'supervised'`; **no push dispatched**; four_eyes still pushes. + - Guardrails: `checkGuardrails('manage_organizations', { action: 'update_org', status: 'suspended' }).approvalScope === 'four_eyes'`; without `status` → `'supervised'`. + - Durable contract: iterate `TIER3_FOUR_EYES_TOOLS` + tools in `TIER3_FOUR_EYES_ACTIONS` against the worker's `session_required` set (export it from `intentReleaseWorker.ts` if currently module-private) — assert empty intersection. +- [ ] **Step 2: Run — fails.** +- [ ] **Step 3: Implement.** In `resolveApprovalScope`, add an input-aware override hook: `if (toolName === 'manage_organizations' && action === 'update_org') return 'status' in input ? 'four_eyes' : 'supervised'` (pass `input` through from `checkGuardrails`; extend the signature to `resolveApprovalScope(toolName, action, input)`). In `aiAgentSdk.ts`: thread `guardrailCheck.approvalScope` into `createIntent`; include `selfApprovalRequestId: snapshot.requesterApprovalRequestId` and `approvalScope` in the approval event payload for supervised (mirror the existing sole-operator emission ~:964); wrap the push block in a four_eyes guard. Fix any durable-contract failures by either making the tool durably executable or (if genuinely session-bound) documenting a session-window carve-out in the contract test's explicit allowlist with a comment. +- [ ] **Step 4: Run the three test files — green.** +- [ ] **Step 5: Commit** (`feat(ai): supervised chat bridge, update_org status escalation, durable four-eyes contract`). + +--- + +### Task 10: Integration pass + full verification + +**Files:** +- Create: `apps/api/src/__tests__/integration/intentSupervisedFourEyes.integration.test.ts` + +- [ ] **Step 1: Write the end-to-end integration test** (real Postgres; follow `intentReleaseWorker*.integration.test.ts` setup): seed two active admins + one requester-technician. Assert: (a) supervised intent → single requester row → plain-click approve API → release worker executes; (b) four_eyes intent → rows for both admins, none for requester → approve at t+30 min (past old 5-min window, mock clock or shrink constants via injection) → executes within lease; (c) disabled second admin → sole-operator fallback engages; (d) fan-in fault injection → rollback (no spent approval row). +- [ ] **Step 2: Run it + the RLS suite** (`vitest -c vitest.integration.config.ts intentSupervisedFourEyes`, then `vitest -c vitest.config.rls.ts`) — green. +- [ ] **Step 3: Full local verification:** `pnpm --filter @breeze/api test`, `pnpm db:check-drift`, then the two contract suites again if any tenancy file changed since Task 2. +- [ ] **Step 4: Commit** (`test(ai): supervised/four-eyes end-to-end integration coverage`). +- [ ] **Step 5: Open the PR** (`feat(ai): tier-3 supervised/four-eyes approval split — backend core`). PR body: link the spec; call out the `users.status` fan-out fix, the decide-atomicity behavior change (500 on fan-in fault, was 200), the audit `approvalMethod: 'supervised_self'` addition (SIEM shape), and that the web inbox lands in Plan 2. Dispatch CI per branch if stacked (`gh workflow run CI --ref `). + +--- + +## Self-Review Notes + +- Spec coverage: §2 (Task 1, 6, 9), §3 (Task 1, 9), §4.1 (Tasks 2–5, 7), §4.2 (Tasks 6, 8), §8 backend rows (Tasks 1–10). §5–§7 are Plans 2–3 by design. +- Legacy `expires_at` dual-write (Task 4) keeps old reapers correct during rolling upgrade; removal is noted for Plan 3. +- Type names consistent: `approvalScope`/`approval_scope`, `approvalExpiresAt`, `releaseBy`, `effectDigest`, `resolveApprovalScope(toolName, action, input)`, `CLASSIFICATION_VERSION`, `RELEASE_LEASE_MS` — used identically across Tasks 1–9. From 7bdf0596cd129bacef07a9a422df2a2568d8e78e Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 00:24:23 -0600 Subject: [PATCH 03/17] feat(ai): classify tier-3 tools into supervised vs four-eyes approval scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TIER3_FOUR_EYES_ACTIONS/TOOLS and TIER3_SUPERVISED_ACTIONS/TOOLS plus resolveApprovalScope() per the 2026-08-05 tier3-supervised-four-eyes design (§3.1/§3.2): within tier 3, four_eyes requires a second approvals:decide holder; everything else is supervised (requester self-approves). checkGuardrails now surfaces approvalScope on both tier-3 return branches. Exports getAllRegisteredToolNames() from aiTools.ts (core registry + M365/Google tier maps) so the exhaustiveness contract test can walk every real tool. s1_threat_action gets a new per-action split in TIER3_ACTIONS (kill/quarantine supervised, rollback four_eyes) since its action enum is a real discriminator, unlike s1_isolate_device's boolean isolate flag which stays whole-tool supervised per spec §3.1. Co-Authored-By: Claude Fable 5 --- ...iGuardrails.approvalScope.contract.test.ts | 62 +++++++ apps/api/src/services/aiGuardrails.ts | 157 ++++++++++++++++++ apps/api/src/services/aiTools.ts | 16 ++ 3 files changed, 235 insertions(+) create mode 100644 apps/api/src/services/aiGuardrails.approvalScope.contract.test.ts diff --git a/apps/api/src/services/aiGuardrails.approvalScope.contract.test.ts b/apps/api/src/services/aiGuardrails.approvalScope.contract.test.ts new file mode 100644 index 000000000..341d1ea62 --- /dev/null +++ b/apps/api/src/services/aiGuardrails.approvalScope.contract.test.ts @@ -0,0 +1,62 @@ +/** + * Contract test for the tier-3 supervised/four_eyes approval-scope split + * (2026-08-05 tier3-supervised-four-eyes design, §3.1). + * + * Modeled on aiGuardrails.readonly.contract.test.ts: no vi.mock — this suite + * needs the REAL aiTools registry, because base tiers are half the answer. + */ +import { describe, it, expect } from 'vitest'; +import { + TIER3_ACTIONS, TIER3_FOUR_EYES_ACTIONS, TIER3_FOUR_EYES_TOOLS, + TIER3_SUPERVISED_ACTIONS, TIER3_SUPERVISED_TOOLS, + checkGuardrails, resolveApprovalScope, +} from './aiGuardrails'; +import { getToolTier, getAllRegisteredToolNames } from './aiTools'; + +describe('tier-3 approval scope classification', () => { + it('classifies every per-action tier-3 pair in exactly one scope', () => { + for (const [tool, actions] of Object.entries(TIER3_ACTIONS)) { + for (const action of actions) { + const inFourEyes = TIER3_FOUR_EYES_ACTIONS[tool]?.includes(action) ?? false; + const inSupervised = TIER3_SUPERVISED_ACTIONS[tool]?.includes(action) ?? false; + expect(inFourEyes !== inSupervised, `${tool}:${action} must be in exactly one scope table`).toBe(true); + } + } + }); + + it('classifies every base-tier-3 tool in exactly one whole-tool scope set', () => { + for (const tool of getAllRegisteredToolNames()) { + if (getToolTier(tool) !== 3) continue; + const inFourEyes = TIER3_FOUR_EYES_TOOLS.has(tool); + const inSupervised = TIER3_SUPERVISED_TOOLS.has(tool); + expect(inFourEyes !== inSupervised, `${tool} must be in exactly one whole-tool scope set`).toBe(true); + } + }); + + it('scope tables reference only real tier-3 surfaces', () => { + for (const [tool, actions] of Object.entries(TIER3_FOUR_EYES_ACTIONS)) { + for (const a of actions) expect(TIER3_ACTIONS[tool] ?? []).toContain(a); + } + for (const tool of TIER3_FOUR_EYES_TOOLS) expect(getToolTier(tool)).toBe(3); + }); + + it('defaults unclassified to four_eyes (fail-safe)', () => { + expect(resolveApprovalScope('some_future_unclassified_tool', undefined)).toBe('four_eyes'); + }); + + it('s1_isolate_device is whole-tool four-eyes-exempt via supervised set', () => { + // boolean `isolate` discriminator — cannot be action-classified (spec §3.1) + expect(TIER3_SUPERVISED_TOOLS.has('s1_isolate_device')).toBe(true); + }); + + it('checkGuardrails surfaces the scope on tier-3 results', () => { + const fourEyes = checkGuardrails('manage_invoices', { action: 'issue' }); + expect(fourEyes.tier).toBe(3); + expect(fourEyes.approvalScope).toBe('four_eyes'); + const supervised = checkGuardrails('manage_services', { action: 'restart' }); + expect(supervised.tier).toBe(3); + expect(supervised.approvalScope).toBe('supervised'); + const tier2 = checkGuardrails('manage_patches', { action: 'approve' }); + expect(tier2.approvalScope).toBeUndefined(); + }); +}); diff --git a/apps/api/src/services/aiGuardrails.ts b/apps/api/src/services/aiGuardrails.ts index 425f2a760..54248f142 100644 --- a/apps/api/src/services/aiGuardrails.ts +++ b/apps/api/src/services/aiGuardrails.ts @@ -202,8 +202,154 @@ export const TIER3_ACTIONS: Record = { // Org lifecycle (issue #2366) — tenant-shape mutations require approval. // add_contact stays at the tool's base tier (it returns guidance only). manage_organizations: ['create_org', 'update_org', 'create_site'], + // s1_threat_action is registered at base Tier 3 (see TIER3_FOUR_EYES_TOOLS / + // TIER3_SUPERVISED_TOOLS below for its whole-tool catch-all), but its + // `action` enum (kill/quarantine/rollback) is a real dispatch discriminator + // — same shape as manage_services/security_scan above — so it is split here + // too: rollback (containment RELEASE) is four_eyes, kill/quarantine + // (containment/mitigation) are supervised. See spec §3.2. + s1_threat_action: ['kill', 'quarantine', 'rollback'], }; +// Spec 2026-08-05 §3: within tier 3, `four_eyes` requires a SECOND human +// (approvals:decide holder other than the requester); everything else is +// `supervised` — the requesting human approves their own AI action with a +// plain click, gated on their existing RBAC. Unclassified tier-3 surfaces +// resolve four_eyes (fail-safe); the contract test forbids relying on that. +// +// Two independent classification axes, both required for full coverage: +// 1. Per-action pairs drawn from TIER3_ACTIONS above (TIER3_*_ACTIONS). +// 2. Whole registered tools whose BASE tier is 3 (TIER3_*_TOOLS) — this +// covers pure whole-tool surfaces (execute_command, s1_isolate_device) +// AND the catch-all for action-multiplexed base-Tier-3 tools whose +// action falls outside every TIER1/2/3_ACTIONS table (e.g. security_scan +// 'scan'/'status', which are not itself in TIER3_ACTIONS). A tool can +// legitimately appear in both an *_ACTIONS table and the complementary +// whole-tool set (manage_services, security_scan, s1_threat_action). +// +// See docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md §3.2 +// for the full classification rationale. +export const TIER3_FOUR_EYES_ACTIONS: Record = { + // Financial / externally binding. `void` is not named in spec §3.2's + // bullet list, but TOOL_PERMISSIONS maps it to the same `invoices:send` + // RBAC action as issue/record_payment/void_payment — voiding an issued + // invoice is the same externally-binding class, so it is classified + // alongside them (see task report "concerns"). + manage_invoices: ['issue', 'void', 'record_payment', 'void_payment'], + manage_contracts: ['activate', 'cancel'], + manage_quotes: ['send'], + // update_org: status-split (rename/plain edits -> supervised) is Task 9; + // for this task the whole action resolves four_eyes. + manage_organizations: ['create_org', 'update_org'], + manage_tickets: ['move_org'], + // Destroys or rewinds state. + manage_hyperv_checkpoints: ['delete', 'apply'], + manage_patches: ['rollback'], + // Containment RELEASE: threat rollback reverses a prior mitigation. kill/ + // quarantine are protective mitigation and stay supervised (same rationale + // as s1_isolate_device isolate — urgent protective action must not wait). + s1_threat_action: ['rollback'], +}; + +export const TIER3_FOUR_EYES_TOOLS = new Set([ + // Restore / DR execution — "destroys or rewinds state": these overwrite or + // replace live state from a prior snapshot. + 'restore_snapshot', 'restore_as_vm', 'instant_boot_vm', + 'restore_mssql_database', 'restore_hyperv_vm', 'restore_c2c_items', + 'execute_dr_plan', + // Surveillance-grade / unattended access. + 'computer_control', 'create_remote_session', + // Tenant destruction. + 'delete_tenant', + // Identity / account control — M365 (helpdesk tools; dispatch outside the + // headless registry via makeSessionAwareHandler, but still carry a real + // tier via m365ToolTiers). + 'm365_disable_user', 'm365_reset_password', + // Identity / account control — Google Workspace. Every mutating Google tool + // acts on a human identity/mailbox/account, not a device, so the whole + // mutating surface is classified four_eyes (categorical reading of spec + // §3.2's "these act on human identities, not devices"; only a subset of + // these — password/2SV reset, forwarding/delegates, offboarding/disable, + // device wipe — is named explicitly in the design doc. See task report + // "concerns" for the borderline members: restore_user, signout, + // set_vacation, update_user, share_calendar, move_ou, rename_user, + // add/remove_from_group, assign/remove_license). + 'google_reset_password', 'google_reset_2sv', + 'google_set_forwarding', 'google_disable_forwarding', + 'google_add_mail_delegate', 'google_remove_mail_delegate', + 'google_suspend_user', 'google_offboard_user', 'google_wipe_mobile_device', + 'google_restore_user', 'google_signout', 'google_set_vacation', + 'google_update_user', 'google_share_calendar', 'google_move_ou', + 'google_rename_user', 'google_add_to_group', 'google_remove_from_group', + 'google_assign_license', 'google_remove_license', + // s1_threat_action whole-tool catch-all: every enum value is covered by + // TIER3_FOUR_EYES_ACTIONS/TIER3_SUPERVISED_ACTIONS above, so this only + // matters if the action is missing/unrecognized — fail-safe. + 's1_threat_action', + // PAM elevation grant: rule auto-approve can grant elevated device access + // with no further human review (see TOOL_PERMISSIONS comment on + // request_elevation above). Not named in spec §3.2; classified four_eyes + // out of caution — flagged in the task report "concerns". + 'request_elevation', +]); + +export const TIER3_SUPERVISED_ACTIONS: Record = { + // complement of TIER3_FOUR_EYES_ACTIONS within TIER3_ACTIONS. + file_operations: ['read', 'write', 'delete', 'mkdir', 'rename'], + manage_services: ['start', 'stop', 'restart'], + security_scan: ['quarantine', 'remove', 'restore'], + disk_cleanup: ['execute'], + manage_startup_items: ['disable', 'enable'], + manage_scheduled_tasks: ['run', 'disable', 'enable'], + manage_configuration_policy: ['create', 'update', 'delete'], + manage_deployments: ['create', 'start', 'cancel'], + manage_patches: ['install'], + manage_groups: ['create', 'update', 'delete'], + manage_automations: ['run'], + manage_processes: ['kill'], + manage_policy_feature_link: ['remove'], + registry_operations: ['set_value', 'create_key', 'delete_key'], + manage_dr_plan: ['delete_group'], + manage_monitors: ['create', 'update', 'delete'], + manage_contracts: ['pause', 'resume'], + // create_site adds a location within an existing org, not a new tenant — + // spec §3.2's tenant-shape bullet names only create_org/update_org. + manage_organizations: ['create_site'], + s1_threat_action: ['kill', 'quarantine'], +}; + +export const TIER3_SUPERVISED_TOOLS = new Set([ + // The customer's "regular work on a PC" (spec §3.2's explicit supervised list). + 'execute_command', 'run_script', + // boolean `isolate` discriminator — cannot be action-classified (spec §3.1). + // Isolate is urgent protective containment (stays supervised); unisolate + // (release) sharing this tool is a known, documented v1 tradeoff. + 's1_isolate_device', + 'manage_services', 'security_scan', // whole-tool catch-all complementing their _ACTIONS entries above + 'manage_startup_items', + 'take_screenshot', 'analyze_screen', + 'apply_cis_remediation', 'manage_hyperv_vm', 'manage_peripheral_policy', + 'manage_software_policy', 'manage_browser_policy', + 'network_discovery', 'remediate_sensitive_data', + 'remediate_software_violation', 'remediate_vulnerability', + 'execute_playbook', 'execute_containment', + // Backup triggers / agent maintenance — spec §3.2's explicit supervised list + // ("backup triggers, ... agent upgrades"). + 'trigger_backup', 'trigger_hyperv_backup', 'trigger_mssql_backup', + 'trigger_agent_upgrade', 'trigger_agent_restart', +]); + +export function resolveApprovalScope( + toolName: string, + action: string | undefined, +): 'supervised' | 'four_eyes' { + if (action && TIER3_FOUR_EYES_ACTIONS[toolName]?.includes(action)) return 'four_eyes'; + if (action && TIER3_SUPERVISED_ACTIONS[toolName]?.includes(action)) return 'supervised'; + if (TIER3_FOUR_EYES_TOOLS.has(toolName)) return 'four_eyes'; + if (TIER3_SUPERVISED_TOOLS.has(toolName)) return 'supervised'; + return 'four_eyes'; // fail-safe; contract test keeps this unreachable for real tools +} + // RBAC permission map: tool → { resource, action } (or action-based overrides) export const TOOL_PERMISSIONS: Record> = { query_devices: { resource: 'devices', action: 'read' }, @@ -866,6 +1012,13 @@ export interface GuardrailCheck { * — with the Tier-2 audit-ledger row — even under per_step approval mode. */ readOnly?: boolean; + /** + * Set whenever the effective tier is exactly 3 (never for blocked tier 4): + * `supervised` — the requester approves their own AI action; `four_eyes` — + * a second `approvals:decide` holder must decide. See + * docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md. + */ + approvalScope?: 'supervised' | 'four_eyes'; reason?: string; description?: string; } @@ -921,6 +1074,7 @@ export function checkGuardrails( tier: 3, allowed: true, requiresApproval: true, + approvalScope: resolveApprovalScope(toolName, action), description: buildApprovalDescription(toolName, action, input) }; } @@ -941,6 +1095,9 @@ export function checkGuardrails( tier: baseTier, allowed: true, requiresApproval: true, + // Only tier 3 gets a scope — a future base-Tier-4 tool would be blocked + // (no approval path at all), not a bigger approval scope. + ...(baseTier === 3 ? { approvalScope: resolveApprovalScope(toolName, action) } : {}), description: buildApprovalDescription(toolName, action, input) }; } diff --git a/apps/api/src/services/aiTools.ts b/apps/api/src/services/aiTools.ts index d2460f282..0a22ffefd 100644 --- a/apps/api/src/services/aiTools.ts +++ b/apps/api/src/services/aiTools.ts @@ -358,6 +358,22 @@ export function getToolTier( return coreTier ?? extensionTool?.tier; } +/** + * All CORE (non-extension) registered tool names — the same three sources + * `getToolTier` reads: the headless `aiTools` execution registry plus the two + * session-aware M365/Google tier maps (those tools dispatch outside `aiTools` + * but still have a real tier). Extension tools are per-tenant/dynamic and + * deliberately excluded — classification contracts like the tier-3 + * supervised/four_eyes split operate on the fixed core surface. + */ +export function getAllRegisteredToolNames(): string[] { + return [ + ...aiTools.keys(), + ...Object.keys(m365ToolTiers), + ...Object.keys(googleToolTiers), + ]; +} + /** * True iff a tool is recognized (getToolTier defined) but NOT executable by the * headless `executeTool` path — i.e. it only runs via the inline chat path's From c4fceae37e9ef0259589a6276e5950d55b271650 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 00:44:28 -0600 Subject: [PATCH 04/17] feat(ai): action_intents approval scope, split deadlines, effect digest columns Adds approval_scope/classification_version (immutable tier-3 classification from checkGuardrails), approval_expires_at/release_by (split pending-approval vs execution-lease deadlines), and effect_digest (four_eyes content-pinning hash) to action_intents, per the tier3-supervised-four-eyes-split design. Extends action_intents_immutable_trg to cover the three immutable columns; release_by/approval_expires_at stay mutable for lifecycle stamping. Registers the new columns in the export-policy allowlist and updates the schema contract test's column-list assertion. Co-Authored-By: Claude Fable 5 --- ...14-intent-approval-scope-and-deadlines.sql | 92 +++++++++++++++++++ apps/api/src/db/schema/actionIntents.test.ts | 30 ++++++ apps/api/src/db/schema/actionIntents.ts | 40 ++++++++ .../services/tenantExportPolicyRegistry.ts | 2 +- 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 apps/api/migrations/2026-08-14-intent-approval-scope-and-deadlines.sql diff --git a/apps/api/migrations/2026-08-14-intent-approval-scope-and-deadlines.sql b/apps/api/migrations/2026-08-14-intent-approval-scope-and-deadlines.sql new file mode 100644 index 000000000..cf94547b4 --- /dev/null +++ b/apps/api/migrations/2026-08-14-intent-approval-scope-and-deadlines.sql @@ -0,0 +1,92 @@ +-- Spec docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md +-- §4.1 / §9.1 — tier-3 supervised/four_eyes intent classification split. +-- +-- Adds five columns to action_intents: +-- * approval_scope, classification_version: immutable classification +-- content, decided once at createIntent time from checkGuardrails' +-- approvalScope. Live pre-migration rows backfill via DEFAULT to +-- 'four_eyes'/0 (spec §9.1: "Live pre-migration intents backfill as +-- four_eyes / version 0"). +-- * effect_digest: immutable content-pinning hash for four_eyes intents +-- (script content hash / quote-invoice revision / target state-version, +-- pinned at creation; the release worker revalidates it and fails the +-- release with content_changed on drift). Supervised intents leave it +-- NULL (they skip pinning per spec). +-- * approval_expires_at: the pending-approval deadline, split out of the +-- single expires_at column (advisor-confirmed trap: a single expires_at +-- could reap an intent approved at 59:59 before the release worker +-- claims it). Lifecycle column — set at creation by application code +-- going forward; backfilled here from the legacy expires_at for rows +-- that predate the split. +-- * release_by: the execution lease deadline, stamped atomically by the +-- decide-path when an approval wins (Task 5). Lifecycle column. +-- +-- approval_scope/classification_version/effect_digest are added to the +-- action_intents_immutable_trg deny-list (extending the function created in +-- 2026-07-18-action-intents.sql and already extended once in +-- 2026-08-06-e-action-intents-origin-principal.sql — CREATE OR REPLACE on +-- the existing function, no DROP/CREATE TRIGGER needed since the trigger +-- itself is unchanged, only the function body it points to). Existing +-- content columns are otherwise unchanged. approval_expires_at/release_by +-- are lifecycle columns and are deliberately NOT added to the deny-list — +-- release_by must remain writable when the decide-path stamps it, matching +-- the execution_started_at precedent from 2026-07-19. +-- +-- Idempotent throughout: ADD COLUMN IF NOT EXISTS, DO-guarded constraint add, +-- CREATE OR REPLACE FUNCTION. autoMigrate wraps this file in one transaction +-- — no inner BEGIN/COMMIT. + +ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS approval_scope text NOT NULL DEFAULT 'four_eyes'; +ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS classification_version integer NOT NULL DEFAULT 0; +ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS approval_expires_at timestamptz; +ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS release_by timestamptz; +ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS effect_digest char(64); + +DO $$ BEGIN + ALTER TABLE action_intents ADD CONSTRAINT action_intents_approval_scope_chk + CHECK (approval_scope IN ('supervised','four_eyes')); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- Backfill: pre-split rows are legacy four-eyes (spec §9.1); their approval +-- deadline is the old single deadline. approval_scope/classification_version +-- already land on these rows via the DEFAULTs above. +UPDATE action_intents SET approval_expires_at = expires_at + WHERE approval_expires_at IS NULL; + +-- Extend the immutability trigger's content deny-list: approval_scope, +-- classification_version, and effect_digest are decided once at creation and +-- must never be edited afterward (an editable approval_scope would let an +-- intent switch classification after approvers have already acted on the +-- original scope). release_by and approval_expires_at are intentionally +-- excluded — see header. +CREATE OR REPLACE FUNCTION action_intents_block_content_update() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.org_id IS DISTINCT FROM OLD.org_id + OR NEW.requested_by_user_id IS DISTINCT FROM OLD.requested_by_user_id + OR NEW.requesting_api_key_id IS DISTINCT FROM OLD.requesting_api_key_id + OR NEW.source IS DISTINCT FROM OLD.source + OR NEW.origin_principal_kind IS DISTINCT FROM OLD.origin_principal_kind + OR NEW.origin_principal_id IS DISTINCT FROM OLD.origin_principal_id + OR NEW.action_name IS DISTINCT FROM OLD.action_name + OR NEW.action_version IS DISTINCT FROM OLD.action_version + OR NEW.arguments IS DISTINCT FROM OLD.arguments + OR NEW.argument_digest IS DISTINCT FROM OLD.argument_digest + OR NEW.target_summary IS DISTINCT FROM OLD.target_summary + OR NEW.impact_summary IS DISTINCT FROM OLD.impact_summary + OR NEW.reason IS DISTINCT FROM OLD.reason + OR NEW.risk_tier IS DISTINCT FROM OLD.risk_tier + OR NEW.connection_id IS DISTINCT FROM OLD.connection_id + OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id + OR NEW.idempotency_key IS DISTINCT FROM OLD.idempotency_key + OR NEW.correlation_id IS DISTINCT FROM OLD.correlation_id + OR NEW.created_at IS DISTINCT FROM OLD.created_at + OR NEW.expires_at IS DISTINCT FROM OLD.expires_at + OR NEW.approval_scope IS DISTINCT FROM OLD.approval_scope + OR NEW.classification_version IS DISTINCT FROM OLD.classification_version + OR NEW.effect_digest IS DISTINCT FROM OLD.effect_digest THEN + RAISE EXCEPTION 'action_intents content is immutable'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/apps/api/src/db/schema/actionIntents.test.ts b/apps/api/src/db/schema/actionIntents.test.ts index a92081092..7132d6958 100644 --- a/apps/api/src/db/schema/actionIntents.test.ts +++ b/apps/api/src/db/schema/actionIntents.test.ts @@ -5,6 +5,7 @@ import { intentOutbox, actionIntentStatusEnum, actionIntentSourceEnum, + actionIntentApprovalScopeEnum, intentOutboxEventEnum, } from './actionIntents'; import { approvalRequests } from './approvals'; @@ -36,6 +37,12 @@ describe('intentOutboxEventEnum', () => { }); }); +describe('actionIntentApprovalScopeEnum', () => { + it('has exactly supervised and four_eyes', () => { + expect(actionIntentApprovalScopeEnum).toEqual(['supervised', 'four_eyes']); + }); +}); + describe('action_intents schema', () => { it('exposes the identity/attribution columns', () => { const cols = getTableColumns(actionIntents); @@ -108,6 +115,24 @@ describe('action_intents schema', () => { expect(cols.errorCode).toBeDefined(); }); + it('exposes the supervised/four_eyes classification and split-deadline columns', () => { + const cols = getTableColumns(actionIntents); + // Immutable classification content, decided once at creation. + expect(cols.approvalScope).toBeDefined(); + expect(cols.approvalScope.notNull).toBe(true); + expect(cols.approvalScope.default).toBe('four_eyes'); + expect(cols.classificationVersion).toBeDefined(); + expect(cols.classificationVersion.notNull).toBe(true); + expect(cols.classificationVersion.default).toBe(0); + expect(cols.effectDigest).toBeDefined(); + expect(cols.effectDigest.notNull).toBe(false); + // Lifecycle: mutable, NOT covered by the immutability trigger. + expect(cols.approvalExpiresAt).toBeDefined(); + expect(cols.approvalExpiresAt.notNull).toBe(false); + expect(cols.releaseBy).toBeDefined(); + expect(cols.releaseBy.notNull).toBe(false); + }); + it('has no extra/missing top-level columns', () => { const cols = Object.keys(getTableColumns(actionIntents)).sort(); expect(cols).toEqual( @@ -133,9 +158,14 @@ describe('action_intents schema', () => { 'tenantId', 'idempotencyKey', 'correlationId', + 'approvalScope', + 'classificationVersion', + 'effectDigest', 'status', 'createdAt', 'expiresAt', + 'approvalExpiresAt', + 'releaseBy', 'decidedAt', 'decidedByUserId', 'decidedAssuranceLevel', diff --git a/apps/api/src/db/schema/actionIntents.ts b/apps/api/src/db/schema/actionIntents.ts index 2b6b60c14..297875df9 100644 --- a/apps/api/src/db/schema/actionIntents.ts +++ b/apps/api/src/db/schema/actionIntents.ts @@ -69,6 +69,14 @@ export type ActionIntentOriginPrincipalKind = export const intentOutboxEventEnum = ['intent_created', 'intent_approved'] as const; export type IntentOutboxEvent = (typeof intentOutboxEventEnum)[number]; +/** + * Tier-3 supervised/four_eyes classification (spec + * docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md + * §4.1). Decided once by checkGuardrails at createIntent time. + */ +export const actionIntentApprovalScopeEnum = ['supervised', 'four_eyes'] as const; +export type ActionIntentApprovalScope = (typeof actionIntentApprovalScopeEnum)[number]; + export const actionIntents = pgTable( 'action_intents', { @@ -130,11 +138,43 @@ export const actionIntents = pgTable( tenantId: uuid('tenant_id'), idempotencyKey: text('idempotency_key').notNull(), correlationId: uuid('correlation_id').notNull(), + /** + * Tier-3 classification from checkGuardrails, decided once at creation. + * Immutable — covered by action_intents_immutable_trg (extended in + * 2026-08-14-intent-approval-scope-and-deadlines.sql). Live + * pre-migration rows backfill as 'four_eyes' via the column DEFAULT. + */ + approvalScope: text('approval_scope') + .notNull() + .default('four_eyes') + .$type(), + /** Version of the classification ruleset that produced approvalScope. Immutable. */ + classificationVersion: integer('classification_version').notNull().default(0), + /** + * Content-pinning digest for four_eyes intents (script content hash / + * quote-invoice revision / target state-version), pinned at creation and + * revalidated by the release worker (content_changed on drift). + * Supervised intents leave this NULL. Immutable. + */ + effectDigest: char('effect_digest', { length: 64 }), // Lifecycle (mutable). status: text('status').notNull().default('pending_approval').$type(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + /** + * Pending-approval deadline, split out of expiresAt (advisor-confirmed + * trap: a single expires_at could reap an intent approved at 59:59 + * before the release worker claims it). Backfilled from expiresAt for + * rows that predate the split. + */ + approvalExpiresAt: timestamp('approval_expires_at', { withTimezone: true }), + /** + * Execution lease deadline, stamped atomically by the decide-path when + * an approval wins. The reaper expires on approvalExpiresAt for pending + * intents and releaseBy for approved ones. + */ + releaseBy: timestamp('release_by', { withTimezone: true }), decidedAt: timestamp('decided_at', { withTimezone: true }), decidedByUserId: uuid('decided_by_user_id').references(() => users.id, { onDelete: 'set null', diff --git a/apps/api/src/services/tenantExportPolicyRegistry.ts b/apps/api/src/services/tenantExportPolicyRegistry.ts index dc46d3a00..be0174c1a 100644 --- a/apps/api/src/services/tenantExportPolicyRegistry.ts +++ b/apps/api/src/services/tenantExportPolicyRegistry.ts @@ -41,7 +41,7 @@ export function tablePolicy( export const CORE_TENANT_EXPORT_POLICY: TenantExportPolicyRegistry = { "access_reviews": tablePolicy("org_id", {"included":["id","partner_id","org_id","name","description","status","reviewer_id","due_date","created_at","updated_at","completed_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}), "account_deletion_requests": tablePolicy("org_id", {"included":["id","user_id","org_id","reason","status","requested_at","process_by","processed_at","processed_by","admin_note","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}), - "action_intents": tablePolicy("org_id", {"included":["id","org_id","partner_id","requested_by_user_id","requesting_api_key_id","source","requesting_client_label","action_name","action_version","argument_digest","target_summary","impact_summary","reason","risk_tier","connection_id","tenant_id","idempotency_key","correlation_id","status","created_at","expires_at","decided_at","decided_by_user_id","decided_assurance_level","decided_via","execution_started_at","executed_at","error_code"],"reviewedIncluded":["origin_principal_kind","origin_principal_id"],"excludedSensitive":[],"excludedOpen":["arguments","result"]}), + "action_intents": tablePolicy("org_id", {"included":["id","org_id","partner_id","requested_by_user_id","requesting_api_key_id","source","requesting_client_label","action_name","action_version","argument_digest","target_summary","impact_summary","reason","risk_tier","connection_id","tenant_id","idempotency_key","correlation_id","status","created_at","expires_at","decided_at","decided_by_user_id","decided_assurance_level","decided_via","execution_started_at","executed_at","error_code","approval_scope","classification_version","approval_expires_at","release_by","effect_digest"],"reviewedIncluded":["origin_principal_kind","origin_principal_id"],"excludedSensitive":[],"excludedOpen":["arguments","result"]}), "agent_logs": tablePolicy("org_id", {"included":["id","device_id","org_id","timestamp","level","component","message","agent_version","created_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["fields"]}), "ai_action_plans": tablePolicy("org_id", {"included":["id","session_id","org_id","status","current_step_index","approved_by","approved_at","completed_at","created_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["steps"]}), "ai_budgets": tablePolicy("org_id", {"included":["id","org_id","enabled","monthly_budget_cents","daily_budget_cents","max_turns_per_session","messages_per_minute_per_user","messages_per_hour_per_org","approval_mode","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["allowed_models"]}), From 504171b631f91edb7da4099713369c0d9296f1f5 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 00:57:47 -0600 Subject: [PATCH 05/17] fix(ai): exclude non-active users from intent approver fan-out resolveIntentApprovers counted disabled/invited users as eligible Tier-3 action-intent approvers on both the org-member and partner-axis candidate queries, inflating the four-eyes approver set and wrongly suppressing the sole-operator fallback. Join `users` and gate on status='active' on both queries. Co-Authored-By: Claude Fable 5 --- .../actionIntents/intentApprovers.test.ts | 83 ++++++++++++++++--- .../services/actionIntents/intentApprovers.ts | 20 ++++- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/apps/api/src/services/actionIntents/intentApprovers.test.ts b/apps/api/src/services/actionIntents/intentApprovers.test.ts index 4737af606..4581f470b 100644 --- a/apps/api/src/services/actionIntents/intentApprovers.test.ts +++ b/apps/api/src/services/actionIntents/intentApprovers.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { and, eq, inArray } from 'drizzle-orm'; vi.mock('../../db', () => ({ withSystemDbAccessContext: vi.fn(async (fn: () => Promise) => fn()), @@ -13,18 +14,22 @@ vi.mock('../../db/schema', () => ({ partnerUsers: { userId: 'user_id', partnerId: 'partner_id', roleId: 'role_id', orgAccess: 'org_access', orgIds: 'org_ids' }, rolePermissions: { roleId: 'role_id', permissionId: 'permission_id' }, permissions: { id: 'id', resource: 'resource', action: 'action' }, + users: { id: 'id', status: 'status' }, })); import { db } from '../../db'; +import { organizationUsers, partnerUsers, users } from '../../db/schema'; import { resolveIntentApprovers } from './intentApprovers'; /** * The resolver issues these selects in order: * 1. granting roles: select().from(rolePermissions).innerJoin(permissions).where() * 2. org partner: select().from(organizations).where().limit() - * 3. org members: select().from(organizationUsers).where() - * 4. partner members: select().from(partnerUsers).where() - * (4 is skipped when the org has no partner.) + * 3. org members: select().from(organizationUsers).innerJoin(users).where() + * 4. partner members: select().from(partnerUsers).innerJoin(users).where() + * (4 is skipped when the org has no partner.) Both 3 and 4 join `users` and + * require status='active' so disabled/invited accounts are never counted as + * eligible approvers (nor as the sole-operator fallback). */ function queueSelects(opts: { grantingRoles: Array<{ roleId: string }>; @@ -45,17 +50,19 @@ function queueSelects(opts: { }), } as any); + const orgMembersWhere = vi.fn().mockResolvedValue(opts.orgMembers); + const orgMembersInnerJoin = vi.fn().mockReturnValue({ where: orgMembersWhere }); vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(opts.orgMembers), - }), + from: vi.fn().mockReturnValue({ innerJoin: orgMembersInnerJoin }), } as any); + const partnerMembersWhere = vi.fn().mockResolvedValue(opts.partnerMembers); + const partnerMembersInnerJoin = vi.fn().mockReturnValue({ where: partnerMembersWhere }); vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(opts.partnerMembers), - }), + from: vi.fn().mockReturnValue({ innerJoin: partnerMembersInnerJoin }), } as any); + + return { orgMembersInnerJoin, orgMembersWhere, partnerMembersInnerJoin, partnerMembersWhere }; } describe('resolveIntentApprovers', () => { @@ -121,7 +128,7 @@ describe('resolveIntentApprovers', () => { } as any); vi.mocked(db.select).mockReturnValueOnce({ from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ userId: 'u-org' }]), + innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ userId: 'u-org' }]) }), }), } as any); @@ -141,4 +148,60 @@ describe('resolveIntentApprovers', () => { const result = await resolveIntentApprovers('org-1'); expect(result).toEqual([]); }); + + it('joins users and requires status=active on both the org-member and partner-member candidate queries', async () => { + // 'u-disabled' held an approvals:decide role but is a disabled admin — + // Postgres never returns it once the users-status join is in place, so + // the fixture reflects that (this file's mocks always model what the DB + // would already have filtered to; see header comment). + const { orgMembersInnerJoin, orgMembersWhere, partnerMembersInnerJoin, partnerMembersWhere } = queueSelects({ + grantingRoles: [{ roleId: 'role-decide' }], + org: [{ partnerId: 'partner-1' }], + orgMembers: [{ userId: 'u-active' }], + partnerMembers: [{ userId: 'u-partner-active', orgAccess: 'all', orgIds: null }], + }); + + const result = await resolveIntentApprovers('org-1'); + + expect([...result].sort()).toEqual(['u-active', 'u-partner-active']); + + // The org-member query joins `users` on organizationUsers.userId and + // gates on status='active'. + expect(orgMembersInnerJoin).toHaveBeenCalledWith(users, eq(users.id, organizationUsers.userId)); + expect(orgMembersWhere).toHaveBeenCalledWith( + and( + eq(organizationUsers.orgId, 'org-1'), + inArray(organizationUsers.roleId, ['role-decide']), + eq(users.status, 'active'), + ), + ); + + // The partner-axis query joins `users` on partnerUsers.userId and gates + // on status='active' too — this is the query CRITICAL-2 added, and the + // one most likely to be missed when patching only the org-member query. + expect(partnerMembersInnerJoin).toHaveBeenCalledWith(users, eq(users.id, partnerUsers.userId)); + expect(partnerMembersWhere).toHaveBeenCalledWith( + and( + eq(partnerUsers.partnerId, 'partner-1'), + inArray(partnerUsers.roleId, ['role-decide']), + eq(users.status, 'active'), + ), + ); + }); + + it('sole-operator implication: an active requester is the only result when every other candidate is filtered out as non-active', async () => { + // 'u-disabled-1' (org-scope) and 'u-disabled-2' (partner-scope) also hold + // the granting role but are disabled/invited, so — mirroring real DB + // behavior once the users-status join lands — only the active requester + // survives the fixture. + queueSelects({ + grantingRoles: [{ roleId: 'role-decide' }], + org: [{ partnerId: 'partner-1' }], + orgMembers: [{ userId: 'u-requester' }], + partnerMembers: [], + }); + + const result = await resolveIntentApprovers('org-1'); + expect(result).toEqual(['u-requester']); + }); }); diff --git a/apps/api/src/services/actionIntents/intentApprovers.ts b/apps/api/src/services/actionIntents/intentApprovers.ts index 6ca55d409..4e1aa2dcc 100644 --- a/apps/api/src/services/actionIntents/intentApprovers.ts +++ b/apps/api/src/services/actionIntents/intentApprovers.ts @@ -15,7 +15,12 @@ * `PERMISSIONS.APPROVALS_DECIDE` instead of `DEVICES_EXECUTE`, and WITHOUT * the mobile-device narrowing: an action-intent approver decides from the web * app or an MCP client, not necessarily a phone, so `resolveElevationApprovers`'s - * final `mobile_devices` filter has no equivalent here. + * final `mobile_devices` filter has no equivalent here. One deliberate + * divergence: both candidate queries here additionally join `users` and + * require status='active' (`resolveElevationApprovers` does not) — a + * disabled or still-invited account can hold a granting role but must never + * be counted as an eligible approver, since that both inflates the + * four-eyes fan-out and can wrongly suppress the sole-operator fallback. * * Runs under a system DB access context: this reads role_permissions, * permissions, organization_users, partner_users, and organizations — RLS- @@ -35,6 +40,7 @@ import { partnerUsers, rolePermissions, permissions, + users, } from '../../db/schema'; import { PERMISSIONS } from '../permissions'; @@ -74,14 +80,21 @@ export async function resolveIntentApprovers(orgId: string): Promise { const candidateUserIds = new Set(); - // 1. Direct org members holding an approvals:decide role. + // 1. Direct org members holding an approvals:decide role. Joined against + // `users` and gated on status='active' so a disabled or still-invited + // account is never counted as an eligible approver — it both inflates + // the four-eyes fan-out with someone who can't actually decide, and can + // wrongly suppress the sole-operator fallback (intentService.ts) by + // making it look like a second approver exists when none does. const orgMembers = await db .select({ userId: organizationUsers.userId }) .from(organizationUsers) + .innerJoin(users, eq(users.id, organizationUsers.userId)) .where( and( eq(organizationUsers.orgId, orgId), inArray(organizationUsers.roleId, grantingRoleIds), + eq(users.status, 'active'), ), ); for (const m of orgMembers) candidateUserIds.add(m.userId); @@ -89,6 +102,7 @@ export async function resolveIntentApprovers(orgId: string): Promise { // 2. Partner members of the org's partner whose org_access covers this // org — the population plain organization_users membership can never see // (CRITICAL-2: partner techs/admins have no organization_users row). + // Same `users` join + status='active' gate as above. if (org?.partnerId) { const partnerMembers = await db .select({ @@ -97,10 +111,12 @@ export async function resolveIntentApprovers(orgId: string): Promise { orgIds: partnerUsers.orgIds, }) .from(partnerUsers) + .innerJoin(users, eq(users.id, partnerUsers.userId)) .where( and( eq(partnerUsers.partnerId, org.partnerId), inArray(partnerUsers.roleId, grantingRoleIds), + eq(users.status, 'active'), ), ); for (const m of partnerMembers) { From 3e3b50a7956f8f2891616ce30bd881c834f5e4a3 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 01:09:04 -0600 Subject: [PATCH 06/17] =?UTF-8?q?feat(ai):=20scope-aware=20intent=20creati?= =?UTF-8?q?on=20=E2=80=94=20supervised=20self=20fan-out,=20split=20deadlin?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 of the tier3-supervised-four-eyes backend plan. createActionIntent now reads checkGuardrails' approvalScope, stamps approvalScope/classificationVersion on the intent, and short-circuits supervised intents to a single requester-owned approval row (no approvals:decide required, no push). Four-eyes chat deadlines extend to 60 minutes; supervised keeps the existing 5-minute window. Legacy expiresAt is dual-written alongside the new approvalExpiresAt column for rolling-upgrade compat (removal tracked as Plan 3 cleanup). Co-Authored-By: Claude Fable 5 --- .../actionIntents/intentService.test.ts | 177 +++++++++++++++++- .../services/actionIntents/intentService.ts | 97 ++++++++-- .../services/aiAgentSdk.approvalWait.test.ts | 2 + apps/api/src/services/aiAgentSdk.test.ts | 2 + 4 files changed, 263 insertions(+), 15 deletions(-) diff --git a/apps/api/src/services/actionIntents/intentService.test.ts b/apps/api/src/services/actionIntents/intentService.test.ts index ac5d8f43d..374ce91d5 100644 --- a/apps/api/src/services/actionIntents/intentService.test.ts +++ b/apps/api/src/services/actionIntents/intentService.test.ts @@ -24,7 +24,13 @@ const { schema, dbState, authMock, guardrailMock, aiToolsState, permState, pushS return { schema: { actionIntentsTbl, approvalRequestsTbl, intentOutboxTbl }, dbState: { - insertActionIntentsResults: [] as unknown[][], + // Most entries are a plain queued row array (existing convention). A + // few new scope/deadline tests instead queue a FUNCTION that receives + // the actual `.values(...)` the service passed to db.insert(...) — so + // the "returned" row echoes back whatever computeExpiresAt / + // approvalScope the service really computed, instead of a value the + // test pre-baked independently of production logic. + insertActionIntentsResults: [] as Array) => unknown[])>, insertApprovalRequestsResults: [] as unknown[][], selectActionIntentsResults: [] as unknown[][], selectApprovalRequestsResults: [] as unknown[][], @@ -76,10 +82,15 @@ vi.mock('../../db', () => ({ insert: vi.fn((table: unknown) => ({ values: vi.fn((values: unknown) => { if (table === schema.actionIntentsTbl) { - dbState.insertedActionIntentValues.push(values as Record); + const insertedValues = values as Record; + dbState.insertedActionIntentValues.push(insertedValues); return { onConflictDoNothing: vi.fn(() => ({ - returning: vi.fn(async () => dbState.insertActionIntentsResults.shift() ?? []), + returning: vi.fn(async () => { + const queued = dbState.insertActionIntentsResults.shift(); + if (typeof queued === 'function') return queued(insertedValues); + return queued ?? []; + }), })), }; } @@ -255,9 +266,17 @@ function makeIntentRow(overrides?: Record) { tenantId: null, idempotencyKey: 'idem-1', correlationId: 'corr-1', + // Defaults mirror the schema DEFAULT ('four_eyes' / classificationVersion + // 0) — tests exercising the tier3-supervised-four-eyes split override + // these explicitly (see createIntentWith / echoInsertedIntent below). + approvalScope: 'four_eyes', + classificationVersion: 1, + effectDigest: null, status: 'pending_approval', createdAt: new Date(), expiresAt: new Date(Date.now() + 300_000), + approvalExpiresAt: new Date(Date.now() + 300_000), + releaseBy: null, decidedAt: null, decidedByUserId: null, decidedAssuranceLevel: null, @@ -269,6 +288,24 @@ function makeIntentRow(overrides?: Record) { }; } +/** + * Queues a `db.insert(actionIntents).values(...).returning()` result that + * echoes back whatever the service actually computed (approvalScope, + * approvalExpiresAt/expiresAt, classificationVersion, ...) instead of a value + * the test pre-baked independently — so assertions on the returned snapshot + * genuinely exercise computeExpiresAt / the approvalScope resolution in + * intentService.ts, not just the id plumbing. + */ +function echoInsertedIntent(overrides?: Record) { + return (values: Record) => [ + { ...makeIntentRow(), ...values, id: 'intent-echo', ...overrides }, + ]; +} + +function msUntil(date: Date): number { + return date.getTime() - Date.now(); +} + beforeEach(() => { resetDbState(); vi.clearAllMocks(); @@ -540,6 +577,140 @@ describe('createActionIntent — approver fan-out', () => { }); }); +// --------------------------------------------------------------------------- +// Tier3 supervised/four_eyes split — scope-aware creation, fan-out, deadlines +// (spec docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md) +// --------------------------------------------------------------------------- + +describe('createActionIntent — supervised/four_eyes scope', () => { + it('supervised intent fans out a single requester-owned row and skips push', async () => { + guardrailMock.checkGuardrails.mockReturnValue({ + tier: 3, + allowed: true, + requiresApproval: true, + description: 'Run a script on one or more devices', + approvalScope: 'supervised', + }); + // Nobody is eligible — not even the requester (no approvals:decide at + // all). Supervised must still create the requester's own row: it does + // not require approvals:decide. + intentApproversState.resolveIntentApprovers.mockResolvedValueOnce([]); + dbState.insertActionIntentsResults.push(echoInsertedIntent({ id: 'intent-supervised' })); + dbState.insertApprovalRequestsResults.push([{ id: 'approval-supervised' }]); + + const snap = await createActionIntent(makeAuth(), baseInput({ idempotencyKey: 'key-supervised' })); + + expect(snap.status).toBe('pending_approval'); + expect(snap.requesterApprovalRequestId).toBeDefined(); + expect(snap.requesterApprovalRequestId).toBe('approval-supervised'); + expect(snap.fanOutUserIds).toEqual([REQUESTER_ID]); + expect(pushState.dispatchApprovalPushToTokens).not.toHaveBeenCalled(); + }); + + it('four_eyes chat intent gets a 60-minute approval deadline; supervised keeps 5', async () => { + guardrailMock.checkGuardrails.mockReturnValue({ + tier: 3, + allowed: true, + requiresApproval: true, + description: 'Run a script on one or more devices', + approvalScope: 'four_eyes', + }); + intentApproversState.resolveIntentApprovers.mockResolvedValueOnce([REQUESTER_ID]); + dbState.insertActionIntentsResults.push(echoInsertedIntent({ id: 'intent-fe-deadline' })); + dbState.insertApprovalRequestsResults.push([{ id: 'approval-fe-deadline' }]); + const fe = await createActionIntent( + makeAuth(), + baseInput({ source: 'chat', idempotencyKey: 'key-fe-deadline' }), + ); + + guardrailMock.checkGuardrails.mockReturnValue({ + tier: 3, + allowed: true, + requiresApproval: true, + description: 'Run a script on one or more devices', + approvalScope: 'supervised', + }); + intentApproversState.resolveIntentApprovers.mockResolvedValueOnce([]); + dbState.insertActionIntentsResults.push(echoInsertedIntent({ id: 'intent-sv-deadline' })); + dbState.insertApprovalRequestsResults.push([{ id: 'approval-sv-deadline' }]); + const sv = await createActionIntent( + makeAuth(), + baseInput({ source: 'chat', idempotencyKey: 'key-sv-deadline' }), + ); + + expect(msUntil(fe.approvalExpiresAt!)).toBeCloseTo(60 * 60 * 1000, -4); + expect(msUntil(sv.approvalExpiresAt!)).toBeCloseTo(5 * 60 * 1000, -4); + }); + + it('four_eyes with no other active approver keeps sole-operator fallback', async () => { + guardrailMock.checkGuardrails.mockReturnValue({ + tier: 3, + allowed: true, + requiresApproval: true, + description: 'Run a script on one or more devices', + approvalScope: 'four_eyes', + }); + // Only the requester is eligible → sole-operator single-row fan-out, + // same as the pre-split behavior (existing "creates a single + // sole-operator row" test), now asserted explicitly under four_eyes. + intentApproversState.resolveIntentApprovers.mockResolvedValueOnce([REQUESTER_ID]); + dbState.insertActionIntentsResults.push(echoInsertedIntent({ id: 'intent-fe-solo' })); + dbState.insertApprovalRequestsResults.push([{ id: 'approval-fe-solo' }]); + + const snapshot = await createActionIntent(makeAuth(), baseInput({ idempotencyKey: 'key-fe-solo' })); + + expect(snapshot.status).toBe('pending_approval'); + expect(snapshot.approvalRequestIds).toEqual(['approval-fe-solo']); + expect(snapshot.requesterApprovalRequestId).toBe('approval-fe-solo'); + expect(snapshot.fanOutUserIds).toEqual([REQUESTER_ID]); + const inserted = dbState.insertedApprovalRequestsValues[0] as Array<{ userId: string }>; + expect(inserted).toHaveLength(1); + expect(inserted[0]?.userId).toBe(REQUESTER_ID); + expect(metricsMock.recordActionIntentEvent).toHaveBeenCalledWith( + expect.objectContaining({ details: expect.objectContaining({ soleOperator: true }) }), + ); + }); + + it('persists approvalScope and classificationVersion on the inserted row', async () => { + guardrailMock.checkGuardrails.mockReturnValue({ + tier: 3, + allowed: true, + requiresApproval: true, + description: 'Run a script on one or more devices', + approvalScope: 'supervised', + }); + intentApproversState.resolveIntentApprovers.mockResolvedValueOnce([]); + dbState.insertActionIntentsResults.push(echoInsertedIntent({ id: 'intent-persist' })); + dbState.insertApprovalRequestsResults.push([{ id: 'approval-persist' }]); + + await createActionIntent(makeAuth(), baseInput({ idempotencyKey: 'key-persist' })); + + const captured = dbState.insertedActionIntentValues[0]; + expect(captured?.approvalScope).toBe('supervised'); + expect(captured?.classificationVersion).toBe(1); + // Dual-write compat: legacy `expiresAt` still carries the same value as + // the new `approvalExpiresAt` column (Plan 3 removes the legacy write). + expect(captured?.expiresAt).toEqual(captured?.approvalExpiresAt); + }); + + it('defaults an unclassified tool (no guardrail.approvalScope) to four_eyes, never the weaker supervised path', async () => { + guardrailMock.checkGuardrails.mockReturnValue({ + tier: 3, + allowed: true, + requiresApproval: true, + description: 'Run a script on one or more devices', + // approvalScope intentionally omitted. + }); + intentApproversState.resolveIntentApprovers.mockResolvedValueOnce([REQUESTER_ID]); + dbState.insertActionIntentsResults.push(echoInsertedIntent({ id: 'intent-unclassified' })); + dbState.insertApprovalRequestsResults.push([{ id: 'approval-unclassified' }]); + + await createActionIntent(makeAuth(), baseInput({ idempotencyKey: 'key-unclassified' })); + + expect(dbState.insertedActionIntentValues[0]?.approvalScope).toBe('four_eyes'); + }); +}); + // --------------------------------------------------------------------------- // Connection-hold regression (#1105 class) — approver resolution must not // run inside the write transaction. diff --git a/apps/api/src/services/actionIntents/intentService.ts b/apps/api/src/services/actionIntents/intentService.ts index b5272790b..436ca3100 100644 --- a/apps/api/src/services/actionIntents/intentService.ts +++ b/apps/api/src/services/actionIntents/intentService.ts @@ -2,7 +2,14 @@ import { randomUUID, createHash } from 'crypto'; import { and, eq, inArray, sql } from 'drizzle-orm'; import type { AssuranceLevel } from '@breeze/shared'; import { db, withDbAccessContext, withSystemDbAccessContext, type DbAccessContext } from '../../db'; -import { actionIntents, intentOutbox, type ActionIntent, type ActionIntentSource, type ActionIntentStatus } from '../../db/schema/actionIntents'; +import { + actionIntents, + intentOutbox, + type ActionIntent, + type ActionIntentApprovalScope, + type ActionIntentSource, + type ActionIntentStatus, +} from '../../db/schema/actionIntents'; import { approvalRequests } from '../../db/schema/approvals'; import { type AuthContext, dbAccessContextFromAuth } from '../../middleware/auth'; import { aiTools, resolveWritableToolOrgId } from '../aiTools'; @@ -94,12 +101,22 @@ export type ActionIntentSnapshot = { approvalRequestIds: string[]; /** * The approval_requests row fanned out to the REQUESTER, when one exists — - * i.e. the sole-operator branch (requester is the only eligible approver). - * null on a multi-approver fan-out (spec §4: the requester is excluded) and + * i.e. the sole-operator branch (requester is the only eligible approver) OR + * a supervised intent (always exactly one requester-owned row). null on a + * multi-approver four_eyes fan-out (spec §4: the requester is excluded) and * when there are no approvers. The web chat card uses this to offer an * inline L3 self-approve (WebAuthn) for exactly this row and no other. */ requesterApprovalRequestId: string | null; + /** Pending-approval deadline (Task 2's approvalExpiresAt column) — split + * from `expiresAt` by scope (see computeExpiresAt): 5min supervised-chat, + * 60min four_eyes-chat, 24h mcp_api either scope. */ + approvalExpiresAt: Date | null; + /** userIds that received a fanned-out approval row on creation, in the same + * order as approvalRequestIds. Empty on an idempotent replay (no new + * fan-out happened) and on a read via getActionIntent (fan-out is a + * creation-time concept only). */ + fanOutUserIds: string[]; }; export interface ActionIntentTransitionPatch { @@ -117,12 +134,26 @@ export interface ActionIntentTransitionPatch { // Constants // --------------------------------------------------------------------------- -// Expiry defaults (spec §3.4): chat matches the existing 5-minute -// waitForApproval UX; mcp_api gets a day since there's no live session -// blocking on it. Constants, not env vars, per the design. +// Expiry defaults (spec §3.4, extended by the tier3-supervised-four-eyes +// split design §4.2): chat matches the existing 5-minute waitForApproval UX +// for supervised intents; four_eyes chat intents get a longer 60-minute +// window since finding a second approver takes real wall-clock time. mcp_api +// gets a day regardless of scope, since there's no live session blocking on +// it. Constants, not env vars, per the design. const CHAT_EXPIRY_MS = 5 * 60 * 1000; +const FOUR_EYES_CHAT_EXPIRY_MS = 60 * 60 * 1000; const MCP_EXPIRY_MS = 24 * 60 * 60 * 1000; +/** + * Version of the tier3-supervised-four-eyes classification ruleset + * (checkGuardrails' resolveApprovalScope) that produced a given intent's + * approvalScope. Stamped once at creation into action_intents.classification_version + * (Task 2) so a future ruleset change can be told apart from intents + * classified under an older version. Bump when the classification logic + * changes in a materially observable way. + */ +export const CLASSIFICATION_VERSION = 1; + const MAX_ARG_VALUE_LEN = 80; /** Canonical lowercase UUID — the only form the Postgres `uuid` binding @@ -171,14 +202,17 @@ function deriveIdempotencyKey(actorId: string, actionName: string, digest: strin return createHash('sha256').update(`${actorId}:${actionName}:${digest}`).digest('hex'); } -function computeExpiresAt(source: ActionIntentSource): Date { - return new Date(Date.now() + (source === 'chat' ? CHAT_EXPIRY_MS : MCP_EXPIRY_MS)); +function computeExpiresAt(source: ActionIntentSource, approvalScope: ActionIntentApprovalScope): Date { + if (source !== 'chat') return new Date(Date.now() + MCP_EXPIRY_MS); + const ms = approvalScope === 'supervised' ? CHAT_EXPIRY_MS : FOUR_EYES_CHAT_EXPIRY_MS; + return new Date(Date.now() + ms); } function toSnapshot( intent: ActionIntent, approvalRequestIds: string[], requesterApprovalRequestId: string | null, + fanOutUserIds: string[] = [], ): ActionIntentSnapshot { return { id: intent.id, @@ -191,6 +225,8 @@ function toSnapshot( errorCode: intent.errorCode, approvalRequestIds, requesterApprovalRequestId, + approvalExpiresAt: intent.approvalExpiresAt, + fanOutUserIds, }; } @@ -233,6 +269,12 @@ export async function createActionIntent( } const orgId = resolvedOrg.orgId; const requesterId = auth.user.id; + // Tier-3 supervised/four_eyes classification (Task 1's checkGuardrails). + // Pre-existing tools that haven't been classified yet (approvalScope + // absent) fall back to four_eyes — the stricter, pre-split behavior — never + // the weaker supervised path. Mirrors the column's own DEFAULT 'four_eyes' + // (migration 2026-08-14-intent-approval-scope-and-deadlines.sql). + const approvalScope: ActionIntentApprovalScope = guardrail.approvalScope ?? 'four_eyes'; if (input.binding) { // Both columns are Postgres `uuid`. An uppercase or malformed GUID would @@ -251,7 +293,7 @@ export async function createActionIntent( const idempotencyKey = input.idempotencyKey ?? deriveIdempotencyKey(requesterId, input.toolName, argumentDigest); const targetSummary = buildTargetSummary(input.toolName, input.input); const impactSummary = buildImpactSummary(input.toolName, guardrail); - const expiresAt = computeExpiresAt(input.source); + const expiresAt = computeExpiresAt(input.source, approvalScope); const requestingClientLabel = input.requestingClientLabel ?? (input.source === 'chat' ? 'Breeze AI' : 'MCP API client'); // Tier → riskTier mapping mirrors aiAgentSdk.ts's mobile-approval bridge. @@ -344,7 +386,17 @@ export async function createActionIntent( riskTier: guardrail.tier, idempotencyKey, correlationId: randomUUID(), + approvalScope, + classificationVersion: CLASSIFICATION_VERSION, + // `expiresAt` is the legacy column the pre-split reaper still reads; + // `approvalExpiresAt` is the new Task-2 column the post-split reaper + // reads. Dual-write the SAME value to both for rolling-upgrade + // compat during the deploy window where old and new API instances + // run side by side. Remove the `expiresAt` write (Plan 3 cleanup, + // once the reaper and every other legacy reader have migrated to + // approvalExpiresAt). expiresAt, + approvalExpiresAt: expiresAt, }) // IMPORTANT-4: action_intents_org_idem_uniq is now a PARTIAL unique // index (migration 2026-07-18-action-intents.sql) covering only LIVE @@ -422,7 +474,25 @@ export async function createActionIntent( isRecursive: false, }); - if (eligibleApprovers.length > 0) { + if (approvalScope === 'supervised') { + // Supervised short-circuit (tier3-supervised-four-eyes split design + // §4.2): exactly one approval row, always owned by the requester, + // BEFORE the eligible-approver branch below — supervised does not + // require approvals:decide at all, so this must work even when + // eligibleApprovers is empty and requesterEligible is false (the + // requester holds no approval permission whatsoever). The + // assurance-level gate is enforced later in the decide handler + // (Task 5), same as the sole-operator four_eyes branch. + const rows = await db + .insert(approvalRequests) + .values([approvalRowFor(requesterId)]) + .returning({ id: approvalRequests.id }); + if (rows[0]) { + approvalRequestIds = [rows[0].id]; + requesterApprovalRequestId = rows[0].id; + fanOutUserIds = [requesterId]; + } + } else if (eligibleApprovers.length > 0) { const rows = await db .insert(approvalRequests) .values(eligibleApprovers.map(approvalRowFor)) @@ -493,7 +563,10 @@ export async function createActionIntent( // Best-effort push AFTER the creation transaction commits (#1105) — never // hold a DB transaction open across the push network round-trip. Token // reads happen inside a fresh context per approver; the sends happen after. - if (creation.isNew && creation.intent.status === 'pending_approval') { + // Supervised intents never push: the sole row belongs to the requester + // themselves, who is already looking at the chat/MCP response that created + // it — pushing would just notify them about their own pending action. + if (creation.isNew && creation.intent.status === 'pending_approval' && creation.intent.approvalScope === 'four_eyes') { for (let i = 0; i < creation.approvalRequestIds.length; i++) { const approvalId = creation.approvalRequestIds[i]; const userId = creation.fanOutUserIds[i]; @@ -530,7 +603,7 @@ export async function createActionIntent( }); } - return toSnapshot(creation.intent, creation.approvalRequestIds, creation.requesterApprovalRequestId); + return toSnapshot(creation.intent, creation.approvalRequestIds, creation.requesterApprovalRequestId, creation.fanOutUserIds); } // --------------------------------------------------------------------------- diff --git a/apps/api/src/services/aiAgentSdk.approvalWait.test.ts b/apps/api/src/services/aiAgentSdk.approvalWait.test.ts index 0952435b6..b3cc490f4 100644 --- a/apps/api/src/services/aiAgentSdk.approvalWait.test.ts +++ b/apps/api/src/services/aiAgentSdk.approvalWait.test.ts @@ -172,6 +172,8 @@ function makeIntentSnapshot(overrides: Partial = {}): Acti errorCode: null, approvalRequestIds: ['appr-1'], requesterApprovalRequestId: null, + approvalExpiresAt: new Date(Date.now() + 300_000), + fanOutUserIds: [], ...overrides, }; } diff --git a/apps/api/src/services/aiAgentSdk.test.ts b/apps/api/src/services/aiAgentSdk.test.ts index 7e780cfb8..6e9dcf8db 100644 --- a/apps/api/src/services/aiAgentSdk.test.ts +++ b/apps/api/src/services/aiAgentSdk.test.ts @@ -246,6 +246,8 @@ function makeIntentSnapshot(overrides: Partial = {}): Acti approvalRequestIds: ['appr-1'], // Default is the FOUR-EYES case: the requester holds no approval row. requesterApprovalRequestId: null, + approvalExpiresAt: new Date(Date.now() + 300_000), + fanOutUserIds: [], ...overrides, }; } From aaa5f09be0193b4858240528ca88b110df0e1954 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 01:15:13 -0600 Subject: [PATCH 07/17] refactor(ai): dedupe single-approver insert in intentService fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract insertSingleApproverRow(userId) helper shared by the supervised short-circuit and the four_eyes sole-operator branch — both previously had byte-identical 8-line blocks to insert one approval_requests row and derive approvalRequestIds/requesterApprovalRequestId/fanOutUserIds. Behavior unchanged. Co-Authored-By: Claude Fable 5 --- .../services/actionIntents/intentService.ts | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/apps/api/src/services/actionIntents/intentService.ts b/apps/api/src/services/actionIntents/intentService.ts index 436ca3100..05e8e3b8d 100644 --- a/apps/api/src/services/actionIntents/intentService.ts +++ b/apps/api/src/services/actionIntents/intentService.ts @@ -474,6 +474,30 @@ export async function createActionIntent( isRecursive: false, }); + // Shared by the supervised short-circuit and the four_eyes sole-operator + // branch below: both create exactly one approval_requests row owned by + // a single user and derive the same trio of locals from it. + const insertSingleApproverRow = async ( + userId: string, + ): Promise<{ + approvalRequestIds: string[]; + requesterApprovalRequestId: string | null; + fanOutUserIds: string[]; + }> => { + const rows = await db + .insert(approvalRequests) + .values([approvalRowFor(userId)]) + .returning({ id: approvalRequests.id }); + if (rows[0]) { + return { + approvalRequestIds: [rows[0].id], + requesterApprovalRequestId: rows[0].id, + fanOutUserIds: [userId], + }; + } + return { approvalRequestIds: [], requesterApprovalRequestId: null, fanOutUserIds: [] }; + }; + if (approvalScope === 'supervised') { // Supervised short-circuit (tier3-supervised-four-eyes split design // §4.2): exactly one approval row, always owned by the requester, @@ -483,15 +507,8 @@ export async function createActionIntent( // requester holds no approval permission whatsoever). The // assurance-level gate is enforced later in the decide handler // (Task 5), same as the sole-operator four_eyes branch. - const rows = await db - .insert(approvalRequests) - .values([approvalRowFor(requesterId)]) - .returning({ id: approvalRequests.id }); - if (rows[0]) { - approvalRequestIds = [rows[0].id]; - requesterApprovalRequestId = rows[0].id; - fanOutUserIds = [requesterId]; - } + ({ approvalRequestIds, requesterApprovalRequestId, fanOutUserIds } = + await insertSingleApproverRow(requesterId)); } else if (eligibleApprovers.length > 0) { const rows = await db .insert(approvalRequests) @@ -503,15 +520,8 @@ export async function createActionIntent( // Sole-operator branch: the only eligible approver is the requester. // Create one row carrying the digest; the assurance-level >= 3 gate is // enforced later, in the decide handler (Task 5), not here. - const rows = await db - .insert(approvalRequests) - .values([approvalRowFor(requesterId)]) - .returning({ id: approvalRequests.id }); - if (rows[0]) { - approvalRequestIds = [rows[0].id]; - requesterApprovalRequestId = rows[0].id; - fanOutUserIds = [requesterId]; - } + ({ approvalRequestIds, requesterApprovalRequestId, fanOutUserIds } = + await insertSingleApproverRow(requesterId)); } let finalIntent: ActionIntent = inserted; From 093f8cc84b36d86152dd3c63e072d9bb04f7156e Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 01:32:13 -0600 Subject: [PATCH 08/17] fix(ai): split intent expiry into approval deadline + release lease pending_approval intents now expire on approval_expires_at while approved intents expire on release_by (falling back to expires_at for legacy rows), in both the expiry reaper and the release worker's claim CAS. The approve fan-in stamps release_by = now() + RELEASE_LEASE_MS (10m) on an approval win, so an intent approved just before approval_expires_at gets a fresh lease instead of being reaped for a deadline that no longer governs it (the "59:59 trap"). Co-Authored-By: Claude Fable 5 --- .../intentExpiryReaper.integration.test.ts | 152 +++++++++++++++++- apps/api/src/jobs/intentExpiryReaper.test.ts | 74 +++++++++ apps/api/src/jobs/intentExpiryReaper.ts | 47 ++++-- apps/api/src/jobs/intentReleaseWorker.test.ts | 28 ++++ apps/api/src/jobs/intentReleaseWorker.ts | 15 +- apps/api/src/routes/approvals.test.ts | 26 ++- apps/api/src/routes/approvals.ts | 8 + .../actionIntents/intentService.test.ts | 51 ++++++ .../services/actionIntents/intentService.ts | 29 +++- 9 files changed, 403 insertions(+), 27 deletions(-) diff --git a/apps/api/src/jobs/intentExpiryReaper.integration.test.ts b/apps/api/src/jobs/intentExpiryReaper.integration.test.ts index 4d47b5c25..71beae67b 100644 --- a/apps/api/src/jobs/intentExpiryReaper.integration.test.ts +++ b/apps/api/src/jobs/intentExpiryReaper.integration.test.ts @@ -8,6 +8,14 @@ * text was built, but can't prove the `COALESCE(execution_started_at, * decided_at) < now() - interval` predicate actually selects the right rows * against a real Postgres `now()` — that requires this real-driver test. + * + * Also covers `reapExpiredIntents`' status-split deadline (tier3-supervised- + * four-eyes design §4.2) for the same reason: the mocked unit suite can only + * assert the SQL text shape, not that Postgres actually excludes/includes the + * right rows around the "59:59 trap" — an intent approved just before + * `approval_expires_at` gets a fresh `release_by` lease and must survive a + * pass even though `approval_expires_at` (which no longer governs an approved + * row) has since passed. */ import '../__tests__/integration/setup'; import { describe, it, expect, beforeEach } from 'vitest'; @@ -15,7 +23,7 @@ import { randomUUID } from 'crypto'; import { eq } from 'drizzle-orm'; import { db, withSystemDbAccessContext } from '../db'; import { actionIntents } from '../db/schema/actionIntents'; -import { reapStaleExecutingIntents } from './intentExpiryReaper'; +import { reapStaleExecutingIntents, reapExpiredIntents } from './intentExpiryReaper'; import { createPartner, createOrganization, createUser } from '../__tests__/integration/db-utils'; describe('reapStaleExecutingIntents (real PG)', () => { @@ -101,3 +109,145 @@ describe('reapStaleExecutingIntents (real PG)', () => { expect(freshRow.errorCode).toBeNull(); }); }); + +describe('reapExpiredIntents (real PG) — status-split deadline', () => { + let orgId: string; + let requestedByUserId: string; + + beforeEach(async () => { + const partner = await createPartner(); + const org = await createOrganization({ partnerId: partner.id }); + orgId = org.id; + const user = await createUser({ partnerId: partner.id, orgId: org.id }); + requestedByUserId = user.id; + }); + + async function seedIntent(fields: { + status: 'pending_approval' | 'approved'; + approvalExpiresAt: Date; + releaseBy: Date | null; + expiresAt: Date; + }): Promise { + return withSystemDbAccessContext(async () => { + const [row] = await db + .insert(actionIntents) + .values({ + orgId, + requestedByUserId, + source: 'chat', + actionName: 'execute_command', + arguments: {}, + argumentDigest: 'a'.repeat(64), + targetSummary: 't', + impactSummary: 'i', + riskTier: 3, + idempotencyKey: randomUUID(), + correlationId: randomUUID(), + status: fields.status, + expiresAt: fields.expiresAt, + approvalExpiresAt: fields.approvalExpiresAt, + releaseBy: fields.releaseBy, + }) + .returning({ id: actionIntents.id }); + return row!.id; + }); + } + + const readStatus = async (id: string) => + withSystemDbAccessContext(async () => { + const [r] = await db + .select({ status: actionIntents.status }) + .from(actionIntents) + .where(eq(actionIntents.id, id)) + .limit(1); + return r!.status; + }); + + it('the 59:59 trap: an approved intent past approval_expires_at but with release_by still in the future is NOT reaped', async () => { + const past = new Date(Date.now() - 60_000); + const future = new Date(Date.now() + 5 * 60_000); + + // Approved just before its approval deadline: approval_expires_at is + // already in the past, but release_by (the fresh lease stamped at + // approval time) still has minutes left. Must survive the sweep. + const trapId = await seedIntent({ + status: 'approved', + approvalExpiresAt: past, + releaseBy: future, + expiresAt: past, + }); + + const n = await withSystemDbAccessContext(() => reapExpiredIntents()); + + expect(n).toBe(0); + expect(await readStatus(trapId)).toBe('approved'); + }); + + it('reaps a pending_approval intent whose approval_expires_at has passed', async () => { + const past = new Date(Date.now() - 60_000); + const future = new Date(Date.now() + 3_600_000); + + const id = await seedIntent({ + status: 'pending_approval', + approvalExpiresAt: past, + releaseBy: null, + expiresAt: future, + }); + + const n = await withSystemDbAccessContext(() => reapExpiredIntents()); + + expect(n).toBe(1); + expect(await readStatus(id)).toBe('expired'); + }); + + it('reaps an approved intent once release_by has passed', async () => { + const past = new Date(Date.now() - 60_000); + const future = new Date(Date.now() + 3_600_000); + + const id = await seedIntent({ + status: 'approved', + approvalExpiresAt: future, + releaseBy: past, + expiresAt: future, + }); + + const n = await withSystemDbAccessContext(() => reapExpiredIntents()); + + expect(n).toBe(1); + expect(await readStatus(id)).toBe('expired'); + }); + + it('reaps a legacy approved intent with no release_by via the expires_at fallback', async () => { + const past = new Date(Date.now() - 60_000); + const future = new Date(Date.now() + 3_600_000); + + const id = await seedIntent({ + status: 'approved', + approvalExpiresAt: future, + releaseBy: null, + expiresAt: past, + }); + + const n = await withSystemDbAccessContext(() => reapExpiredIntents()); + + expect(n).toBe(1); + expect(await readStatus(id)).toBe('expired'); + }); + + it('does not reap a legacy approved intent whose expires_at fallback is still in the future', async () => { + const past = new Date(Date.now() - 60_000); + const future = new Date(Date.now() + 3_600_000); + + const id = await seedIntent({ + status: 'approved', + approvalExpiresAt: past, + releaseBy: null, + expiresAt: future, + }); + + const n = await withSystemDbAccessContext(() => reapExpiredIntents()); + + expect(n).toBe(0); + expect(await readStatus(id)).toBe('approved'); + }); +}); diff --git a/apps/api/src/jobs/intentExpiryReaper.test.ts b/apps/api/src/jobs/intentExpiryReaper.test.ts index 61198037d..fcac0a304 100644 --- a/apps/api/src/jobs/intentExpiryReaper.test.ts +++ b/apps/api/src/jobs/intentExpiryReaper.test.ts @@ -56,6 +56,25 @@ function makeUpdateChain(returningValue: unknown = undefined) { return { set, where }; } +/** + * Flattens a drizzle sql`` object to its static SQL text (StringChunks and + * interpolated column identifiers only — bound params contribute nothing). + * Same introspection approach as ticketSlaWorker.test.ts's `sqlText`. + */ +function sqlText(q: unknown): string { + if (q == null) return ''; + if (typeof q === 'string') return q; + const obj = q as { queryChunks?: unknown[]; value?: unknown[]; name?: string }; + if (Array.isArray(obj.queryChunks)) { + return obj.queryChunks.map(sqlText).join(' '); + } + if (Array.isArray(obj.value)) { + return (obj.value as string[]).join(''); + } + if (typeof obj.name === 'string') return obj.name; + return ''; +} + describe('intentExpiryReaper.reapExpiredIntents', () => { beforeEach(() => { vi.resetAllMocks(); @@ -151,6 +170,61 @@ describe('intentExpiryReaper.reapExpiredIntents', () => { expect(reaped).toBe(2); expect(recordActionIntentEvent).toHaveBeenCalledTimes(2); }); + + it('splits the deadline by status: pending_approval on approval_expires_at, approved on release_by falling back to expires_at', async () => { + executeMock.mockResolvedValueOnce({ rows: [] }); + + await reapExpiredIntents(); + + const query = sqlText(executeMock.mock.calls[0]?.[0]); + // pending_approval branch checks approval_expires_at, never expires_at. + expect(query).toMatch(/status\s*=\s*'pending_approval'\s+AND\s+approval_expires_at\s*<\s*now\(\)/); + // approved branch checks release_by with an expires_at fallback for + // legacy rows that predate the release-lease column. + expect(query).toContain('COALESCE( release_by , expires_at )'); + expect(query).toMatch(/status\s*=\s*'approved'\s+AND\s+COALESCE/); + }); + + it('the 59:59 trap: an approved intent past approval_expires_at but with releaseBy still in the future is NOT reaped', async () => { + // The reaper's WHERE clause runs against a real database and this test + // mocks db.execute at the boundary, so it cannot exercise Postgres's + // actual row filtering. What it CAN prove — and what regresses the trap + // if broken — is that the approved branch's predicate is anchored on + // release_by (COALESCE'd with expires_at), not approval_expires_at. If a + // future edit swapped that back to approval_expires_at, this assertion + // fails; the query-shape assertions above are the regression guard for + // this exact scenario, verified end-to-end by the RLS/integration suite. + executeMock.mockResolvedValueOnce({ rows: [] }); + + await reapExpiredIntents(); + + const query = sqlText(executeMock.mock.calls[0]?.[0]); + expect(query).not.toMatch(/status\s*=\s*'approved'\s+AND\s+approval_expires_at\s*<\s*now\(\)/); + }); + + it('reaps an approved intent once release_by has passed', async () => { + const past = new Date(Date.now() - 60_000); + executeMock.mockResolvedValueOnce({ + rows: [ + { + id: 'intent-approved-leased', + org_id: 'org-1', + action_name: 'breeze.c', + argument_digest: 'd3', + source: 'chat', + requested_by_user_id: 'user-1', + expires_at: past, + }, + ], + }); + const chain = makeUpdateChain([]); + updateMock.mockReturnValue({ set: chain.set }); + + const reaped = await reapExpiredIntents(); + + expect(reaped).toBe(1); + expect(recordActionIntentEvent).toHaveBeenCalledTimes(1); + }); }); describe('intentExpiryReaper.reapStaleExecutingIntents', () => { diff --git a/apps/api/src/jobs/intentExpiryReaper.ts b/apps/api/src/jobs/intentExpiryReaper.ts index 921f73985..1648b404d 100644 --- a/apps/api/src/jobs/intentExpiryReaper.ts +++ b/apps/api/src/jobs/intentExpiryReaper.ts @@ -25,11 +25,23 @@ import { REVEAL_WINDOW_DAYS } from '../services/actionIntents/resultSecrets'; * * Two sweeps run every pass: * - * 1. `reapExpiredIntents` — `pending_approval`/`approved` intents whose - * `expires_at` has passed → `expired`. Approval does NOT stop the clock: - * an approved-but-not-yet-released intent still expires if execution - * never begins in time. Linked `approval_requests` rows still `pending` - * for that intent are expired in the same pass. Uses + * 1. `reapExpiredIntents` — `pending_approval`/`approved` intents past their + * respective deadline → `expired`. The two statuses no longer share one + * deadline column (tier3-supervised-four-eyes design §4.2): `pending_approval` + * rows expire on `approval_expires_at` (the decide-by deadline; backfilled + * from the legacy `expires_at` for pre-split rows, so it is never null). + * `approved` rows expire on `release_by` — the fixed lease the approve + * fan-in (`routes/approvals.ts`) stamps when it flips the intent to + * `approved` — falling back to `expires_at` when `release_by IS NULL` + * (rows approved before this deploy, which never got a lease stamped). + * Approval does NOT stop the clock: an approved-but-not-yet-released + * intent still expires if execution never begins within its lease. This + * split matters at the boundary: an intent approved just before + * `approval_expires_at` gets a FRESH `release_by` lease starting from the + * approval moment, so it must NOT be reaped just because + * `approval_expires_at` (a deadline that no longer applies once approved) + * has since passed — the "59:59 trap". Linked `approval_requests` rows + * still `pending` for that intent are expired in the same pass. Uses * `recordActionIntentEvent(..., outcome: 'expired')` — `expired` is one * of the seven canonical outcomes Task 4's metrics helper models (spec * §7), so both the audit row and the Prometheus counter come from one @@ -119,20 +131,29 @@ function extractRows(result: unknown): T[] { } /** - * Flips `pending_approval`/`approved` intents whose `expires_at` is in the - * past to `expired`, expires their still-`pending` linked approval rows, and - * writes one `action_intent.expired` audit event per intent. Bounded to - * MAX_REAP_PER_RUN via a CTE so a backlog spike can't lock the table for - * too long. Returns the number of intents transitioned. + * Flips `pending_approval` intents past `approval_expires_at` and `approved` + * intents past `release_by` (falling back to `expires_at` for legacy rows + * with no lease stamped) to `expired`, expires their still-`pending` linked + * approval rows, and writes one `action_intent.expired` audit event per + * intent. Bounded to MAX_REAP_PER_RUN via a CTE so a backlog spike can't + * lock the table for too long. Returns the number of intents transitioned. */ export async function reapExpiredIntents(): Promise { const transitioned = await db.execute(sql` WITH due AS ( SELECT id FROM ${actionIntents} - WHERE ${actionIntents.status} IN ('pending_approval', 'approved') - AND ${actionIntents.expiresAt} < now() - ORDER BY ${actionIntents.expiresAt} ASC + WHERE ( + ${actionIntents.status} = 'pending_approval' + AND ${actionIntents.approvalExpiresAt} < now() + ) OR ( + ${actionIntents.status} = 'approved' + AND COALESCE(${actionIntents.releaseBy}, ${actionIntents.expiresAt}) < now() + ) + ORDER BY CASE + WHEN ${actionIntents.status} = 'pending_approval' THEN ${actionIntents.approvalExpiresAt} + ELSE COALESCE(${actionIntents.releaseBy}, ${actionIntents.expiresAt}) + END ASC LIMIT ${MAX_REAP_PER_RUN} FOR UPDATE SKIP LOCKED ) diff --git a/apps/api/src/jobs/intentReleaseWorker.test.ts b/apps/api/src/jobs/intentReleaseWorker.test.ts index 15858142b..daf0bd974 100644 --- a/apps/api/src/jobs/intentReleaseWorker.test.ts +++ b/apps/api/src/jobs/intentReleaseWorker.test.ts @@ -312,6 +312,34 @@ describe('releaseApprovedIntent', () => { expect(actorContextMock.buildAuthContextForIntent).not.toHaveBeenCalled(); }); + it('the 59:59 trap: claims and executes an intent whose approval_expires_at is already past but releaseBy is still in the future', async () => { + // The worker itself never re-derives a deadline — it always defers to + // transitionIntent's requireNotExpired predicate (COALESCE(release_by, + // expires_at) > now(), proved directly against approval_expires_at in + // intentService.test.ts and end-to-end against real Postgres in + // intentExpiryReaper.integration.test.ts). This test documents the + // worker-level contract: as long as the CAS resolves true — which it + // will here, since release_by hasn't passed even though + // approval_expires_at has — the release proceeds to execution + // regardless of approval_expires_at. + const intent = baseIntent({ + approvalExpiresAt: new Date(Date.now() - 60_000), + releaseBy: new Date(Date.now() + 5 * 60_000), + } as Partial); + primeThroughRevalidation(intent); + aiToolsMock.executeTool.mockResolvedValueOnce(JSON.stringify({ ok: true })); + intentServiceMock.transitionIntent.mockResolvedValueOnce(true); // executing -> completed + + await releaseApprovedIntent(intent.id); + + expect(intentServiceMock.transitionIntent).toHaveBeenCalledWith( + intent.id, 'approved', 'executing', + expect.objectContaining({ executedAt: null, executionStartedAt: expect.any(Date) }), + { requireNotExpired: true }, + ); + expect(aiToolsMock.executeTool).toHaveBeenCalledWith(intent.actionName, intent.arguments, fakeAuth); + }); + it('stamps execution_started_at when it claims the intent (approved -> executing)', async () => { intentServiceMock.transitionIntent.mockResolvedValueOnce(true); // claim CAS dbState.selectActionIntentsResults.push([]); // short-circuit: intent row missing after CAS diff --git a/apps/api/src/jobs/intentReleaseWorker.ts b/apps/api/src/jobs/intentReleaseWorker.ts index 6bfd2f8dd..29e3b444d 100644 --- a/apps/api/src/jobs/intentReleaseWorker.ts +++ b/apps/api/src/jobs/intentReleaseWorker.ts @@ -244,10 +244,17 @@ export async function releaseApprovedIntent(intentId: string): Promise { // (expiry, cancel, a prior delivery of this exact job, or the stale- // executing reaper already claimed it) — exit silently. This is what // makes repeated/duplicate `intent_approved` enqueues safe. - // requireNotExpired folds the deadline into the claim: an intent approved - // just before expires_at cannot be claimed for execution once past it (the - // 30s expiry reaper terminalizes the leftover approved row). Without this an - // action could execute after its authorization window closed. + // requireNotExpired folds the deadline into the claim: an approved intent + // cannot be claimed for execution once past its release_by lease (falling + // back to expires_at for legacy rows with no lease — see + // intentService.ts's transitionIntent). release_by, not + // approval_expires_at, is what governs an already-approved intent — an + // intent approved just before approval_expires_at gets a FRESH lease + // starting at approval time (the "59:59 trap" — jobs/intentExpiryReaper.ts's + // header), so it stays claimable here even though approval_expires_at has + // since passed. Once release_by itself passes, the 30s expiry reaper + // terminalizes the leftover approved row. Without this check an action + // could execute after its authorization window closed. const claimed = await transitionIntent( intentId, 'approved', diff --git a/apps/api/src/routes/approvals.test.ts b/apps/api/src/routes/approvals.test.ts index 573bba476..34bfd26b3 100644 --- a/apps/api/src/routes/approvals.test.ts +++ b/apps/api/src/routes/approvals.test.ts @@ -51,10 +51,18 @@ vi.mock('../db/schema/actionIntents', () => ({ }, })); -// Task 6: the decide handler now performs the intent CAS INLINE inside the -// single system-scoped fan-in transaction (was a separate transitionIntent -// call), so approvals.ts no longer imports intentService and there is nothing -// to mock here — the CAS is asserted directly on the mocked tx.update below. +// The decide handler performs the intent CAS INLINE inside the single +// system-scoped fan-in transaction (not a separate transitionIntent call), so +// the CAS itself is asserted directly on the mocked tx.update below. Task 5 +// adds one lightweight import from intentService.ts — the RELEASE_LEASE_MS +// constant, stamped into release_by on an approval win — so this mocks the +// module wholesale rather than letting the real one load: the real +// intentService.ts pulls in ../aiTools (and its whole dependency graph), +// which this file's narrow ../services/permissions mock below (missing the +// PERMISSIONS export routes/monitors.ts needs at import time) can't support. +vi.mock('../services/actionIntents/intentService', () => ({ + RELEASE_LEASE_MS: 10 * 60 * 1000, +})); vi.mock('../services/actionIntents/metrics', () => ({ recordActionIntentEvent: vi.fn(), @@ -1352,6 +1360,13 @@ describe('Task 5: decide-handler bound to action_intents', () => { expect(intentCasSet).toHaveBeenCalledWith( expect.objectContaining({ status: 'approved', decidedByUserId: TEST_USER.id }), ); + // Task 5: an approval win stamps the fixed release lease (release_by) in + // the same CAS, so the reaper/worker have a deadline that starts at the + // approval moment rather than inheriting the (possibly near-expired) + // approval_expires_at window — the "59:59 trap". + const stampedCas = intentCasSet.mock.calls[0]![0] as { releaseBy?: Date }; + expect(stampedCas.releaseBy).toBeInstanceOf(Date); + expect(stampedCas.releaseBy!.getTime()).toBeGreaterThan(Date.now()); expect(siblingSet).toHaveBeenCalledWith( expect.objectContaining({ status: 'expired' }), ); @@ -1426,6 +1441,9 @@ describe('Task 5: decide-handler bound to action_intents', () => { expect(intentCasSet).toHaveBeenCalledWith( expect.objectContaining({ status: 'rejected' }), ); + // A rejected intent never executes, so it gets no release lease. + const stampedCas = intentCasSet.mock.calls[0]![0] as { releaseBy?: Date }; + expect(stampedCas.releaseBy).toBeUndefined(); expect(siblingSet).toHaveBeenCalledWith( expect.objectContaining({ status: 'expired' }), ); diff --git a/apps/api/src/routes/approvals.ts b/apps/api/src/routes/approvals.ts index b3fc07ef4..83a67ac91 100644 --- a/apps/api/src/routes/approvals.ts +++ b/apps/api/src/routes/approvals.ts @@ -15,6 +15,7 @@ import { dispatchApprovalPush } from '../services/expoPush'; import { revokeUserOauthClient } from './lifecycle'; import { assertApprovalAssurance, StepUpRequiredError, ReauthRequiredError } from '../services/authenticatorAssurance'; import { recordActionIntentEvent } from '../services/actionIntents/metrics'; +import { RELEASE_LEASE_MS } from '../services/actionIntents/intentService'; import { resolveIntentApprovers } from '../services/actionIntents/intentApprovers'; import { getUserPermissions, userCanDecideApprovals, canAccessOrg } from '../services/permissions'; import { generateApprovalAssertionOptions } from '../services/approverWebAuthn'; @@ -885,6 +886,13 @@ async function decideHandler( decidedByUserId: userId, decidedAssuranceLevel: assurance.decidedAssuranceLevel, decidedVia: assurance.decidedVia, + // Fixed release lease (design §4.2), stamped only on an approval + // win — a rejected intent never executes, so it has no release + // window to bound. Task 6 makes this whole fan-in atomic with + // the release worker's claim; here it's just the field. + ...(status === 'approved' + ? { releaseBy: new Date(Date.now() + RELEASE_LEASE_MS) } + : {}), }) .where( and( diff --git a/apps/api/src/services/actionIntents/intentService.test.ts b/apps/api/src/services/actionIntents/intentService.test.ts index 374ce91d5..eedfcf3f6 100644 --- a/apps/api/src/services/actionIntents/intentService.test.ts +++ b/apps/api/src/services/actionIntents/intentService.test.ts @@ -12,6 +12,8 @@ const { schema, dbState, authMock, guardrailMock, aiToolsState, permState, pushS orgId: col('org_id'), idempotencyKey: col('idempotency_key'), status: col('status'), + expiresAt: col('expires_at'), + releaseBy: col('release_by'), }; // `userId` MUST be present here: createActionIntent projects // `approvalRequests.userId` on the idempotent-replay path and matches it @@ -183,6 +185,17 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn((...args: unknown[]) => ({ op: 'eq', args })), and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), inArray: vi.fn((...args: unknown[]) => ({ op: 'inArray', args })), + // Flattens the tagged template to its static text, substituting each + // interpolated column mock's `.name` — enough to assert which columns a + // `sql\`...\`` fragment references without a real SQL builder. + sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + op: 'sql', + text: strings.reduce( + (acc, str, i) => + acc + str + (i < values.length ? String((values[i] as { name?: string })?.name ?? values[i]) : ''), + '', + ), + })), })); // --------------------------------------------------------------------------- @@ -803,6 +816,44 @@ describe('transitionIntent', () => { executedAt: new Date('2026-01-01'), }); }); + + describe('requireNotExpired (release worker claim CAS)', () => { + it('folds COALESCE(release_by, expires_at) > now() into the where clause — not approval_expires_at', async () => { + dbState.updateActionIntentsResults.push([{ id: 'intent-1' }]); + await transitionIntent( + 'intent-1', + 'approved', + 'executing', + { executedAt: null }, + { requireNotExpired: true }, + ); + + const whereArgs = (dbState.updateActionIntentsWheres[0] as { args: unknown[] }).args; + const sqlCondition = whereArgs.find( + (c): c is { op: string; text: string } => + typeof c === 'object' && c !== null && (c as { op?: string }).op === 'sql', + ); + expect(sqlCondition).toBeDefined(); + // The 59:59 trap: this MUST be release_by (falling back to + // expires_at), never approval_expires_at — approval_expires_at stops + // governing an intent the moment it is approved (see + // jobs/intentExpiryReaper.ts's header). + expect(sqlCondition!.text).toContain('COALESCE(release_by, expires_at)'); + expect(sqlCondition!.text).not.toContain('approval_expires_at'); + expect(sqlCondition!.text).toContain('> now()'); + }); + + it('omits the expiry condition when requireNotExpired is not set', async () => { + dbState.updateActionIntentsResults.push([{ id: 'intent-1' }]); + await transitionIntent('intent-1', 'pending_approval', 'cancelled'); + + const whereArgs = (dbState.updateActionIntentsWheres[0] as { args: unknown[] }).args; + const sqlCondition = whereArgs.find( + (c) => typeof c === 'object' && c !== null && (c as { op?: string }).op === 'sql', + ); + expect(sqlCondition).toBeUndefined(); + }); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/api/src/services/actionIntents/intentService.ts b/apps/api/src/services/actionIntents/intentService.ts index 05e8e3b8d..bc6cd4439 100644 --- a/apps/api/src/services/actionIntents/intentService.ts +++ b/apps/api/src/services/actionIntents/intentService.ts @@ -144,6 +144,19 @@ const CHAT_EXPIRY_MS = 5 * 60 * 1000; const FOUR_EYES_CHAT_EXPIRY_MS = 60 * 60 * 1000; const MCP_EXPIRY_MS = 24 * 60 * 60 * 1000; +/** + * Fixed release lease (tier3-supervised-four-eyes design §4.2): how long an + * `approved` intent has to actually execute before the reaper reclaims it. + * Stamped into `release_by` by the approve fan-in + * (`routes/approvals.ts`) in the same CAS that flips the intent to + * `approved` — independent of how much of the `approval_expires_at` window + * was left when the approval landed. Without this, an intent approved with + * only seconds left on its approval deadline would have only seconds to + * execute instead of a full lease (the "59:59 trap" — see + * `jobs/intentExpiryReaper.ts`'s header). + */ +export const RELEASE_LEASE_MS = 10 * 60 * 1000; + /** * Version of the tier3-supervised-four-eyes classification ruleset * (checkGuardrails' resolveApprovalScope) that produced a given intent's @@ -706,15 +719,21 @@ export async function transitionIntent( const fromList = Array.isArray(from) ? from : [from]; return withSystemDbAccessContext(async () => { // requireNotExpired folds the deadline into the CAS predicate so a release - // claim is atomic with the intent still being live. Without it, an intent - // approved just before expires_at could be claimed approved -> executing in - // the window before the 30s expiry reaper terminalizes it, executing an + // claim is atomic with the intent still being live. The only caller today + // (the release worker's approved -> executing claim) checks the deadline + // that actually governs an APPROVED intent: release_by (falling back to + // expires_at for legacy rows approved before release_by existed), not + // approval_expires_at — that column stops applying the moment an intent + // is approved (see jobs/intentExpiryReaper.ts's header for the "59:59 + // trap" this avoids). Without this check at all, an intent approved just + // before its deadline could be claimed approved -> executing in the + // window before the 30s expiry reaper terminalizes it, executing an // action whose authorization window has already closed. Uses the DB clock // (now()) rather than a JS timestamp so the comparison is against the same - // clock that stamped expires_at. + // clock that stamped release_by/expires_at. const conditions = [eq(actionIntents.id, intentId), inArray(actionIntents.status, fromList)]; if (opts?.requireNotExpired) { - conditions.push(sql`${actionIntents.expiresAt} > now()`); + conditions.push(sql`COALESCE(${actionIntents.releaseBy}, ${actionIntents.expiresAt}) > now()`); } const rows = await db .update(actionIntents) From 0386795600729c2a3131038745fbfedc3913bfc6 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 02:11:47 -0600 Subject: [PATCH 09/17] feat(ai): supervised plain-decide branch + atomic decide transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier-3 SUPERVISED intents let the requester self-decide with no WebAuthn assertion, gated on a live re-check of the underlying tool's RBAC permission (not approvals:decide) via the same buildAuthContextForIntent + checkToolPermission pair the release worker uses. A non-requester is refused (403 not_requester) even if they hold approvals:decide. The whole decide write — approval-row CAS, ai_tool_executions mirror, and the action-intents fan-in (intent CAS + release_by + sibling expiry + intent_approved outbox insert) — now commits as ONE system-scoped transaction, so a fault anywhere inside rolls everything back and the caller gets a retryable 500 instead of a half-applied decision (approval decided with no intent/outbox follow-through). report-suspicious's intent-rejection block gets the same fail-loud treatment instead of swallowing the error. Extends aiAgentSdk.ts's ApprovalMethod audit union with 'supervised_self' so the postToolUse audit event distinguishes a supervised self-decide from a four_eyes approval via the approvals surface. Co-Authored-By: Claude Fable 5 --- apps/api/src/routes/approvals.test.ts | 591 +++++++++++--- apps/api/src/routes/approvals.ts | 731 +++++++++++------- ...provalsDecideAtomicity.integration.test.ts | 72 +- apps/api/src/services/aiAgentSdk.ts | 16 +- 4 files changed, 990 insertions(+), 420 deletions(-) diff --git a/apps/api/src/routes/approvals.test.ts b/apps/api/src/routes/approvals.test.ts index 34bfd26b3..5708d3250 100644 --- a/apps/api/src/routes/approvals.test.ts +++ b/apps/api/src/routes/approvals.test.ts @@ -68,6 +68,33 @@ vi.mock('../services/actionIntents/metrics', () => ({ recordActionIntentEvent: vi.fn(), })); +// Task 6: the supervised branch re-checks live RBAC for the underlying tool +// action via checkToolPermission (aiGuardrails) + buildAuthContextForIntent +// (actorContext) — mocked wholesale for the SAME reason intentService is +// above: the real aiGuardrails.ts pulls in ../aiTools's whole dependency +// graph, which this file's narrow ../services/permissions mock can't support. +// Defaults are the PERMISSIVE case (a still-authorized requester); the +// RBAC-revoked test overrides checkToolPermission to return a denial string. +vi.mock('../services/aiGuardrails', () => ({ + checkToolPermission: vi.fn(async () => null), +})); + +vi.mock('../services/actionIntents/actorContext', () => ({ + buildAuthContextForIntent: vi.fn(async () => ({ + principal: { kind: 'user_session' }, + user: { id: '00000000-0000-0000-0000-000000000001', email: 'req@example.com', name: 'Requester', isPlatformAdmin: false }, + token: { sub: '00000000-0000-0000-0000-000000000001', email: 'req@example.com', roleId: 'role-1', orgId: 'org-9', partnerId: null, scope: 'organization', type: 'access', mfa: true }, + partnerId: null, + orgId: 'org-9', + scope: 'organization', + accessibleOrgIds: ['org-9'], + orgCondition: () => undefined, + canAccessOrg: () => true, + allowedSiteIds: null, + canAccessSite: () => true, + })), +})); + // #2685: the decide handler RE-DERIVES the eligible approver set before // permitting a self-approve, instead of inferring sole-operator status from // "a requester-owned row exists". Default: the deciding user is the only @@ -217,6 +244,8 @@ import { requireCurrentPasswordStepUp } from './auth/helpers'; import { recordActionIntentEvent } from '../services/actionIntents/metrics'; import { getUserPermissions, userCanDecideApprovals, canAccessOrg } from '../services/permissions'; import { resolveIntentApprovers } from '../services/actionIntents/intentApprovers'; +import { checkToolPermission } from '../services/aiGuardrails'; +import { buildAuthContextForIntent } from '../services/actionIntents/actorContext'; function buildApp() { const app = new Hono(); @@ -232,15 +261,18 @@ function mockUpdateReturning(rows: unknown[]) { return set; } -// Wires the decideHandler flow: a pre-fetch select followed by the CAS update. -// Returns the captured `.set(...)` argument so callers can assert the factor -// columns persisted alongside status/decidedAt. +// Wires the decideHandler flow: a pre-fetch select followed by the Task 6 +// atomic decide-write transaction (ONE `db.transaction` call, whose `tx` does +// a SINGLE `.update(approvalRequests)` for the approval-row CAS — the plain, +// non-intent/non-execution/non-elevation-linked case). Returns the captured +// `.set(...)` argument so callers can assert the factor columns persisted +// alongside status/decidedAt. // -// Uses persistent `mockReturnValue` (NOT `mockReturnValueOnce`) on purpose: -// `vi.clearAllMocks()` in beforeEach clears call history but does NOT drain a -// queued `mockReturnValueOnce`, so an early-return decide case (404/409/410 -// never reaches the update) would otherwise leave an unconsumed update-once in -// the queue and poison a later test's `db.update`. +// Uses persistent `mockReturnValue`/`mockImplementation` (NOT the `Once` +// variants) on purpose: `vi.clearAllMocks()` in beforeEach clears call history +// but does NOT drain a queued `mockReturnValueOnce`, so an early-return decide +// case (404/409/410 never opens the transaction at all) would otherwise leave +// an unconsumed once-mock in the queue and poison a later test. function mockDecideFlow(opts: { existing: unknown | null; updateReturns: unknown[]; @@ -252,16 +284,47 @@ function mockDecideFlow(opts: { }), } as any); - // 2) CAS update — capture the set arg + // 2) the atomic decide-write transaction — capture the tx.update `.set(...)` arg const set = vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue(opts.updateReturns), }), }); - vi.mocked(db.update).mockReturnValue({ set } as any); + const tx = { update: vi.fn(() => ({ set })) }; + vi.mocked(db.transaction).mockImplementation(async (fn: any) => fn(tx)); return set; } +// Wires the Task 6 atomic decide-write tx with TWO sequential `tx.update` +// calls: (1) the approval-row CAS, (2) the ai_tool_executions mirror. Used by +// both the approve and deny ai_tool_executions-mirror tests. +function mockDecideTxWithExecutionMirror(opts: { + linkedRow: unknown; + aiReturning: unknown[]; +}) { + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ ...(opts.linkedRow as object), status: 'pending' }]), + }), + } as any); + const approvalReturning = vi.fn().mockResolvedValue([opts.linkedRow]); + const approvalSet = vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ returning: approvalReturning }), + }); + const aiReturning = vi.fn().mockResolvedValue(opts.aiReturning); + const aiSet = vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ returning: aiReturning }), + }); + const tx = { + update: vi + .fn() + .mockReturnValueOnce({ set: approvalSet } as any) // 1) approval_requests CAS + .mockReturnValueOnce({ set: aiSet } as any), // 2) ai_tool_executions mirror + }; + vi.mocked(db.transaction).mockImplementation(async (fn: any) => fn(tx)); + return { approvalSet, aiSet, aiReturning }; +} + function mockSelectResolves(rows: unknown[]) { vi.mocked(db.select).mockReturnValue({ from: vi.fn().mockReturnValue({ @@ -313,6 +376,22 @@ beforeEach(() => { // #2685: re-establish the "decider is still the only eligible approver" // default after clearAllMocks wipes the factory implementation. vi.mocked(resolveIntentApprovers).mockResolvedValue([TEST_USER.id]); + // Task 6: re-establish the supervised-branch live-RBAC defaults (permissive) + // after clearAllMocks wipes the factory implementation. + vi.mocked(checkToolPermission).mockResolvedValue(null); + vi.mocked(buildAuthContextForIntent).mockResolvedValue({ + principal: { kind: 'user_session' }, + user: { id: TEST_USER.id, email: TEST_USER.email, name: TEST_USER.name, isPlatformAdmin: false }, + token: { sub: TEST_USER.id, email: TEST_USER.email, roleId: 'role-1', orgId: 'org-9', partnerId: null, scope: 'organization', type: 'access', mfa: true }, + partnerId: null, + orgId: 'org-9', + scope: 'organization', + accessibleOrgIds: ['org-9'], + orgCondition: () => undefined, + canAccessOrg: () => true, + allowedSiteIds: null, + canAccessSite: () => true, + } as any); vi.mocked(authMiddleware).mockImplementation((c: any, next: any) => { c.set('auth', { scope: 'partner', @@ -590,24 +669,10 @@ describe('POST /approvals/:id/approve', () => { it('mirrors approval to ai_tool_executions when executionId is linked', async () => { const linkedRow = { ...updatedRow, executionId: 'exec-42' }; - // Pre-fetch select returns the pending row; first update (approval_requests) - // returns the row; second update (ai_tool_executions) just resolves. - vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ ...linkedRow, status: 'pending' }]), - }), - } as any); - const aiReturning = vi.fn().mockResolvedValue([{ id: 'exec-42' }]); - const aiSet = vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ returning: aiReturning }), + const { approvalSet, aiSet } = mockDecideTxWithExecutionMirror({ + linkedRow, + aiReturning: [{ id: 'exec-42' }], }); - const approvalReturning = vi.fn().mockResolvedValue([linkedRow]); - const approvalSet = vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ returning: approvalReturning }), - }); - vi.mocked(db.update) - .mockReturnValueOnce({ set: approvalSet } as any) - .mockReturnValueOnce({ set: aiSet } as any); const res = await buildApp().request('/approvals/a1/approve', { method: 'POST' }); expect(res.status).toBe(200); @@ -624,22 +689,7 @@ describe('POST /approvals/:id/approve', () => { // source of truth and correctly recorded THIS decision, so the decide // call must still succeed; only the mirror silently lost the race. const linkedRow = { ...updatedRow, executionId: 'exec-42' }; - vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ ...linkedRow, status: 'pending' }]), - }), - } as any); - const aiReturning = vi.fn().mockResolvedValue([]); - const aiSet = vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ returning: aiReturning }), - }); - const approvalReturning = vi.fn().mockResolvedValue([linkedRow]); - const approvalSet = vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ returning: approvalReturning }), - }); - vi.mocked(db.update) - .mockReturnValueOnce({ set: approvalSet } as any) - .mockReturnValueOnce({ set: aiSet } as any); + const { aiReturning } = mockDecideTxWithExecutionMirror({ linkedRow, aiReturning: [] }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const res = await buildApp().request('/approvals/a1/approve', { method: 'POST' }); @@ -1082,22 +1132,10 @@ describe('POST /approvals/:id/deny', () => { executionId: 'exec-77', createdAt: new Date(), }; - vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ ...deniedRow, status: 'pending' }]), - }), - } as any); - const aiReturning = vi.fn().mockResolvedValue([{ id: 'exec-77' }]); - const aiSet = vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ returning: aiReturning }), + const { aiSet } = mockDecideTxWithExecutionMirror({ + linkedRow: deniedRow, + aiReturning: [{ id: 'exec-77' }], }); - const approvalReturning = vi.fn().mockResolvedValue([deniedRow]); - const approvalSet = vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ returning: approvalReturning }), - }); - vi.mocked(db.update) - .mockReturnValueOnce({ set: approvalSet } as any) - .mockReturnValueOnce({ set: aiSet } as any); const res = await buildApp().request('/approvals/a1/deny', { method: 'POST', @@ -1112,12 +1150,14 @@ describe('POST /approvals/:id/deny', () => { }); describe('#1254 PAM mobile bridge: mirror decision back to elevation', () => { - // Builds a tx stub for db.transaction(fn). `elevationUpdateRows` is what the - // elevation CAS returns ([] = lost the race). The tx now does ONLY the - // elevation CAS (.update) + the audit insert (.values) — the sibling-expiry - // moved OUT of the tx to a post-commit system-scoped db.update (see - // mockSiblingExpireUpdate). Captures the elevation .set arg and the - // elevationAudit .values arg. + // Builds a tx stub for the SECOND db.transaction(fn) call — the elevation + // mirror tx, which runs AFTER the Task 6 atomic decide-write tx (registered + // by mockDecideWithElevation below, consumed first via mockImplementationOnce + // ordering). `elevationUpdateRows` is what the elevation CAS returns ([] = + // lost the race). The tx does ONLY the elevation CAS (.update) + the audit + // insert (.values) — the sibling-expiry runs post-commit as a separate, + // system-scoped db.update (see mockDecideWithElevation's siblingExpireSet). + // Captures the elevation .set arg and the elevationAudit .values arg. function mockElevationTx(elevationUpdateRows: unknown[]) { const elevationSet = vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue(elevationUpdateRows) }), @@ -1127,17 +1167,17 @@ describe('#1254 PAM mobile bridge: mirror decision back to elevation', () => { update: vi.fn(() => ({ set: elevationSet } as any)), insert: vi.fn(() => ({ values: auditValues } as any)), }; - vi.mocked(db.transaction).mockImplementation(async (fn: any) => fn(tx)); + vi.mocked(db.transaction).mockImplementationOnce(async (fn: any) => fn(tx)); return { elevationSet, auditValues }; } - // The decideHandler pre-fetch select + the approval_requests CAS update. The - // updated row carries elevationRequestId so the mirror block runs. - // - // On the win path the route now calls db.update TWICE: first the - // approval_requests CAS (inside decideHandler), then the post-commit - // system-scoped sibling-expiry. Wire both via mockReturnValueOnce so the - // sibling set arg is captured separately; return that captured set. + // The decideHandler pre-fetch select + the Task 6 atomic decide-write + // transaction (the FIRST db.transaction call — approval_requests CAS only, + // since an elevation-linked row never carries executionId/intentId), then + // the post-commit system-scoped sibling-expiry (a plain db.update, outside + // any tx). The updated row carries elevationRequestId so the elevation + // mirror block runs next (wired separately by mockElevationTx, the SECOND + // db.transaction call). function mockDecideWithElevation(opts: { status: 'pending'; riskTier: string; elevationRequestId: string | null }) { const updatedRow = { id: 'appr-1', @@ -1163,14 +1203,14 @@ describe('#1254 PAM mobile bridge: mirror decision back to elevation', () => { where: vi.fn().mockResolvedValue([{ ...updatedRow, status: 'pending' }]), }), } as any); - // 1) approval_requests CAS update + // 1) the atomic decide-write tx — ONE tx.update call (approval_requests CAS) const casReturning = vi.fn().mockResolvedValue([updatedRow]); const casSet = vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ returning: casReturning }) }); + const mainTx = { update: vi.fn(() => ({ set: casSet } as any)) }; + vi.mocked(db.transaction).mockImplementationOnce(async (fn: any) => fn(mainTx)); // 2) post-commit sibling-expiry (system scope) — a terminal .set().where() const siblingExpireSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); - vi.mocked(db.update) - .mockReturnValueOnce({ set: casSet } as any) - .mockReturnValueOnce({ set: siblingExpireSet } as any); + vi.mocked(db.update).mockReturnValueOnce({ set: siblingExpireSet } as any); return { updatedRow, casSet, siblingExpireSet }; } @@ -1180,7 +1220,7 @@ describe('#1254 PAM mobile bridge: mirror decision back to elevation', () => { const res = await buildApp().request('/approvals/appr-1/approve', { method: 'POST' }); expect(res.status).toBe(200); - expect(db.transaction).toHaveBeenCalledOnce(); + expect(db.transaction).toHaveBeenCalledTimes(2); expect(tx.elevationSet).toHaveBeenCalledWith( expect.objectContaining({ status: 'approved', approvedByUserId: TEST_USER.id }), ); @@ -1235,7 +1275,10 @@ describe('#1254 PAM mobile bridge: mirror decision back to elevation', () => { const res = await buildApp().request('/approvals/appr-1/approve', { method: 'POST' }); expect(res.status).toBe(200); - expect(db.transaction).not.toHaveBeenCalled(); + // Only the Task 6 atomic decide-write tx opens (the approval CAS) — the + // elevation-specific mirror transaction never does, since there is no + // elevationRequestId to mirror. + expect(db.transaction).toHaveBeenCalledTimes(1); }); it('mirror failure is non-fatal: decide still returns 200', async () => { @@ -1248,14 +1291,19 @@ describe('#1254 PAM mobile bridge: mirror decision back to elevation', () => { }); describe('Task 5: decide-handler bound to action_intents', () => { + // Shared between mockDecideWithIntent and mockIntentFanInTx (below) so the + // fan-in tx's approval-row CAS return value matches the row the pre-fetch + // select produced, without threading it through every call site. + let lastApprovalRow: Record | undefined; + // Wires the decideHandler flow for an intent-linked approval row: // 1) pre-fetch select (approval_requests, carries intentId + boundArgumentDigest) // 2) intent load select (action_intents, by id, system context) - // 3) approval_requests CAS update (the deciding user's OWN approval — the - // Task 6 intent CAS is separate, done inline in the fan-in transaction - // wired by mockIntentFanInTx). requestedByUserId defaults to someone - // OTHER than TEST_USER so the sole-operator gate doesn't fire unless a - // test opts in. + // The approval_requests CAS itself now runs inline as the FIRST tx.update + // call inside the Task 6 atomic decide-write transaction — wired by + // mockIntentFanInTx below, not here. requestedByUserId defaults to someone + // OTHER than TEST_USER so the sole-operator gate doesn't fire unless a + // test opts in. function mockDecideWithIntent(opts: { riskTier?: string; requestedByUserId?: string; @@ -1310,41 +1358,51 @@ describe('Task 5: decide-handler bound to action_intents', () => { }), } as any); - // 3) approval_requests CAS update - const casReturning = vi.fn().mockResolvedValue([{ ...approvalRow, status: 'approved' }]); - const casSet = vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ returning: casReturning }), - }); - vi.mocked(db.update).mockReturnValueOnce({ set: casSet } as any); - - return { approvalRow, intentRow, casSet }; + lastApprovalRow = approvalRow; + return { approvalRow, intentRow }; } - // Task 6: the whole intent fan-in — the intent CAS (inline, was - // transitionIntent), sibling expiry, and the intent_approved outbox insert — - // runs inside ONE `db.transaction` under system context. The tx does TWO - // updates in order (1: action_intents CAS with `.returning({ id })`, 2: - // approval_requests sibling expiry) plus, on approve, one intent_outbox - // insert. `casWins` controls whether the CAS RETURNING is non-empty; when it - // loses the race the handler returns early inside the tx (no sibling expiry, - // no outbox, no metrics). + // Task 6: the WHOLE decision write for an intent-linked row — the + // approval_requests CAS, the intent CAS (inline, was transitionIntent), + // sibling expiry, and the intent_approved outbox insert — runs inside ONE + // `db.transaction` under system context. The tx does up to THREE updates in + // order (1: approval_requests CAS for the deciding user's own row, 2: + // action_intents CAS with `.returning({ id })`, 3: approval_requests + // sibling expiry) plus, on approve, one intent_outbox insert. `casWins` + // controls whether the intent CAS RETURNING is non-empty; when it loses the + // race the handler returns early inside the tx (no sibling expiry, no + // outbox, no metrics) — the approval_requests CAS itself still always wins + // (this row is exclusively owned by the deciding user). function mockIntentFanInTx(opts: { casWins?: boolean } = {}) { const casWins = opts.casWins ?? true; + + // 1) approval_requests CAS (the deciding user's own row) + const approvalCasReturning = vi + .fn() + .mockResolvedValue([{ ...(lastApprovalRow ?? {}), status: 'approved' }]); + const approvalCasSet = vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ returning: approvalCasReturning }), + }); + + // 2) intent CAS const casReturning = vi.fn().mockResolvedValue(casWins ? [{ id: 'intent-1' }] : []); const intentCasSet = vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ returning: casReturning }), }); + + // 3) sibling expiry const siblingSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); const outboxValues = vi.fn().mockResolvedValue(undefined); const tx = { update: vi .fn() - .mockReturnValueOnce({ set: intentCasSet } as any) // 1) intent CAS - .mockReturnValueOnce({ set: siblingSet } as any), // 2) sibling expiry + .mockReturnValueOnce({ set: approvalCasSet } as any) // 1) approval_requests CAS + .mockReturnValueOnce({ set: intentCasSet } as any) // 2) intent CAS + .mockReturnValueOnce({ set: siblingSet } as any), // 3) sibling expiry insert: vi.fn(() => ({ values: outboxValues }) as any), }; vi.mocked(db.transaction).mockImplementation(async (fn: any) => fn(tx)); - return { intentCasSet, siblingSet, outboxValues, tx }; + return { approvalCasSet, intentCasSet, siblingSet, outboxValues, tx }; } it('approving an intent-linked row transitions the intent, writes an intent_approved outbox row, and expires siblings', async () => { @@ -1624,13 +1682,338 @@ describe('Task 5: decide-handler bound to action_intents', () => { const res = await buildApp().request('/approvals/a1/approve', { method: 'POST' }); expect(res.status).toBe(200); expect(set).toHaveBeenCalled(); - expect(db.transaction).not.toHaveBeenCalled(); + // The Task 6 atomic decide-write tx always opens (even for a plain row — + // it's what performs the approval-row CAS), but with only ONE tx.update + // call: no intent CAS, no sibling expiry, no outbox insert. + expect(db.transaction).toHaveBeenCalledTimes(1); expect(recordActionIntentEvent).not.toHaveBeenCalled(); - // Only the pre-fetch + CAS selects/updates ran — no intent load select. + // Only the pre-fetch select ran — no intent load select. expect(vi.mocked(db.select)).toHaveBeenCalledTimes(1); }); }); +describe('Task 6: supervised intent plain-decide branch', () => { + // Shared with mockIntentFanInTx-style wiring below: the approval_requests + // CAS return value must match the row the pre-fetch select produced. + let lastApprovalRow: Record | undefined; + + // Wires the decideHandler flow for a SUPERVISED intent-linked approval row + // (exactly one approval row, owned by the requester — Task 4). Defaults + // requestedByUserId to TEST_USER.id (the common case: the requester IS the + // decider); the non-requester test overrides it. + function mockDecideWithSupervisedIntent(opts: { + riskTier?: string; + requestedByUserId?: string; + decidingUserId?: string; + }) { + const decidingUserId = opts.decidingUserId ?? TEST_USER.id; + const approvalRow = { + id: 'appr-1', + userId: decidingUserId, + requestingClientLabel: 'Breeze AI', + requestingMachineLabel: null, + requestingClientId: null, + requestingSessionId: null, + actionLabel: 'x', + actionToolName: 'execute_command', + actionArguments: { deviceId: 'dev-1', commandType: 'kill_process' }, + riskTier: opts.riskTier ?? 'high', + riskSummary: 'z', + status: 'pending', + expiresAt: new Date(Date.now() + 60_000), + decidedAt: null, + decisionReason: null, + executionId: null, + elevationRequestId: null, + intentId: 'intent-sv-1', + boundArgumentDigest: 'digest-abc', + isRecursive: false, + createdAt: new Date(), + }; + + const intentRow = { + id: 'intent-sv-1', + orgId: 'org-9', + actionName: 'execute_command', + arguments: { deviceId: 'dev-1', commandType: 'kill_process' }, + argumentDigest: 'digest-abc', + source: 'chat', + status: 'pending_approval', + approvalScope: 'supervised', + requestedByUserId: opts.requestedByUserId ?? decidingUserId, + }; + + // 1) pre-fetch select (filtered to the DECIDING user's own row) + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([approvalRow]), + }), + } as any); + // 2) intent load select (system context, by id) + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([intentRow]), + }), + } as any); + + lastApprovalRow = approvalRow; + return { approvalRow, intentRow }; + } + + // Wires the Task 6 atomic decide-write tx for the supervised happy path: + // approval_requests CAS, intent CAS (+ release_by on approve), sibling + // expiry (a no-op here — supervised has no siblings), and the + // intent_approved outbox insert on approve. + function mockSupervisedFanInTx() { + const approvalCasReturning = vi + .fn() + .mockResolvedValue([{ ...(lastApprovalRow ?? {}), status: 'approved' }]); + const approvalCasSet = vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ returning: approvalCasReturning }), + }); + const intentCasReturning = vi.fn().mockResolvedValue([{ id: 'intent-sv-1' }]); + const intentCasSet = vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ returning: intentCasReturning }), + }); + const siblingSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); + const outboxValues = vi.fn().mockResolvedValue(undefined); + const tx = { + update: vi + .fn() + .mockReturnValueOnce({ set: approvalCasSet } as any) + .mockReturnValueOnce({ set: intentCasSet } as any) + .mockReturnValueOnce({ set: siblingSet } as any), + insert: vi.fn(() => ({ values: outboxValues }) as any), + }; + vi.mocked(db.transaction).mockImplementation(async (fn: any) => fn(tx)); + return { approvalCasSet, intentCasSet, siblingSet, outboxValues }; + } + + it('supervised requester approves with no assertion', async () => { + mockDecideWithSupervisedIntent({}); + mockSupervisedFanInTx(); + + const res = await buildApp().request('/approvals/appr-1/approve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + + expect(res.status).toBe(200); + // The whole assertion/assurance ladder is skipped for a supervised + // self-decide — no WebAuthn challenge is ever verified. + expect(assertApprovalAssurance).not.toHaveBeenCalled(); + // The live-RBAC re-check DOES run (approve only), against the underlying + // tool action — not approvals:decide. + expect(buildAuthContextForIntent).toHaveBeenCalledWith( + expect.objectContaining({ id: 'intent-sv-1', actionName: 'execute_command' }), + ); + expect(checkToolPermission).toHaveBeenCalledWith( + 'execute_command', + { deviceId: 'dev-1', commandType: 'kill_process' }, + expect.anything(), + ); + }); + + it('records session_tap/L1 (no proof consumed) on a supervised approve', async () => { + mockDecideWithSupervisedIntent({}); + const { approvalCasSet } = mockSupervisedFanInTx(); + + const res = await buildApp().request('/approvals/appr-1/approve', { method: 'POST' }); + expect(res.status).toBe(200); + expect(approvalCasSet).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'approved', + decidedVia: 'session_tap', + decidedAssuranceLevel: 1, + authenticatorDeviceId: null, + }), + ); + }); + + it('supervised row rejects a NON-requester decide even with approvals:decide', async () => { + // The row's userId happens to be the deciding user (Shape-6 self-visibility + // in production would otherwise 404 this for anyone else — see the not_requester + // gate's own comment), but the intent's requestedByUserId is a DIFFERENT + // user, simulating a non-requester somehow reaching this row. + mockDecideWithSupervisedIntent({ requestedByUserId: 'someone-else' }); + // Holding approvals:decide must NOT substitute for the identity check. + vi.mocked(userCanDecideApprovals).mockReturnValue(true); + vi.mocked(canAccessOrg).mockReturnValue(true); + + const res = await buildApp().request('/approvals/appr-1/approve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe('not_requester'); + // Fails closed before ever touching the assurance ladder or the write tx. + expect(assertApprovalAssurance).not.toHaveBeenCalled(); + expect(db.transaction).not.toHaveBeenCalled(); + }); + + it('a non-requester supervised DENY is also refused (identity gate is unconditional)', async () => { + mockDecideWithSupervisedIntent({ requestedByUserId: 'someone-else' }); + + const res = await buildApp().request('/approvals/appr-1/deny', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ reason: 'no' }), + }); + + expect(res.status).toBe(403); + expect((await res.json()).error).toBe('not_requester'); + expect(db.transaction).not.toHaveBeenCalled(); + }); + + it('supervised approve re-checks live RBAC for the underlying tool action and refuses when it was revoked', async () => { + mockDecideWithSupervisedIntent({}); + // Simulate devices:execute having been revoked from the requester between + // intent creation and decide. + vi.mocked(checkToolPermission).mockResolvedValueOnce( + 'Insufficient permissions: requires devices.execute', + ); + + const res = await buildApp().request('/approvals/appr-1/approve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe('forbidden'); + // Refused BEFORE the write transaction and before the (skipped) assurance + // ladder would have run. + expect(db.transaction).not.toHaveBeenCalled(); + expect(assertApprovalAssurance).not.toHaveBeenCalled(); + expect(recordActionIntentEvent).toHaveBeenCalledWith( + expect.objectContaining({ + intentId: 'intent-sv-1', + outcome: 'approver_unauthorized', + details: expect.objectContaining({ errorCode: 'rbac_denied' }), + }), + ); + }); + + it('a supervised DENY is never blocked by a revoked tool permission (deny is harmless)', async () => { + mockDecideWithSupervisedIntent({}); + mockSupervisedFanInTx(); + // NOTE: deliberately NOT queuing a checkToolPermission denial here — the + // assertion below is that checkToolPermission is never even CALLED on a + // deny, so a queued `mockResolvedValueOnce` would go unconsumed and poison + // a later test's call to the same mock (the exact trap this file's other + // helpers document). + + const res = await buildApp().request('/approvals/appr-1/deny', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ reason: 'changed my mind' }), + }); + + expect(res.status).toBe(200); + // checkToolPermission is gated to approve-only, so it's never even called + // on a deny. + expect(checkToolPermission).not.toHaveBeenCalled(); + }); + + it('supervised approve never requires approvals:decide (the four_eyes RBAC re-check never runs)', async () => { + mockDecideWithSupervisedIntent({}); + mockSupervisedFanInTx(); + // A requester with NO approvals:decide permission whatsoever — the + // four_eyes decider-RBAC re-check would refuse this, but supervised must + // never call it at all. NOTE: deliberately not queuing a + // getUserPermissions override here — it's asserted below to never be + // called, so a queued `mockResolvedValueOnce` would go unconsumed and + // poison a later test (the same trap `checkToolPermission` had above). + vi.mocked(userCanDecideApprovals).mockReturnValue(false); + + const res = await buildApp().request('/approvals/appr-1/approve', { method: 'POST' }); + expect(res.status).toBe(200); + expect(getUserPermissions).not.toHaveBeenCalled(); + expect(userCanDecideApprovals).not.toHaveBeenCalled(); + expect(resolveIntentApprovers).not.toHaveBeenCalled(); + // Restore the permissive default — this mock is set with a persistent + // (not "Once") override above, and a later test's four_eyes branch DOES + // call userCanDecideApprovals. + vi.mocked(userCanDecideApprovals).mockReturnValue(true); + }); + + it('four_eyes rows keep the assurance gate (unaffected by the supervised branch)', async () => { + // An explicit four_eyes intent (approvalScope set, not merely absent) — + // confirms the else-branch is still reached and still runs the full + // assertion/assurance ladder. + const approvalRow = { + id: 'appr-fe-1', + userId: TEST_USER.id, + requestingClientLabel: 'MCP API client', + requestingMachineLabel: null, + requestingClientId: null, + requestingSessionId: null, + actionLabel: 'x', + actionToolName: 'y', + actionArguments: {}, + riskTier: 'high', + riskSummary: 'z', + status: 'pending', + expiresAt: new Date(Date.now() + 60_000), + decidedAt: null, + decisionReason: null, + executionId: null, + elevationRequestId: null, + intentId: 'intent-fe-1', + boundArgumentDigest: 'digest-abc', + isRecursive: false, + createdAt: new Date(), + }; + const intentRow = { + id: 'intent-fe-1', + orgId: 'org-9', + actionName: 'y', + arguments: {}, + argumentDigest: 'digest-abc', + source: 'mcp_api', + status: 'pending_approval', + approvalScope: 'four_eyes', + requestedByUserId: 'requester-1', + }; + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([approvalRow]) }), + } as any); + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([intentRow]) }), + } as any); + const approvalCasReturning = vi.fn().mockResolvedValue([{ ...approvalRow, status: 'approved' }]); + const approvalCasSet = vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ returning: approvalCasReturning }), + }); + const intentCasSet = vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([{ id: 'intent-fe-1' }]) }), + }); + const siblingSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); + const tx = { + update: vi + .fn() + .mockReturnValueOnce({ set: approvalCasSet } as any) + .mockReturnValueOnce({ set: intentCasSet } as any) + .mockReturnValueOnce({ set: siblingSet } as any), + insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue(undefined) }) as any), + }; + vi.mocked(db.transaction).mockImplementation(async (fn: any) => fn(tx)); + + const res = await buildApp().request('/approvals/appr-fe-1/approve', { method: 'POST' }); + expect(res.status).toBe(200); + expect(assertApprovalAssurance).toHaveBeenCalledWith( + expect.objectContaining({ approvalId: 'appr-fe-1', userId: TEST_USER.id }), + ); + expect(buildAuthContextForIntent).not.toHaveBeenCalled(); + expect(checkToolPermission).not.toHaveBeenCalled(); + }); +}); + describe('POST /approvals/:id/report-suspicious', () => { const baseRow = { id: 'a1', diff --git a/apps/api/src/routes/approvals.ts b/apps/api/src/routes/approvals.ts index 83a67ac91..b7b18ca30 100644 --- a/apps/api/src/routes/approvals.ts +++ b/apps/api/src/routes/approvals.ts @@ -13,10 +13,18 @@ import { auditLogs } from '../db/schema/audit'; import { actionIntents, intentOutbox, type ActionIntent, type ActionIntentStatus } from '../db/schema/actionIntents'; import { dispatchApprovalPush } from '../services/expoPush'; import { revokeUserOauthClient } from './lifecycle'; -import { assertApprovalAssurance, StepUpRequiredError, ReauthRequiredError } from '../services/authenticatorAssurance'; +import { + assertApprovalAssurance, + resolveApprovalAssurance, + StepUpRequiredError, + ReauthRequiredError, + type AssuranceDecision, +} from '../services/authenticatorAssurance'; import { recordActionIntentEvent } from '../services/actionIntents/metrics'; import { RELEASE_LEASE_MS } from '../services/actionIntents/intentService'; import { resolveIntentApprovers } from '../services/actionIntents/intentApprovers'; +import { buildAuthContextForIntent } from '../services/actionIntents/actorContext'; +import { checkToolPermission } from '../services/aiGuardrails'; import { getUserPermissions, userCanDecideApprovals, canAccessOrg } from '../services/permissions'; import { generateApprovalAssertionOptions } from '../services/approverWebAuthn'; import { issueMobileAssertionNonce } from '../services/mobileHwKey'; @@ -320,18 +328,24 @@ approvalRoutes.post('/:id/report-suspicious', async (c) => { // rows belong to OTHER approvers, invisible to this user's request context. if (existing.intentId) { const intentId = existing.intentId; + // Atomic reject fan-in: the intent CAS (pending_approval -> rejected) + // and the sibling-approval expiry commit in ONE system-scoped + // transaction (a rejection has no intent_approved outbox row — mirror + // only what a reject writes). Collapsing them means a fan-in failure + // can no longer leave the intent rejected while a sibling approver's + // row stays live to approve the flagged action. System scope: + // action_intents is org-scoped and the sibling approval_requests rows + // belong to OTHER approvers, invisible to this user's request context. + // The CAS RETURNING carries the intent metadata for the metrics event; + // a lost race (zero rows) is a clean no-op. + // + // Task 6 "same treatment" as the main decide handler: a genuine throw + // here is NOT swallowed — it rolls this transaction back (the intent + // stays pending_approval, no sibling was expired) and the whole + // request fails with a retryable 500 BEFORE revocation/audit run, + // rather than silently leaving the intent live while still reporting + // success to the caller. try { - // Atomic reject fan-in: the intent CAS (pending_approval -> rejected) - // and the sibling-approval expiry commit in ONE system-scoped - // transaction (a rejection has no intent_approved outbox row — mirror - // only what a reject writes). Collapsing them means a swallowed - // sibling-expiry failure can no longer leave the intent rejected while - // a sibling approver's row stays live to approve the flagged action. - // System scope: action_intents is org-scoped and the sibling - // approval_requests rows belong to OTHER approvers, invisible to this - // user's request context. The CAS RETURNING carries the intent - // metadata for the metrics event; a lost race (zero rows) is a clean - // no-op. const rejected = await runOutsideDbContext(() => withSystemDbAccessContext(() => db.transaction(async (tx) => { @@ -380,7 +394,8 @@ approvalRoutes.post('/:id/report-suspicious', async (c) => { }); } } catch (err) { - console.error('[approvals] report-suspicious: failed to reject linked action intent:', err); + console.error('[approvals] report-suspicious: failed to reject linked action intent (rolled back):', err); + return c.json({ error: 'report_suspicious_failed', retryable: true }, 500); } } } @@ -481,6 +496,10 @@ async function decideHandler( // action_intents is org-scoped (Shape 1) and we want this read to succeed // regardless of the ambient request scope. let linkedIntent: ActionIntent | null = null; + // Set true only for a supervised intent's requester-owned decide (Task 6). + // Read below to skip the whole assertion/assurance ladder — supervised + // decides no WebAuthn/step-up ceremony, only the live-RBAC re-check above. + let isSupervisedSelfDecide = false; if (existing.intentId) { linkedIntent = await runOutsideDbContext(() => withSystemDbAccessContext(async () => { @@ -525,34 +544,27 @@ async function decideHandler( return c.json({ error: 'digest_mismatch' }, 409); } - // Re-check the DECIDER's live authorization before an intent-backed - // APPROVE (spec §4). The fanned-out approval_requests row (Shape-6, - // user-id-scoped) is otherwise a durable bearer capability: it was created - // for a user who held approvals:decide over the intent's org at fan-out - // time (services/actionIntents/intentApprovers.ts), but nothing re-checks - // that they STILL hold it. An Org Admin demoted to a role without - // approvals:decide (while keeping org membership, so the row stays visible) - // could otherwise still approve and drive a release. Resolve current perms - // for the intent's org and fail closed. Gated to `approved` only: a deny is - // harmless (it cancels the action) and must stay available even to a - // demoted approver. Checked BEFORE the assurance proof below so a stale - // approver never even consumes a WebAuthn challenge. Uses system context so - // the org membership/role reads resolve regardless of ambient request - // scope (partner approvers have no organization_users row). - if (status === 'approved') { - const deciderPerms = await runOutsideDbContext(() => - withSystemDbAccessContext(() => - getUserPermissions(userId, { - partnerId: c.get('auth').partnerId ?? undefined, - orgId: linkedIntent!.orgId, - }), - ), - ); - if ( - !deciderPerms || - !canAccessOrg(deciderPerms, linkedIntent.orgId) || - !userCanDecideApprovals(deciderPerms) - ) { + // Tier-3 supervised/four_eyes split (spec + // docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md + // §4.2). Supervised intents fan out exactly ONE approval row, always owned + // by the requester (services/actionIntents/intentService.ts) — no other + // approvals:decide holder is ever eligible, and none of the four_eyes + // machinery below (live approvals:decide re-check, sole-operator + // re-derivation, the WebAuthn/step-up ladder) applies to it. Branching + // here, BEFORE any of that runs, is what lets a supervised requester who + // holds no approvals:decide permission at all still decide their own row. + if (linkedIntent.approvalScope === 'supervised') { + isSupervisedSelfDecide = true; + + // Identity gate: even if a non-requester somehow reached this row (a + // future bug, manual DB tampering, or an admin-decide-any-row surface + // added later), a supervised row's whole trust model rests on "the + // requester is deciding their own action" — holding approvals:decide + // must NEVER substitute for that identity check, since supervised + // decides skip the assertion ladder entirely below. Unconditional + // (both approve and deny): nobody but the requester is ever a + // legitimate decider for this row. + if (linkedIntent.requestedByUserId !== userId) { recordActionIntentEvent({ orgId: linkedIntent.orgId, intentId: linkedIntent.id, @@ -561,54 +573,40 @@ async function decideHandler( source: linkedIntent.source, outcome: 'approver_unauthorized', actorId: userId, - details: { approvalId: existing.id }, + details: { approvalId: existing.id, errorCode: 'not_requester' }, }); - return c.json({ error: 'forbidden' }, 403); + return c.json({ error: 'not_requester' }, 403); } - // Sole-operator RE-DERIVATION (#2685). Four-eyes for a Tier-3 intent is - // otherwise decided exactly once, at fan-out - // (services/actionIntents/intentService.ts), by branch mutual exclusion: - // the multi-approver branch fans rows out to OTHER users, and only the - // sole-operator branch ever creates a requester-owned row. Nothing - // downstream re-establishes that — this handler used to infer "you were - // the only eligible approver" purely from "a row exists that you own". - // Since release is first-wins CAS, any future fan-out regression that - // leaked a requester-owned row into a multi-approver intent would let the - // requester unilaterally release it with no server-side check catching - // it. So re-derive the eligible set here and require the self-approver to - // STILL be the only eligible approver for the intent's org. - // - // This is deliberately a re-derivation, not a persisted `sole_operator` - // flag (issue #2685 option 2 over option 1): it fails closed, and "you - // are no longer the only approver, so you no longer get to self-approve" - // is what the four-eyes model implies. An intent created while solo and - // decided after the org gained a second approver is REFUSED — intended. - // A persisted flag would still let that self-approve through. - // - // Only runs on a self-approve (requester === decider), so the common - // cross-user approve pays nothing. `resolveIntentApprovers` opens its own - // system context internally (partner_users is Shape-3 partner-axis RLS, - // invisible from an org-scoped request context), so it must be called - // with runOutsideDbContext — a nested withDbAccessContext RETAINS the - // ambient context rather than elevating (db/index.ts) — and calling it - // outside any context also avoids holding a pooled connection across the - // round-trip (the #1105 connection-hold class). - // - // Ordered with the stale-approver check ABOVE the assurance proof for the - // same reason that one is: a refused decision must never consume a - // WebAuthn challenge. Gated to `approved` only — a deny stays available - // in every case, since denying only cancels the action. - if (linkedIntent.requestedByUserId === userId) { - const eligibleNow = await runOutsideDbContext(() => - resolveIntentApprovers(linkedIntent!.orgId), + // Live RBAC re-check for the underlying TOOL action (spec §4.2) — NOT + // approvals:decide, which supervised intents never require. Mirrors the + // release worker's revalidation (services/actionIntents/revalidateRelease.ts) + // so the decide-time and release-time gates can never diverge: same + // `buildAuthContextForIntent` + `checkToolPermission(actionName, + // arguments, auth)` pair. Gated to `approved` only — a deny only cancels + // the action and must stay available even to a requester who lost the + // underlying permission. System-scoped: buildAuthContextForIntent reads + // the requester's role from organization_users/partner_users, which the + // caller's own ambient request context may not make visible (partner + // approvers have no organization_users row) — and a bare (non-exited) + // withSystemDbAccessContext call from inside the ambient request context + // is a no-op passthrough (db/index.ts), so this must go through + // runOutsideDbContext to actually elevate. + if (status === 'approved') { + const revalidation = await runOutsideDbContext(() => + withSystemDbAccessContext(async () => { + const auth = await buildAuthContextForIntent(linkedIntent!); + if (!auth) return { ok: false as const, errorCode: 'actor_invalid' }; + const denial = await checkToolPermission( + linkedIntent!.actionName, + linkedIntent!.arguments, + auth, + ); + if (denial) return { ok: false as const, errorCode: 'rbac_denied', reason: denial }; + return { ok: true as const }; + }), ); - const othersEligible = eligibleNow.filter((candidate) => candidate !== userId); - // "ONLY eligible approver" is both halves: nobody else is eligible AND - // the self-approver still is. The second half is belt-and-braces over - // the live-authorization re-check above (which asks the permissions - // service rather than this resolver) — if the two ever disagree, refuse. - if (othersEligible.length > 0 || !eligibleNow.includes(userId)) { + if (!revalidation.ok) { recordActionIntentEvent({ orgId: linkedIntent.orgId, intentId: linkedIntent.id, @@ -617,15 +615,115 @@ async function decideHandler( source: linkedIntent.source, outcome: 'approver_unauthorized', actorId: userId, - details: { - approvalId: existing.id, - errorCode: 'not_sole_approver', - // Count only — never the approver ids (spec §7: ids of the - // event's own subjects, not a roster of other users). - eligibleApproverCount: eligibleNow.length, - }, + details: { approvalId: existing.id, errorCode: revalidation.errorCode }, }); - return c.json({ error: 'not_sole_approver' }, 403); + return c.json({ error: 'forbidden' }, 403); + } + } + } else { + // four_eyes (unchanged): re-check the DECIDER's live authorization before + // an intent-backed APPROVE (spec §4). The fanned-out approval_requests row + // (Shape-6, user-id-scoped) is otherwise a durable bearer capability: it + // was created for a user who held approvals:decide over the intent's org + // at fan-out time (services/actionIntents/intentApprovers.ts), but nothing + // re-checks that they STILL hold it. An Org Admin demoted to a role + // without approvals:decide (while keeping org membership, so the row + // stays visible) could otherwise still approve and drive a release. + // Resolve current perms for the intent's org and fail closed. Gated to + // `approved` only: a deny is harmless (it cancels the action) and must + // stay available even to a demoted approver. Checked BEFORE the + // assurance proof below so a stale approver never even consumes a + // WebAuthn challenge. Uses system context so the org membership/role + // reads resolve regardless of ambient request scope (partner approvers + // have no organization_users row). + if (status === 'approved') { + const deciderPerms = await runOutsideDbContext(() => + withSystemDbAccessContext(() => + getUserPermissions(userId, { + partnerId: c.get('auth').partnerId ?? undefined, + orgId: linkedIntent!.orgId, + }), + ), + ); + if ( + !deciderPerms || + !canAccessOrg(deciderPerms, linkedIntent.orgId) || + !userCanDecideApprovals(deciderPerms) + ) { + recordActionIntentEvent({ + orgId: linkedIntent.orgId, + intentId: linkedIntent.id, + actionName: linkedIntent.actionName, + argumentDigest: linkedIntent.argumentDigest, + source: linkedIntent.source, + outcome: 'approver_unauthorized', + actorId: userId, + details: { approvalId: existing.id }, + }); + return c.json({ error: 'forbidden' }, 403); + } + + // Sole-operator RE-DERIVATION (#2685). Four-eyes for a Tier-3 intent is + // otherwise decided exactly once, at fan-out + // (services/actionIntents/intentService.ts), by branch mutual exclusion: + // the multi-approver branch fans rows out to OTHER users, and only the + // sole-operator branch ever creates a requester-owned row. Nothing + // downstream re-establishes that — this handler used to infer "you were + // the only eligible approver" purely from "a row exists that you own". + // Since release is first-wins CAS, any future fan-out regression that + // leaked a requester-owned row into a multi-approver intent would let the + // requester unilaterally release it with no server-side check catching + // it. So re-derive the eligible set here and require the self-approver to + // STILL be the only eligible approver for the intent's org. + // + // This is deliberately a re-derivation, not a persisted `sole_operator` + // flag (issue #2685 option 2 over option 1): it fails closed, and "you + // are no longer the only approver, so you no longer get to self-approve" + // is what the four-eyes model implies. An intent created while solo and + // decided after the org gained a second approver is REFUSED — intended. + // A persisted flag would still let that self-approve through. + // + // Only runs on a self-approve (requester === decider), so the common + // cross-user approve pays nothing. `resolveIntentApprovers` opens its own + // system context internally (partner_users is Shape-3 partner-axis RLS, + // invisible from an org-scoped request context), so it must be called + // with runOutsideDbContext — a nested withDbAccessContext RETAINS the + // ambient context rather than elevating (db/index.ts) — and calling it + // outside any context also avoids holding a pooled connection across the + // round-trip (the #1105 connection-hold class). + // + // Ordered with the stale-approver check ABOVE the assurance proof for the + // same reason that one is: a refused decision must never consume a + // WebAuthn challenge. Gated to `approved` only — a deny stays available + // in every case, since denying only cancels the action. + if (linkedIntent.requestedByUserId === userId) { + const eligibleNow = await runOutsideDbContext(() => + resolveIntentApprovers(linkedIntent!.orgId), + ); + const othersEligible = eligibleNow.filter((candidate) => candidate !== userId); + // "ONLY eligible approver" is both halves: nobody else is eligible AND + // the self-approver still is. The second half is belt-and-braces over + // the live-authorization re-check above (which asks the permissions + // service rather than this resolver) — if the two ever disagree, refuse. + if (othersEligible.length > 0 || !eligibleNow.includes(userId)) { + recordActionIntentEvent({ + orgId: linkedIntent.orgId, + intentId: linkedIntent.id, + actionName: linkedIntent.actionName, + argumentDigest: linkedIntent.argumentDigest, + source: linkedIntent.source, + outcome: 'approver_unauthorized', + actorId: userId, + details: { + approvalId: existing.id, + errorCode: 'not_sole_approver', + // Count only — never the approver ids (spec §7: ids of the + // event's own subjects, not a roster of other users). + eligibleApproverCount: eligibleNow.length, + }, + }); + return c.json({ error: 'not_sole_approver' }, 403); + } } } } @@ -639,115 +737,232 @@ async function decideHandler( // Phase 4: an ENFORCING partner policy may reject an under-assured APPROVE // (StepUpRequiredError → 403). A deny is passed through with decision:'denied' // so it is never blocked. - let assurance; - try { - assurance = await assertApprovalAssurance({ - approvalId: id, - userId, - riskTier: existing.riskTier as RiskTier, - proof, - partnerId: c.get('auth').partnerId ?? null, - decision: status, - reauthVerified, - }); - } catch (err) { - if (err instanceof StepUpRequiredError) { - return c.json({ error: 'step_up_required', requiredLevel: err.requiredLevel }, 403); - } - if (err instanceof ReauthRequiredError) { - // Critical (L4) approve with a valid signature but no fresh re-auth — tell - // the client to re-collect the password and retry, not a generic failure. - return c.json({ error: 'reauth_required' }, 401); + // + // Supervised self-decide (Task 6): skip the WHOLE assertion/assurance ladder + // — no WebAuthn challenge, no partner-policy step-up floor, no + // ReauthRequiredError ceremony. `resolveApprovalAssurance` is the same + // synchronous "no proof presented" L1/session_tap default `proof===undefined` + // resolves to today; reusing it (rather than hand-rolling the literal) keeps + // the recorded factor byte-for-byte identical to that existing no-proof shape + // and impossible to drift from `assertDecisionConsistent`'s invariants. + let assurance: AssuranceDecision; + if (isSupervisedSelfDecide) { + assurance = resolveApprovalAssurance(existing.riskTier as RiskTier); + } else { + try { + assurance = await assertApprovalAssurance({ + approvalId: id, + userId, + riskTier: existing.riskTier as RiskTier, + proof, + partnerId: c.get('auth').partnerId ?? null, + decision: status, + reauthVerified, + }); + } catch (err) { + if (err instanceof StepUpRequiredError) { + return c.json({ error: 'step_up_required', requiredLevel: err.requiredLevel }, 403); + } + if (err instanceof ReauthRequiredError) { + // Critical (L4) approve with a valid signature but no fresh re-auth — tell + // the client to re-collect the password and retry, not a generic failure. + return c.json({ error: 'reauth_required' }, 401); + } + console.error('[approvals] assertion verification failed:', err); + return c.json({ error: 'assertion_failed' }, 401); } - console.error('[approvals] assertion verification failed:', err); - return c.json({ error: 'assertion_failed' }, 401); - } - // Sole-operator step-up (spec §1 / §4): a requester approving their OWN - // intent (the sole-operator single-row fan-out case) must present >= L3 - // assurance (webauthn_platform or mobile_hw_key). Checked BEFORE the CAS - // so an under-assured self-approval never flips the row. Deny is - // unaffected — only an approve of one's own intent is gated. - if ( - linkedIntent && - status === 'approved' && - linkedIntent.requestedByUserId === userId - ) { - const level = assurance.decidedAssuranceLevel ?? 0; - if (level < 3) { - return c.json({ error: 'step_up_required', requiredLevel: 3 }, 403); + // Sole-operator step-up (spec §1 / §4, four_eyes only): a requester + // approving their OWN intent (the sole-operator single-row fan-out case) + // must present >= L3 assurance (webauthn_platform or mobile_hw_key). + // Checked BEFORE the CAS so an under-assured self-approval never flips the + // row. Deny is unaffected — only an approve of one's own intent is gated. + // Never reached for a supervised self-decide (handled in the branch + // above, which always records L1 by design — this gate would otherwise + // refuse every supervised approve outright). + if ( + linkedIntent && + status === 'approved' && + linkedIntent.requestedByUserId === userId + ) { + const level = assurance.decidedAssuranceLevel ?? 0; + if (level < 3) { + return c.json({ error: 'step_up_required', requiredLevel: 3 }, 403); + } } } - const result = await db - .update(approvalRequests) - .set({ - status, - decidedAt: new Date(), - decisionReason: reason ?? null, - decidedAssuranceLevel: assurance.decidedAssuranceLevel, - decidedVia: assurance.decidedVia, - authenticatorDeviceId: assurance.authenticatorDeviceId, - }) - .where( - and( - eq(approvalRequests.id, id), - eq(approvalRequests.userId, userId), - eq(approvalRequests.status, 'pending'), - gt(approvalRequests.expiresAt, new Date()), - ) - ) - .returning(); + // Task 6: the ENTIRE decision write — approval-row CAS, the ai_tool_executions + // mirror, and the action-intents fan-in (intent CAS + release_by stamp + + // sibling expiry + intent_approved outbox insert) — commits as ONE + // transaction. Before this, the intent fan-in ran in its own, + // independently-committing transaction: a fault there left the approval row + // decided with NO intent/outbox follow-through (or vice versa on other + // fault points), a state no retry could repair. Now any throw here rolls + // EVERYTHING back and the caller gets a retryable 500 instead of a + // half-applied decision. + // + // System-scoped (runOutsideDbContext + withSystemDbAccessContext, exactly + // like the fan-in already was): the sibling approval_requests rows belong to + // OTHER approvers and are invisible under the caller's own Shape-6 + // user-id-scoped ambient context, and action_intents/ai_tool_executions need + // org-scoped visibility the caller's ambient scope doesn't guarantee either. + // System scope is safe here because authorization was already fully decided + // by the checks above — this is purely a write, not a fresh access decision. + // Push dispatch and any other network I/O stay OUTSIDE this transaction + // (#1105 — never hold a txn across network I/O); there is none in this path. + type DecideWriteResult = + | { lostRace: true } + | { lostRace: false; updated: typeof approvalRequests.$inferSelect; wonIntent: boolean }; + + let writeResult: DecideWriteResult; + try { + writeResult = await runOutsideDbContext(() => + withSystemDbAccessContext(() => + db.transaction(async (tx): Promise => { + const casRows = await tx + .update(approvalRequests) + .set({ + status, + decidedAt: new Date(), + decisionReason: reason ?? null, + decidedAssuranceLevel: assurance.decidedAssuranceLevel, + decidedVia: assurance.decidedVia, + authenticatorDeviceId: assurance.authenticatorDeviceId, + }) + .where( + and( + eq(approvalRequests.id, id), + eq(approvalRequests.userId, userId), + eq(approvalRequests.status, 'pending'), + gt(approvalRequests.expiresAt, new Date()), + ) + ) + .returning(); + + if (casRows.length === 0) { + // Lost a concurrent decide/expiry race between the pre-fetch and + // the CAS. Not a failure — the transaction still commits (nothing + // was written), and the caller gets a plain 409 below. + return { lostRace: true }; + } + const updated = casRows[0]!; + + // If this approval row was created by the AI agent SDK (Breeze AI / + // chat), it carries an `executionId` linking back to the + // ai_tool_executions row that the SDK is blocked on via + // waitForApproval(). Flip that row's status so the SDK's poll + // unblocks and the tool either executes or returns "rejected or + // timed out". For non-AI sources (helper, dev seed) execution_id is + // null and this is a no-op. + if (updated.executionId) { + const aiStatus = status === 'approved' ? 'approved' : 'rejected'; + const mirrored = await tx + .update(aiToolExecutions) + .set({ status: aiStatus, approvedBy: userId, approvedAt: new Date() }) + // Guarded on 'pending' (#3089): a settled approval wait marks the + // execution row 'rejected' without touching this approval_requests + // row first in every failure mode, so a decide that squeaked past + // the approval_requests CAS must not resurrect a closed execution + // row as a stranded 'approved' that the legacy bridge (no durable + // worker) would never run. + .where(and( + eq(aiToolExecutions.id, updated.executionId), + eq(aiToolExecutions.status, 'pending'), + )) + .returning({ id: aiToolExecutions.id }); + if (mirrored.length === 0) { + // Lost the race, not a query failure: the execution row was + // already closed out (settled/timed out) between the + // approval_requests CAS above and this mirror — same + // first-wins posture as the elevation and intent mirrors below. + // approval_requests is still the source of truth for the + // mobile UI and correctly records this approver's decision, + // but the underlying tool call is already gone and will NOT + // run despite the 'approved' response below — worth a distinct + // log line so this isn't mistaken for the mirror failing. + console.warn('[approvals] ai_tool_executions mirror lost the race (execution already settled):', updated.executionId); + } + } + + // Action intents (spec §4 / §3.4): mirror the decision onto the + // linked action_intents row. First-wins inline CAS — a lost race + // (another approver, the reaper, or a retry already decided the + // intent) is a clean no-op: this row's own decision still commits + // (with the rest of this transaction), so the user's decide call + // still succeeds either way. + let wonIntent = false; + if (updated.intentId && linkedIntent) { + const intentId = updated.intentId; + const intentTargetStatus: ActionIntentStatus = status === 'approved' ? 'approved' : 'rejected'; + + const intentCas = await tx + .update(actionIntents) + .set({ + status: intentTargetStatus, + decidedAt: new Date(), + decidedByUserId: userId, + decidedAssuranceLevel: assurance.decidedAssuranceLevel, + decidedVia: assurance.decidedVia, + // Fixed release lease (design §4.2), stamped only on an + // approval win — a rejected intent never executes, so it has + // no release window to bound. + ...(status === 'approved' + ? { releaseBy: new Date(Date.now() + RELEASE_LEASE_MS) } + : {}), + }) + .where( + and( + eq(actionIntents.id, intentId), + eq(actionIntents.status, 'pending_approval'), + ), + ) + .returning({ id: actionIntents.id }); - if (result.length === 0) { - // Lost a concurrent decide/expiry race between the pre-fetch and the CAS. - return c.json({ error: 'Already decided', finalStatus: 'expired' }, 409); - } + if (intentCas.length > 0) { + wonIntent = true; - const [updated] = result; + // MUST run in the same system-scoped transaction: approval_requests + // is Shape-6 (user-id-scoped), so the sibling rows belong to OTHER + // approvers and are invisible to this approver's own ambient + // context — a context-scoped UPDATE would silently match zero rows. + await tx + .update(approvalRequests) + .set({ status: 'expired', decidedAt: new Date() }) + .where( + and( + eq(approvalRequests.intentId, intentId), + eq(approvalRequests.status, 'pending'), + ne(approvalRequests.id, updated.id), + ), + ); - // If this approval row was created by the AI agent SDK (Breeze AI / chat), - // it carries an `executionId` linking back to the ai_tool_executions row - // that the SDK is blocked on via waitForApproval(). Flip that row's status - // so the SDK's poll unblocks and the tool either executes or returns - // "rejected or timed out". For non-AI sources (helper, dev seed) execution_id - // is null and this is a no-op. - if (updated?.executionId) { - const aiStatus = status === 'approved' ? 'approved' : 'rejected'; - try { - const mirrored = await db - .update(aiToolExecutions) - .set({ status: aiStatus, approvedBy: userId, approvedAt: new Date() }) - // Guarded on 'pending' (#3089): a settled approval wait marks the - // execution row 'rejected' without touching this approval_requests - // row first in every failure mode (the two writes aren't atomic), so - // a decide that squeaked past the approval_requests CAS must not - // resurrect a closed execution row as a stranded 'approved' that the - // legacy bridge (no durable worker) would never run. - .where(and( - eq(aiToolExecutions.id, updated.executionId), - eq(aiToolExecutions.status, 'pending'), - )) - .returning({ id: aiToolExecutions.id }); - if (mirrored.length === 0) { - // Lost the race, not a query failure: the execution row was already - // closed out (settled/timed out) between the approval_requests CAS - // above and this mirror — same first-wins posture as the elevation - // and intent mirrors below. approval_requests is still the source of - // truth for the mobile UI and correctly records this approver's - // decision, but the underlying tool call is already gone and will - // NOT run despite the 'approved' response below — worth a distinct - // log line so this isn't mistaken for the mirror simply failing. - console.warn('[approvals] ai_tool_executions mirror lost the race (execution already settled):', updated.executionId); - } - } catch (err) { - console.error('[approvals] Failed to mirror status to ai_tool_executions:', err); - // Non-fatal: the approval_request row is the source of truth for the - // mobile UI. The SDK poll will time out at the 5-min ceiling if the - // mirror fails — better than failing the user-facing decide call. - } + if (status === 'approved') { + await tx.insert(intentOutbox).values({ + intentId, + eventType: 'intent_approved', + // Ids only, no argument content (spec §3.2). + payload: { intentId, orgId: linkedIntent.orgId }, + }); + } + } + } + + return { lostRace: false, updated, wonIntent }; + }), + ), + ); + } catch (err) { + console.error('[approvals] decide transaction failed (rolled back):', err); + return c.json({ error: 'decide_failed', retryable: true }, 500); + } + + if (writeResult.lostRace) { + return c.json({ error: 'Already decided', finalStatus: 'expired' }, 409); } + const { updated, wonIntent } = writeResult; + // #1254: PAM mobile bridge. If this approval was fanned out from a pending // uac_intercept elevation, mirror the decision back onto the elevation and // expire the sibling approval rows. First-wins: the CAS only fires while the @@ -849,115 +1064,35 @@ async function decideHandler( } } - // Action intents (spec §4 / §3.4): mirror the decision onto the linked - // action_intents row. First-wins inline CAS — a lost race (another approver, - // the reaper, or a retry already decided the intent) is a clean no-op; this - // row's own decision already committed above, so the user's decide call still - // succeeds either way. - if (updated?.intentId && linkedIntent) { - const intentId = updated.intentId; - const intentTargetStatus: ActionIntentStatus = status === 'approved' ? 'approved' : 'rejected'; + // Action intents (spec §4 / §3.4): post-commit audit/metrics projection for + // the intent fan-in that already committed (or rolled back) as part of the + // ONE decide transaction above. `wonIntent` reflects whether THIS decide's + // CAS actually transitioned the intent (a lost race — another approver, the + // reaper, or a retry already decided it — is a clean no-op: no event here, + // but this row's own decision still committed above either way). + if (wonIntent && updated.intentId && linkedIntent) { const soleOperatorApproval = status === 'approved' && linkedIntent.requestedByUserId === userId; - - // Atomic intent fan-in: the intent CAS + sibling expiry + (approve-only) - // intent_approved outbox insert commit in ONE system-scoped transaction, so - // an `approved` intent can never exist without its intent_approved outbox - // row (which is exactly what the release worker consumes to run the - // action). Before this was one transaction, a swallowed fan-in failure left - // the intent approved with no outbox row → the worker never released it. - // MUST run in system scope: approval_requests is Shape-6 (user-id-scoped), - // so the sibling rows belong to OTHER approvers and are invisible to this - // approver's request context — a context-scoped UPDATE would silently - // match zero rows. - let wonIntent = false; - try { - wonIntent = await runOutsideDbContext(() => - withSystemDbAccessContext(() => - db.transaction(async (tx) => { - // First-wins CAS, inline (was transitionIntent). A lost race - // (another approver, the reaper, or a retry already decided the - // intent) affects zero rows → clean no-op: do NOT expire siblings - // or write the outbox. - const cas = await tx - .update(actionIntents) - .set({ - status: intentTargetStatus, - decidedAt: new Date(), - decidedByUserId: userId, - decidedAssuranceLevel: assurance.decidedAssuranceLevel, - decidedVia: assurance.decidedVia, - // Fixed release lease (design §4.2), stamped only on an approval - // win — a rejected intent never executes, so it has no release - // window to bound. Task 6 makes this whole fan-in atomic with - // the release worker's claim; here it's just the field. - ...(status === 'approved' - ? { releaseBy: new Date(Date.now() + RELEASE_LEASE_MS) } - : {}), - }) - .where( - and( - eq(actionIntents.id, intentId), - eq(actionIntents.status, 'pending_approval'), - ), - ) - .returning({ id: actionIntents.id }); - if (cas.length === 0) return false; - - await tx - .update(approvalRequests) - .set({ status: 'expired', decidedAt: new Date() }) - .where( - and( - eq(approvalRequests.intentId, intentId), - eq(approvalRequests.status, 'pending'), - ne(approvalRequests.id, updated.id), - ), - ); - - if (status === 'approved') { - await tx.insert(intentOutbox).values({ - intentId, - eventType: 'intent_approved', - // Ids only, no argument content (spec §3.2). - payload: { intentId, orgId: linkedIntent!.orgId }, - }); - } - return true; - }), - ), - ); - } catch (err) { - // The approver's own approval row already committed above; a failure of - // the intent mirror now rolls back ALL of {CAS, sibling expiry, outbox} - // together (no partial state) and leaves the intent pending_approval for - // re-decide / the expiry reaper. It must not fail the user's decide call. - console.error('[approvals] Failed atomic intent fan-in (CAS / sibling expiry / outbox):', err); - wonIntent = false; - } - - if (wonIntent) { - recordActionIntentEvent({ - orgId: linkedIntent.orgId, - intentId, - actionName: linkedIntent.actionName, - argumentDigest: linkedIntent.argumentDigest, - source: linkedIntent.source, - outcome: soleOperatorApproval - ? 'self_approved_sole_operator' - : status === 'approved' - ? 'approved' - : 'rejected', - actorId: userId, - details: { - approvalRequestId: updated.id, - decidedAssuranceLevel: assurance.decidedAssuranceLevel, - decidedVia: assurance.decidedVia, - }, - }); - } + recordActionIntentEvent({ + orgId: linkedIntent.orgId, + intentId: updated.intentId, + actionName: linkedIntent.actionName, + argumentDigest: linkedIntent.argumentDigest, + source: linkedIntent.source, + outcome: soleOperatorApproval + ? 'self_approved_sole_operator' + : status === 'approved' + ? 'approved' + : 'rejected', + actorId: userId, + details: { + approvalRequestId: updated.id, + decidedAssuranceLevel: assurance.decidedAssuranceLevel, + decidedVia: assurance.decidedVia, + }, + }); } - return c.json({ approval: serialize(updated!) }); + return c.json({ approval: serialize(updated) }); } // The two M365 mutation tools (tier 3) that create an approval card. Read-only diff --git a/apps/api/src/routes/approvalsDecideAtomicity.integration.test.ts b/apps/api/src/routes/approvalsDecideAtomicity.integration.test.ts index 81ee6341a..84e1bf9f0 100644 --- a/apps/api/src/routes/approvalsDecideAtomicity.integration.test.ts +++ b/apps/api/src/routes/approvalsDecideAtomicity.integration.test.ts @@ -159,12 +159,17 @@ async function seedScenario(): Promise { * row id (the row we'll decide through the real route). */ async function seedIntentWithTwoApprovers(s: Scenario): Promise<{ intentId: string; approverARowId: string }> { const auth = requesterAuth(s.requester, s.orgId, s.partnerId, s.requesterRoleId); - // execute_command is a base Tier-3 tool — no `action` field needed, and - // createActionIntent never verifies the device exists (that happens at - // release time), so a bare random UUID is fine (mirrors intentFanout). + // restore_snapshot is a base Tier-3 tool classified whole-tool `four_eyes` + // (TIER3_FOUR_EYES_TOOLS, aiGuardrails.ts) — required for this fixture's + // two-approver fan-out. execute_command was used here pre-Task-6, but the + // tier3-supervised-four-eyes split (Task 1) classifies it `supervised`, + // which fans out to exactly ONE (requester-owned) row and silently broke + // this fixture's `toHaveLength(2)` assertion below. createActionIntent + // never verifies the snapshot/device exist (that happens at release time), + // so bare random UUIDs are fine (mirrors intentFanout). const snapshot = await createActionIntent(auth, { - toolName: 'execute_command', - input: { deviceId: randomUUID(), commandType: 'kill_process' }, + toolName: 'restore_snapshot', + input: { snapshotId: randomUUID(), deviceId: randomUUID() }, source: 'chat', }); expect(snapshot.status).toBe('pending_approval'); @@ -266,23 +271,29 @@ describe('decide-path intent fan-in atomicity (real Postgres, breeze_app)', () = }); }); - runDb('fault injection: a failing intent_approved outbox insert rolls the intent CAS back — never approved-without-outbox', async () => { + runDb('fault injection: a failing intent_approved outbox insert rolls back the ENTIRE decide write (approval CAS included) and returns a retryable 500', async () => { const s = seeded!; const { intentId, approverARowId } = await seedIntentWithTwoApprovers(s); - // The intent-mirror failure is swallowed by design (the approver's own - // approval row already committed), so the decide call still returns 200 — - // but the whole {CAS + sibling expiry + outbox} must roll back together. + // Task 6: the approval-row CAS, the intent CAS, the sibling expiry, and + // the intent_approved outbox insert are now ONE transaction — a fault + // anywhere inside it rolls back EVERYTHING, including the approver's own + // approval_requests row, and the caller gets a retryable 500. (Pre-Task-6, + // the approval CAS committed independently in its own transaction before + // the intent fan-in ever opened, so this same fault left the approval row + // decided while the intent silently never got its outbox row — a 200 + // response masking a half-applied decision. That is the exact bug this + // task closes: assert 500 here, not 200.) const res = await withIntentApprovedOutboxFault(() => approveViaRoute(s, approverARowId)); - expect(res.status).toBe(200); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.retryable).toBe(true); await withSystemDbAccessContext(async () => { const [intent] = await db.select().from(actionIntents).where(eq(actionIntents.id, intentId)); // THE property this task delivers: with the outbox insert faulted, the // intent CAS rolled back too — the intent is NOT left `approved` (the - // release worker would never see an outbox row for it). On the pre-Task-6 - // three-transaction code the CAS committed independently and this is - // `approved` → the bug. + // release worker would never see an outbox row for it). expect(intent?.status).toBe('pending_approval'); const outbox = await db @@ -291,14 +302,45 @@ describe('decide-path intent fan-in atomicity (real Postgres, breeze_app)', () = .where(and(eq(intentOutbox.intentId, intentId), eq(intentOutbox.eventType, 'intent_approved'))); expect(outbox).toHaveLength(0); + // The approver's OWN approval_requests row rolled back with everything + // else — it must NOT be left `approved` with no corresponding intent + // transition (the half-applied state this task eliminates). + const [ownRow] = await db + .select({ status: approvalRequests.status }) + .from(approvalRequests) + .where(eq(approvalRequests.id, approverARowId)); + expect(ownRow?.status).toBe('pending'); + // Sibling expiry is part of the same rolled-back transaction, so - // partnerApprover's row is still pending (the reaper / a retry can still - // drive the intent to a terminal state). + // partnerApprover's row is still pending too. const pendingSiblings = await db .select({ id: approvalRequests.id }) .from(approvalRequests) .where(and(eq(approvalRequests.intentId, intentId), eq(approvalRequests.status, 'pending'))); expect(pendingSiblings.length).toBeGreaterThan(0); }); + + // A retry (fault no longer installed — withIntentApprovedOutboxFault + // dropped the trigger in its `finally`) succeeds cleanly: same approval + // row, same intent, now genuinely committed together. + const retryRes = await approveViaRoute(s, approverARowId); + expect(retryRes.status).toBe(200); + + await withSystemDbAccessContext(async () => { + const [intent] = await db.select().from(actionIntents).where(eq(actionIntents.id, intentId)); + expect(intent?.status).toBe('approved'); + + const outbox = await db + .select() + .from(intentOutbox) + .where(and(eq(intentOutbox.intentId, intentId), eq(intentOutbox.eventType, 'intent_approved'))); + expect(outbox).toHaveLength(1); + + const [ownRow] = await db + .select({ status: approvalRequests.status }) + .from(approvalRequests) + .where(eq(approvalRequests.id, approverARowId)); + expect(ownRow?.status).toBe('approved'); + }); }); }); diff --git a/apps/api/src/services/aiAgentSdk.ts b/apps/api/src/services/aiAgentSdk.ts index ecf364f66..3cf13a565 100644 --- a/apps/api/src/services/aiAgentSdk.ts +++ b/apps/api/src/services/aiAgentSdk.ts @@ -223,7 +223,8 @@ const pendingIntentBySession = new WeakMap(); */ type ApprovalMethod = | 'per_step_user' // human decided the lightweight Tier-2 approval card - | 'action_intent' // durable Tier-3 intent decided via the approvals surface + | 'action_intent' // durable Tier-3 four_eyes intent decided via the approvals surface + | 'supervised_self' // Task 6: durable Tier-3 SUPERVISED intent self-decided by the requester (no external approver, no assertion — tier3-supervised-four-eyes split design §4.2) | 'pam' // helper session — PAM elevation policy/approver decision | 'plan_step' // pre-authorized step of a human-approved action plan | 'auto_approve_mode' // session runs auto_approve; Tier 2 executes unprompted @@ -233,9 +234,12 @@ const lastApprovalBySession = new WeakMap = new Set([ 'per_step_user', 'action_intent', + 'supervised_self', 'pam', 'plan_step', ]); @@ -1422,10 +1426,16 @@ export function createSessionPreToolUse(session: ActiveSession): PreToolUseCallb if (guardrailCheck.tier >= 2) { // Reaching here inside the tier>=2 block means the call was explicitly // decided: the durable tier-3 intent flow (approver via the approvals - // surface) or the tier-2 lightweight card (user clicked Approve). + // surface, or the requester self-deciding a SUPERVISED intent — Task 6) + // or the tier-2 lightweight card (user clicked Approve). lastApprovalBySession.set(session, { toolName, - method: guardrailCheck.tier >= 3 ? 'action_intent' : 'per_step_user', + method: + guardrailCheck.tier >= 3 + ? guardrailCheck.approvalScope === 'supervised' + ? 'supervised_self' + : 'action_intent' + : 'per_step_user', }); } return { allowed: true, intentId: createdIntentId }; From 7b6bfd0f6ec7e721456bbe5addcb36fbdda0912f Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 02:41:19 -0600 Subject: [PATCH 10/17] =?UTF-8?q?fix(ai):=20address=20review=20round=201?= =?UTF-8?q?=20=E2=80=94=20lock=20order,=20tenant=20guard,=20atomicity,=20e?= =?UTF-8?q?nforcing=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round 1 on the supervised plain-decide + atomic-decide-transaction work: 1. report-suspicious: fold the approval-row 'reported' flip into the SAME transaction as the intent CAS + sibling expiry. Previously the flip committed separately BEFORE that transaction opened, so a throw inside it left the row 'reported' with the intent permanently stranded pending_approval (a retry's pre-fetch saw status!=='pending' and never re-entered the fan-in). A rollback now restores 'pending' too. 2. Lock-order inversion: both the decide-write transaction and report-suspicious's intent-reject transaction now take a `SELECT ... FOR UPDATE` lock on the intent row FIRST, before touching approval_requests — matching report-suspicious/intentExpiryReaper's existing order. The decide handler previously locked its own approval row first and the intent second, the opposite order, which could deadlock (Postgres 40P01) against a concurrent report-suspicious/reaper transaction. 3. The ai_tool_executions mirror (system-scoped, no RLS) now carries an explicit `exists(...)` tenant/linkage guard in its UPDATE's WHERE clause — verifying the execution's ai_sessions row belongs to the same user as the approval_requests row being decided — instead of relying solely on the (already-trusted, but app-layer-only) executionId FK value. 4. New `approvalsDecideSupervised.integration.test.ts`: drives the real approve/deny route against genuine role/permission state (not the mocked buildAuthContextForIntent/checkToolPermission the unit suite stands in for) — happy path (200, intent approved, outbox written) and a revoked-permission path (403, nothing written). 5. Adjudicated requirement: a supervised decide now checks whether the partner's authenticator policy is actively enforcing before skipping the assurance ladder. When enforcing, the requester goes through the same ladder a four_eyes sole-operator self-approve does (WebAuthn L3 satisfies it); non-enforcing partners keep the unchanged plain-click path. Co-Authored-By: Claude Fable 5 --- apps/api/src/routes/approvals.test.ts | 141 ++++++++- apps/api/src/routes/approvals.ts | 205 +++++++++---- ...rovalsDecideSupervised.integration.test.ts | 275 ++++++++++++++++++ apps/api/vitest.config.ts | 7 + apps/api/vitest.integration.config.ts | 8 + 5 files changed, 568 insertions(+), 68 deletions(-) create mode 100644 apps/api/src/routes/approvalsDecideSupervised.integration.test.ts diff --git a/apps/api/src/routes/approvals.test.ts b/apps/api/src/routes/approvals.test.ts index 5708d3250..2b19351ab 100644 --- a/apps/api/src/routes/approvals.test.ts +++ b/apps/api/src/routes/approvals.test.ts @@ -95,6 +95,18 @@ vi.mock('../services/actionIntents/actorContext', () => ({ })), })); +// Fix round 1, finding 5: a supervised approve now checks whether the +// partner's authenticator policy is actively ENFORCING before skipping the +// assurance ladder. Mocked wholesale (loadPartnerPolicy does a real DB read +// this file's mocked `db` can't usefully back) with a permissive default +// (non-enforcing, i.e. every existing "plain click" supervised test keeps its +// behavior) — the two Task 6 enforcing/non-enforcing tests override +// `isEnforcing` directly. +vi.mock('../services/authenticatorPolicy', () => ({ + loadPartnerPolicy: vi.fn(async () => null), + isEnforcing: vi.fn(() => false), +})); + // #2685: the decide handler RE-DERIVES the eligible approver set before // permitting a self-approve, instead of inferring sole-operator status from // "a requester-owned row exists". Default: the deciding user is the only @@ -246,6 +258,7 @@ import { getUserPermissions, userCanDecideApprovals, canAccessOrg } from '../ser import { resolveIntentApprovers } from '../services/actionIntents/intentApprovers'; import { checkToolPermission } from '../services/aiGuardrails'; import { buildAuthContextForIntent } from '../services/actionIntents/actorContext'; +import { loadPartnerPolicy, isEnforcing } from '../services/authenticatorPolicy'; function buildApp() { const app = new Hono(); @@ -295,9 +308,32 @@ function mockDecideFlow(opts: { return set; } +// Fix round 1, finding 3: the ai_tool_executions mirror's WHERE clause now +// embeds an `exists(tx.select(...).from(aiSessions).where(...))` tenant/ +// linkage guard, built synchronously while the WHERE expression is +// constructed. Real Drizzle's `exists()` just wraps whatever it's given into +// a `sql` template fragment (drizzle-orm/sql/expressions/conditions.js) — it +// never calls anything on it — so `tx.select(...)` only needs to be a +// callable chain here, never actually awaited by this stub. +function txSelectExistsStub() { + return vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn(() => ({})) })) })); +} + +// Fix round 1, finding 2: an intent-linked decide-write tx now takes a +// `SELECT ... FOR UPDATE` lock on the intent row as its FIRST statement +// (lock-order fix — see the route's own comment). This IS awaited directly, +// so the stub's `.for(...)` must resolve. +function txSelectForUpdateStub() { + return vi.fn(() => ({ + from: vi.fn(() => ({ where: vi.fn(() => ({ for: vi.fn().mockResolvedValue([]) })) })), + })); +} + // Wires the Task 6 atomic decide-write tx with TWO sequential `tx.update` // calls: (1) the approval-row CAS, (2) the ai_tool_executions mirror. Used by -// both the approve and deny ai_tool_executions-mirror tests. +// both the approve and deny ai_tool_executions-mirror tests. These rows carry +// executionId (never intentId — mutually exclusive), so the tx never takes +// the finding-2 intent lock, but it DOES reach the finding-3 exists() guard. function mockDecideTxWithExecutionMirror(opts: { linkedRow: unknown; aiReturning: unknown[]; @@ -316,6 +352,7 @@ function mockDecideTxWithExecutionMirror(opts: { where: vi.fn().mockReturnValue({ returning: aiReturning }), }); const tx = { + select: txSelectExistsStub(), update: vi .fn() .mockReturnValueOnce({ set: approvalSet } as any) // 1) approval_requests CAS @@ -392,6 +429,11 @@ beforeEach(() => { allowedSiteIds: null, canAccessSite: () => true, } as any); + // Fix round 1, finding 5: re-establish the non-enforcing default after + // clearAllMocks wipes the factory implementation — every existing + // supervised "plain click" test relies on this. + vi.mocked(loadPartnerPolicy).mockResolvedValue(null); + vi.mocked(isEnforcing).mockReturnValue(false); vi.mocked(authMiddleware).mockImplementation((c: any, next: any) => { c.set('auth', { scope: 'partner', @@ -1394,6 +1436,10 @@ describe('Task 5: decide-handler bound to action_intents', () => { const siblingSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); const outboxValues = vi.fn().mockResolvedValue(undefined); const tx = { + // Fix round 1, finding 2: the FIRST statement in an intent-linked + // decide-write tx is now `SELECT ... FOR UPDATE` on the intent row + // (lock-order fix), ahead of the three `update` calls below. + select: txSelectForUpdateStub(), update: vi .fn() .mockReturnValueOnce({ set: approvalCasSet } as any) // 1) approval_requests CAS @@ -1778,6 +1824,8 @@ describe('Task 6: supervised intent plain-decide branch', () => { const siblingSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); const outboxValues = vi.fn().mockResolvedValue(undefined); const tx = { + // Fix round 1, finding 2: intent-first lock ahead of the three updates. + select: txSelectForUpdateStub(), update: vi .fn() .mockReturnValueOnce({ set: approvalCasSet } as any) @@ -1789,7 +1837,7 @@ describe('Task 6: supervised intent plain-decide branch', () => { return { approvalCasSet, intentCasSet, siblingSet, outboxValues }; } - it('supervised requester approves with no assertion', async () => { + it('supervised requester approves with no assertion (non-enforcing partner policy — fix round 1, finding 5)', async () => { mockDecideWithSupervisedIntent({}); mockSupervisedFanInTx(); @@ -1801,8 +1849,12 @@ describe('Task 6: supervised intent plain-decide branch', () => { expect(res.status).toBe(200); // The whole assertion/assurance ladder is skipped for a supervised - // self-decide — no WebAuthn challenge is ever verified. + // self-decide UNDER A NON-ENFORCING partner policy — no WebAuthn + // challenge is ever verified. The enforcing branch is covered by the + // dedicated 'enforcing partner policy' test below. expect(assertApprovalAssurance).not.toHaveBeenCalled(); + expect(loadPartnerPolicy).toHaveBeenCalledWith('partner-123'); + expect(isEnforcing).toHaveBeenCalled(); // The live-RBAC re-check DOES run (approve only), against the underlying // tool action — not approvals:decide. expect(buildAuthContextForIntent).toHaveBeenCalledWith( @@ -1815,6 +1867,64 @@ describe('Task 6: supervised intent plain-decide branch', () => { ); }); + it('an ENFORCING partner policy blocks a plain-click supervised approve with step_up_required (fix round 1, finding 5)', async () => { + mockDecideWithSupervisedIntent({}); + vi.mocked(isEnforcing).mockReturnValue(true); + + const res = await buildApp().request('/approvals/appr-1/approve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe('step_up_required'); + // The RBAC re-check still ran (it is unconditional for supervised, + // regardless of enforcement) — only the assurance ladder's outcome + // changed. The write transaction never opened. + expect(checkToolPermission).toHaveBeenCalled(); + expect(db.transaction).not.toHaveBeenCalled(); + }); + + it('an ENFORCING partner policy is satisfied by a WebAuthn L3 proof on a supervised approve', async () => { + mockDecideWithSupervisedIntent({}); + vi.mocked(isEnforcing).mockReturnValue(true); + vi.mocked(assertApprovalAssurance).mockResolvedValueOnce({ + requiredLevel: 3, + decidedAssuranceLevel: 3, + decidedVia: 'webauthn_platform', + authenticatorDeviceId: 'dev-1', + }); + const { approvalCasSet } = mockSupervisedFanInTx(); + + const res = await buildApp().request('/approvals/appr-1/approve', { method: 'POST' }); + expect(res.status).toBe(200); + expect(assertApprovalAssurance).toHaveBeenCalledWith( + expect.objectContaining({ approvalId: 'appr-1', userId: TEST_USER.id }), + ); + expect(approvalCasSet).toHaveBeenCalledWith( + expect.objectContaining({ decidedVia: 'webauthn_platform', decidedAssuranceLevel: 3 }), + ); + }); + + it('an ENFORCING partner policy never blocks a supervised DENY (deny is harmless, no partner-policy read)', async () => { + mockDecideWithSupervisedIntent({}); + mockSupervisedFanInTx(); + vi.mocked(isEnforcing).mockReturnValue(true); + + const res = await buildApp().request('/approvals/appr-1/deny', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ reason: 'no' }), + }); + + expect(res.status).toBe(200); + // Gated to approve-only — a deny never spends a partner-policy read. + expect(loadPartnerPolicy).not.toHaveBeenCalled(); + expect(assertApprovalAssurance).not.toHaveBeenCalled(); + }); + it('records session_tap/L1 (no proof consumed) on a supervised approve', async () => { mockDecideWithSupervisedIntent({}); const { approvalCasSet } = mockSupervisedFanInTx(); @@ -1995,6 +2105,8 @@ describe('Task 6: supervised intent plain-decide branch', () => { }); const siblingSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); const tx = { + // Fix round 1, finding 2: intent-first lock ahead of the three updates. + select: txSelectForUpdateStub(), update: vi .fn() .mockReturnValueOnce({ set: approvalCasSet } as any) @@ -2104,31 +2216,36 @@ describe('POST /approvals/:id/report-suspicious', () => { where: vi.fn().mockResolvedValue([intentRow]), }), } as any); - // 2) update approval_requests -> reported - vi.mocked(db.update).mockReturnValueOnce({ - set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), - } as any); - // 3) Task 6: the reject fan-in is now ONE db.transaction — the intent CAS - // (inline, `.returning(...)` the metadata for the metrics event) plus - // the sibling-expiry update, both on `tx`. + // Fix round 1, finding 1: the reject fan-in is now ONE db.transaction + // that ALSO folds in the approval-row 'reported' flip (previously a + // separate, unconditional db.update BEFORE this transaction even + // opened) — the tx does the intent-first lock (finding 2), the flip, + // the intent CAS (`.returning(...)` the metadata for the metrics + // event), and the sibling-expiry update, all on `tx`. const casReturning = vi .fn() .mockResolvedValue([{ orgId: 'org-9', actionName: 'y', argumentDigest: 'd', source: 'mcp_api' }]); + const flipSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); const intentCasSet = vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ returning: casReturning }), }); const siblingSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); const tx = { + select: txSelectForUpdateStub(), update: vi .fn() - .mockReturnValueOnce({ set: intentCasSet } as any) // 1) intent CAS - .mockReturnValueOnce({ set: siblingSet } as any), // 2) sibling expiry + .mockReturnValueOnce({ set: flipSet } as any) // 1) approval_requests -> reported + .mockReturnValueOnce({ set: intentCasSet } as any) // 2) intent CAS + .mockReturnValueOnce({ set: siblingSet } as any), // 3) sibling expiry }; vi.mocked(db.transaction).mockImplementation(async (fn: any) => fn(tx)); vi.mocked(db.insert).mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) } as any); const res = await buildApp().request('/approvals/a1/report-suspicious', { method: 'POST' }); expect(res.status).toBe(204); + expect(flipSet).toHaveBeenCalledWith( + expect.objectContaining({ status: 'reported' }), + ); expect(intentCasSet).toHaveBeenCalledWith( expect.objectContaining({ status: 'rejected', decidedByUserId: TEST_USER.id }), ); diff --git a/apps/api/src/routes/approvals.ts b/apps/api/src/routes/approvals.ts index b7b18ca30..b3779b6a8 100644 --- a/apps/api/src/routes/approvals.ts +++ b/apps/api/src/routes/approvals.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import { zValidator } from '../lib/validation'; -import { and, eq, gt, desc, inArray, isNull, ne } from 'drizzle-orm'; +import { and, eq, exists, gt, desc, inArray, isNull, ne, sql } from 'drizzle-orm'; import { db, runOutsideDbContext, withSystemDbAccessContext } from '../db'; import { authMiddleware } from '../middleware/auth'; @@ -25,6 +25,7 @@ import { RELEASE_LEASE_MS } from '../services/actionIntents/intentService'; import { resolveIntentApprovers } from '../services/actionIntents/intentApprovers'; import { buildAuthContextForIntent } from '../services/actionIntents/actorContext'; import { checkToolPermission } from '../services/aiGuardrails'; +import { loadPartnerPolicy, isEnforcing } from '../services/authenticatorPolicy'; import { getUserPermissions, userCanDecideApprovals, canAccessOrg } from '../services/permissions'; import { generateApprovalAssertionOptions } from '../services/approverWebAuthn'; import { issueMobileAssertionNonce } from '../services/mobileHwKey'; @@ -297,58 +298,59 @@ approvalRoutes.post('/:id/report-suspicious', async (c) => { // Flip status to 'reported' if still pending, else leave as-is. Either way // we treat the report as authoritative for revocation + audit. if (existing.status === 'pending') { - await db - .update(approvalRequests) - .set({ - status: 'reported', - decidedAt: new Date(), - decisionReason: 'Reported as suspicious by user', - }) - .where(and(eq(approvalRequests.id, id), eq(approvalRequests.userId, userId))); - - // Mirror to ai_tool_executions so the SDK waiter unblocks with denial. - if (existing.executionId) { - try { - await db - .update(aiToolExecutions) - .set({ status: 'rejected', approvedBy: userId, approvedAt: new Date() }) - .where(eq(aiToolExecutions.id, existing.executionId)); - } catch (err) { - console.error('[approvals] report-suspicious: failed to mirror to ai_tool_executions:', err); - } - } - - // Intent-backed rows (durable four-eyes path) carry `intentId`, not - // `executionId`. A suspicious report is a strong DENY, so it must reject the - // whole intent and expire every SIBLING approval row — otherwise the intent - // stays pending_approval and another approver's still-live row could approve - // the action the reporter just flagged as malicious. Mirrors the decide - // handler's fan-in (first-wins CAS + system-scope sibling expiry). Runs in - // system scope: action_intents is org-scoped and sibling approval_requests - // rows belong to OTHER approvers, invisible to this user's request context. + // Intent-backed rows (durable four-eyes / supervised path) carry + // `intentId`, not `executionId`. A suspicious report is a strong DENY, so + // it must reject the whole intent and expire every SIBLING approval row — + // otherwise the intent stays pending_approval and another approver's + // still-live row could approve the action the reporter just flagged as + // malicious. Mirrors the decide handler's fan-in (first-wins CAS + + // system-scope sibling expiry). Runs in system scope: action_intents is + // org-scoped and sibling approval_requests rows belong to OTHER + // approvers, invisible to this user's request context. if (existing.intentId) { const intentId = existing.intentId; - // Atomic reject fan-in: the intent CAS (pending_approval -> rejected) - // and the sibling-approval expiry commit in ONE system-scoped - // transaction (a rejection has no intent_approved outbox row — mirror - // only what a reject writes). Collapsing them means a fan-in failure - // can no longer leave the intent rejected while a sibling approver's - // row stays live to approve the flagged action. System scope: - // action_intents is org-scoped and the sibling approval_requests rows - // belong to OTHER approvers, invisible to this user's request context. - // The CAS RETURNING carries the intent metadata for the metrics event; - // a lost race (zero rows) is a clean no-op. + // Fix round 1, finding 1: the 'reported' flip for THIS row is now + // folded into the SAME transaction as the intent CAS + sibling expiry + // (it used to be a separate, unconditional statement committed BEFORE + // this transaction even opened). Previously, a throw here rolled back + // only the intent CAS/sibling-expiry — the flip had already committed — + // so a retry's pre-fetch saw status='reported' (not 'pending'), the + // outer `if` above never re-entered, and the intent was permanently + // stranded `pending_approval` with live sibling rows that could still + // approve the flagged action. Folding the flip in here means a + // rollback restores 'pending' too, so a retry genuinely replays + // everything (flip + intent CAS + sibling expiry) as one unit, same as + // the main decide handler's atomic write. // - // Task 6 "same treatment" as the main decide handler: a genuine throw - // here is NOT swallowed — it rolls this transaction back (the intent - // stays pending_approval, no sibling was expired) and the whole - // request fails with a retryable 500 BEFORE revocation/audit run, - // rather than silently leaving the intent live while still reporting - // success to the caller. + // Fix round 1, finding 2 (lock-order inversion): lock the intent row + // FIRST, before touching approval_requests — the SAME order the decide + // handler's transaction now takes (see its own comment for the + // deadlock this prevents between concurrent decide / report-suspicious + // / intentExpiryReaper transactions). + // + // A genuine throw is NOT swallowed — it rolls this whole transaction + // back (flip + intent CAS + sibling expiry all undone) and the request + // fails with a retryable 500 BEFORE revocation/audit run, rather than + // silently leaving a half-applied state while still reporting success. try { const rejected = await runOutsideDbContext(() => withSystemDbAccessContext(() => db.transaction(async (tx) => { + await tx + .select({ id: actionIntents.id }) + .from(actionIntents) + .where(eq(actionIntents.id, intentId)) + .for('update'); + + await tx + .update(approvalRequests) + .set({ + status: 'reported', + decidedAt: new Date(), + decisionReason: 'Reported as suspicious by user', + }) + .where(and(eq(approvalRequests.id, id), eq(approvalRequests.userId, userId))); + const cas = await tx .update(actionIntents) .set({ status: 'rejected', decidedAt: new Date(), decidedByUserId: userId }) @@ -397,6 +399,32 @@ approvalRoutes.post('/:id/report-suspicious', async (c) => { console.error('[approvals] report-suspicious: failed to reject linked action intent (rolled back):', err); return c.json({ error: 'report_suspicious_failed', retryable: true }, 500); } + } else { + // Non-intent-linked rows (executionId-linked legacy AI mobile-push + // flow, or plain dev-seed/PAM rows with neither): unchanged from + // before this fix round — a single flip statement, plus a best-effort + // ai_tool_executions mirror. There is no intent/sibling fan-in for + // these rows, so there is nothing else to make atomic with the flip. + await db + .update(approvalRequests) + .set({ + status: 'reported', + decidedAt: new Date(), + decisionReason: 'Reported as suspicious by user', + }) + .where(and(eq(approvalRequests.id, id), eq(approvalRequests.userId, userId))); + + // Mirror to ai_tool_executions so the SDK waiter unblocks with denial. + if (existing.executionId) { + try { + await db + .update(aiToolExecutions) + .set({ status: 'rejected', approvedBy: userId, approvedAt: new Date() }) + .where(eq(aiToolExecutions.id, existing.executionId)); + } catch (err) { + console.error('[approvals] report-suspicious: failed to mirror to ai_tool_executions:', err); + } + } } } @@ -740,13 +768,30 @@ async function decideHandler( // // Supervised self-decide (Task 6): skip the WHOLE assertion/assurance ladder // — no WebAuthn challenge, no partner-policy step-up floor, no - // ReauthRequiredError ceremony. `resolveApprovalAssurance` is the same + // ReauthRequiredError ceremony — UNLESS the partner's authenticator policy + // is actively ENFORCING (fix round 1, finding 5 — adjudicated requirement). + // An enforcing partner's step-up floor must not be silently bypassed just + // because an intent classified as supervised: the requester can still + // satisfy it with a WebAuthn L3 proof, exactly like the four_eyes + // sole-operator self-approve does. The ladder's own self-approve step-up + // gate below already keys off `requestedByUserId === userId` (never off + // approvalScope), which is unconditionally true for a supervised row, so no + // extra gate is needed there — routing an enforcing supervised approve + // through the shared ladder is sufficient. Only checked for an approve + // (deny is never blocked by assurance, so there's no reason to spend a + // partner-policy read on it) — `resolveApprovalAssurance` is the same // synchronous "no proof presented" L1/session_tap default `proof===undefined` // resolves to today; reusing it (rather than hand-rolling the literal) keeps // the recorded factor byte-for-byte identical to that existing no-proof shape // and impossible to drift from `assertDecisionConsistent`'s invariants. + const isPartnerEnforcingForSupervised = + isSupervisedSelfDecide && status === 'approved' + ? isEnforcing(await loadPartnerPolicy(c.get('auth').partnerId ?? null), new Date()) + : false; + const skipAssuranceLadder = isSupervisedSelfDecide && !isPartnerEnforcingForSupervised; + let assurance: AssuranceDecision; - if (isSupervisedSelfDecide) { + if (skipAssuranceLadder) { assurance = resolveApprovalAssurance(existing.riskTier as RiskTier); } else { try { @@ -772,14 +817,16 @@ async function decideHandler( return c.json({ error: 'assertion_failed' }, 401); } - // Sole-operator step-up (spec §1 / §4, four_eyes only): a requester - // approving their OWN intent (the sole-operator single-row fan-out case) - // must present >= L3 assurance (webauthn_platform or mobile_hw_key). - // Checked BEFORE the CAS so an under-assured self-approval never flips the - // row. Deny is unaffected — only an approve of one's own intent is gated. - // Never reached for a supervised self-decide (handled in the branch - // above, which always records L1 by design — this gate would otherwise - // refuse every supervised approve outright). + // Sole-operator step-up (spec §1 / §4): a requester approving their OWN + // intent (the four_eyes sole-operator single-row fan-out case, OR a + // supervised row under an enforcing partner policy) must present >= L3 + // assurance (webauthn_platform or mobile_hw_key). Checked BEFORE the CAS + // so an under-assured self-approval never flips the row. Deny is + // unaffected — only an approve of one's own intent is gated. Never + // reached for a supervised self-decide under a NON-enforcing policy + // (handled by `skipAssuranceLadder` above, which always records L1 by + // design — this gate would otherwise refuse every plain-click supervised + // approve outright). if ( linkedIntent && status === 'approved' && @@ -820,6 +867,27 @@ async function decideHandler( writeResult = await runOutsideDbContext(() => withSystemDbAccessContext(() => db.transaction(async (tx): Promise => { + // Fix round 1, finding 2 (lock-order inversion): report-suspicious + // and intentExpiryReaper both lock action_intents BEFORE touching + // approval_requests. This transaction used to lock its OWN + // approval_requests row first and the intent second — the opposite + // order — so a concurrent report-suspicious/reaper transaction + // (intent held, waiting on a sibling approval_requests row this + // transaction already holds) and this transaction (approval_requests + // row held, waiting on the intent report-suspicious/reaper already + // holds) could deadlock (Postgres 40P01 → a user-visible 500). + // Taking the SAME intent-first lock here makes every writer agree on + // one global order, so concurrent transactions serialize instead of + // cycling. `existing.intentId` is read from the pre-fetch outside + // this transaction, before any lock is held. + if (existing.intentId) { + await tx + .select({ id: actionIntents.id }) + .from(actionIntents) + .where(eq(actionIntents.id, existing.intentId)) + .for('update'); + } + const casRows = await tx .update(approvalRequests) .set({ @@ -866,9 +934,34 @@ async function decideHandler( // the approval_requests CAS must not resurrect a closed execution // row as a stranded 'approved' that the legacy bridge (no durable // worker) would never run. + // + // Fix round 1, finding 3 (tenant/linkage guard): this UPDATE runs + // under system scope (no RLS), and `ai_tool_executions` has no + // org_id column of its own — it's scoped via its `ai_sessions` + // row. Matching on `updated.executionId` alone relies entirely on + // that FK having been populated correctly at insert time (an + // app-layer guarantee, not a DB-enforced one), which the repo's + // tenancy contract treats as insufficient for a system-scoped + // write. This executionId-linked flow (services/aiAgentSdk.ts's + // mobile waitForApproval path) is ALWAYS a self-approval — the + // approval_requests row's own userId (== `userId` here, already + // proven equal by the CAS's WHERE clause above) must own the + // session the execution belongs to. Carrying that check inside + // the UPDATE's WHERE (not as a separate app-layer assertion) + // means a wrong/stale executionId fails closed (0 rows) instead + // of silently mutating another tenant's execution row. .where(and( eq(aiToolExecutions.id, updated.executionId), eq(aiToolExecutions.status, 'pending'), + exists( + tx + .select({ one: sql`1` }) + .from(aiSessions) + .where(and( + eq(aiSessions.id, aiToolExecutions.sessionId), + eq(aiSessions.userId, userId), + )), + ), )) .returning({ id: aiToolExecutions.id }); if (mirrored.length === 0) { diff --git a/apps/api/src/routes/approvalsDecideSupervised.integration.test.ts b/apps/api/src/routes/approvalsDecideSupervised.integration.test.ts new file mode 100644 index 000000000..638f6f5af --- /dev/null +++ b/apps/api/src/routes/approvalsDecideSupervised.integration.test.ts @@ -0,0 +1,275 @@ +/** + * Real-Postgres proof that the Tier-3 SUPERVISED plain-decide branch (Task 6) + * enforces its live-RBAC re-check against genuine role/permission state. + * + * `approvals.test.ts` mocks BOTH of the supervised branch's authz + * dependencies wholesale — `buildAuthContextForIntent` + * (services/actionIntents/actorContext.ts) and `checkToolPermission` + * (services/aiGuardrails.ts) — because the real modules pull in + * `../aiTools`'s whole dependency graph, which that file's narrow + * `../services/permissions` mock can't support. That leaves the actual + * plumbing between "requester's role holds devices:execute" and "the decide + * route returns 200/403" completely unexercised against a real database: a + * bug in `buildAuthContextForIntent`'s role resolution, `getUserPermissions` + * caching, or the wiring between them would pass the mocked unit suite + * unnoticed (fix round 1, finding 4). + * + * This suite drives the REAL approve route (real JWT + `authMiddleware` + + * `breeze_app` RLS) against the test Postgres, seeding an `execute_command` + * intent (TIER3_SUPERVISED_TOOLS, aiGuardrails.ts — resolves whole-tool + * `supervised`) and deciding it as the requester. + * + * Co-located with the route it exercises (per the repo's test-placement + * convention) rather than under `src/__tests__/integration/`, so it is named + * explicitly in BOTH `vitest.integration.config.ts` (`include`) and + * `vitest.config.ts` (`exclude`) — the same dual hand-list + * `approvalsDecideAtomicity.integration.test.ts` uses. Miss either edit and + * it silently never runs in CI, or reds the no-DB unit job on ECONNREFUSED. + */ +import '../__tests__/integration/setup'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { randomUUID } from 'crypto'; +import { and, eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { db, withSystemDbAccessContext } from '../db'; +import { actionIntents, intentOutbox } from '../db/schema/actionIntents'; +import { approvalRequests } from '../db/schema/approvals'; +import { permissions, rolePermissions } from '../db/schema'; +import { createActionIntent } from '../services/actionIntents/intentService'; +import { PERMISSIONS, clearPermissionCache } from '../services/permissions'; +import { buildOrgAccessClosures, type AuthContext } from '../middleware/auth'; +import { createAccessToken, type TokenPayload } from '../services/jwt'; +import { getTestDb } from '../__tests__/integration/setup'; +import { + assignUserToOrganization, + createOrganization, + createPartner, + createRole, + createUser, + grantRolePermissions, +} from '../__tests__/integration/db-utils'; +import { approvalRoutes } from './approvals'; + +const runDb = it.runIf(!!process.env.DATABASE_URL); + +/** Real AuthContext for the requester acting on `orgId` — same shape + * authMiddleware produces (reuses buildOrgAccessClosures so org-access + * semantics can't drift from the live path). Mirrors the atomicity suite's + * helper of the same name. */ +function requesterAuth( + user: { id: string; email: string }, + orgId: string, + partnerId: string, + roleId: string, +): AuthContext { + const { orgCondition, canAccessOrg } = buildOrgAccessClosures([orgId]); + return { + principal: { kind: 'user_session' }, + user: { id: user.id, email: user.email, name: 'Requester', isPlatformAdmin: false }, + token: { + sub: user.id, + email: user.email, + roleId, + orgId, + partnerId, + scope: 'organization', + type: 'access', + mfa: true, + }, + partnerId, + orgId, + scope: 'organization', + accessibleOrgIds: [orgId], + orgCondition, + canAccessOrg, + }; +} + +/** A real access token for the requester deciding their own supervised row, + * minted the same way the atomicity suite's approverAccessToken is. */ +async function requesterAccessToken( + user: { id: string; email: string }, + orgId: string, + partnerId: string, + roleId: string, +): Promise { + const payload: Omit = { + sub: user.id, + email: user.email, + roleId, + orgId, + partnerId, + scope: 'organization', + mfa: false, + aep: 1, + mep: 1, + sid: randomUUID(), + }; + return createAccessToken(payload); +} + +interface Scenario { + partnerId: string; + orgId: string; + requester: { id: string; email: string }; + requesterRoleId: string; +} + +/** Seeds one org under one partner + a requester whose role holds + * devices:execute (the RBAC entry TOOL_PERMISSIONS maps execute_command to) + * but deliberately NOT approvals:decide — supervised never requires it. */ +async function seedSupervisedScenario(): Promise { + const partner = await createPartner(); + const org = await createOrganization({ partnerId: partner.id }); + + const role = await createRole({ scope: 'organization', orgId: org.id }); + await grantRolePermissions(role.id, [PERMISSIONS.DEVICES_EXECUTE]); + + const requester = await createUser({ + partnerId: partner.id, + orgId: org.id, + email: `requester-${randomUUID()}@decidesupervised.test`, + }); + await assignUserToOrganization(requester.id, org.id, role.id); + + return { + partnerId: partner.id, + orgId: org.id, + requester: { id: requester.id, email: requester.email }, + requesterRoleId: role.id, + }; +} + +/** Revokes devices:execute from a role by deleting its role_permissions row + * directly (no db-utils helper exists for revocation — grantRolePermissions + * only adds). Also busts the permission cache so the live re-check actually + * observes it (services/permissions.ts caches resolved role permissions). */ +async function revokeDevicesExecute(roleId: string, userId: string): Promise { + const sdb = getTestDb(); + const [permRow] = await sdb + .select({ id: permissions.id }) + .from(permissions) + .where(and(eq(permissions.resource, 'devices'), eq(permissions.action, 'execute'))) + .limit(1); + if (!permRow) throw new Error('seed: devices:execute permission row not found'); + await sdb + .delete(rolePermissions) + .where(and(eq(rolePermissions.roleId, roleId), eq(rolePermissions.permissionId, permRow.id))); + await clearPermissionCache(userId); +} + +async function seedSupervisedIntent(s: Scenario): Promise<{ intentId: string; approvalRowId: string }> { + const auth = requesterAuth(s.requester, s.orgId, s.partnerId, s.requesterRoleId); + // execute_command is in TIER3_SUPERVISED_TOOLS (aiGuardrails.ts) — a base + // Tier-3 tool needing no `action` field. createActionIntent never verifies + // the device exists (that happens at release time), so a bare random UUID + // is fine. + const snapshot = await createActionIntent(auth, { + toolName: 'execute_command', + input: { deviceId: randomUUID(), commandType: 'kill_process' }, + source: 'chat', + }); + expect(snapshot.status).toBe('pending_approval'); + // Supervised: exactly ONE approval row, always owned by the requester. + expect(snapshot.approvalRequestIds).toHaveLength(1); + expect(snapshot.requesterApprovalRequestId).toBeTruthy(); + + const [intent] = await withSystemDbAccessContext(() => + db.select({ approvalScope: actionIntents.approvalScope }).from(actionIntents).where(eq(actionIntents.id, snapshot.id)), + ); + expect(intent?.approvalScope).toBe('supervised'); + + return { intentId: snapshot.id, approvalRowId: snapshot.requesterApprovalRequestId! }; +} + +async function decideViaRoute( + s: Scenario, + approvalRowId: string, + action: 'approve' | 'deny' = 'approve', +): Promise { + const token = await requesterAccessToken(s.requester, s.orgId, s.partnerId, s.requesterRoleId); + const app = new Hono(); + app.route('/approvals', approvalRoutes); + return app.request(`/approvals/${approvalRowId}/${action}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); +} + +let seeded: Scenario | null = null; + +beforeEach(async () => { + seeded = await seedSupervisedScenario(); +}); + +describe('supervised intent plain-decide branch (real Postgres, breeze_app)', () => { + runDb('happy path: requester self-decides a supervised intent — 200, intent approved, outbox row written', async () => { + const s = seeded!; + const { intentId, approvalRowId } = await seedSupervisedIntent(s); + + const res = await decideViaRoute(s, approvalRowId, 'approve'); + expect(res.status).toBe(200); + + await withSystemDbAccessContext(async () => { + const [intent] = await db.select().from(actionIntents).where(eq(actionIntents.id, intentId)); + expect(intent?.status).toBe('approved'); + expect(intent?.releaseBy).toBeInstanceOf(Date); + + const outbox = await db + .select() + .from(intentOutbox) + .where(and(eq(intentOutbox.intentId, intentId), eq(intentOutbox.eventType, 'intent_approved'))); + expect(outbox).toHaveLength(1); + + const [row] = await db + .select({ status: approvalRequests.status }) + .from(approvalRequests) + .where(eq(approvalRequests.id, approvalRowId)); + expect(row?.status).toBe('approved'); + }); + }); + + runDb('revoked-permission path: requester loses devices:execute between create and decide — 403, nothing written', async () => { + const s = seeded!; + const { intentId, approvalRowId } = await seedSupervisedIntent(s); + + await revokeDevicesExecute(s.requesterRoleId, s.requester.id); + + const res = await decideViaRoute(s, approvalRowId, 'approve'); + expect(res.status).toBe(403); + + await withSystemDbAccessContext(async () => { + const [intent] = await db.select().from(actionIntents).where(eq(actionIntents.id, intentId)); + expect(intent?.status).toBe('pending_approval'); + expect(intent?.releaseBy).toBeNull(); + + const outbox = await db + .select() + .from(intentOutbox) + .where(and(eq(intentOutbox.intentId, intentId), eq(intentOutbox.eventType, 'intent_approved'))); + expect(outbox).toHaveLength(0); + + const [row] = await db + .select({ status: approvalRequests.status }) + .from(approvalRequests) + .where(eq(approvalRequests.id, approvalRowId)); + expect(row?.status).toBe('pending'); + }); + }); + + runDb('a requester who lost devices:execute can still DENY their own supervised row (deny is harmless)', async () => { + const s = seeded!; + const { intentId, approvalRowId } = await seedSupervisedIntent(s); + + await revokeDevicesExecute(s.requesterRoleId, s.requester.id); + + const res = await decideViaRoute(s, approvalRowId, 'deny'); + expect(res.status).toBe(200); + + await withSystemDbAccessContext(async () => { + const [intent] = await db.select().from(actionIntents).where(eq(actionIntents.id, intentId)); + expect(intent?.status).toBe('rejected'); + }); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 6f7f6736b..4dd803e8a 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -92,6 +92,13 @@ export default defineConfig({ // so the no-DB unit runner would fail it on connect. Belongs to // vitest.integration.config.ts (registered in its include list). 'src/routes/approvalsDecideAtomicity.integration.test.ts', + // Supervised plain-decide branch real-DB test (Task 6 fix round 1, + // finding 4): imports `__tests__/integration/setup` (real postgres pool + // + autoMigrate) and lives in src/routes/ outside the + // `src/__tests__/integration/**` glob, so the no-DB unit runner would + // fail it on connect. Belongs to vitest.integration.config.ts + // (registered in its include list). + 'src/routes/approvalsDecideSupervised.integration.test.ts', // Create-path atomicity + tenant-isolation real-DB test (Task 7): imports // `__tests__/integration/setup` (real postgres pool + autoMigrate) and // lives in src/services/actionIntents/ outside the diff --git a/apps/api/vitest.integration.config.ts b/apps/api/vitest.integration.config.ts index 87d345d62..6534d540c 100644 --- a/apps/api/vitest.integration.config.ts +++ b/apps/api/vitest.integration.config.ts @@ -124,6 +124,14 @@ export default defineConfig({ // expiry + outbox} roll back together — a rollback the mocked unit suite // (which mocks db.transaction) cannot exercise. 'src/routes/approvalsDecideAtomicity.integration.test.ts', + // Co-located real-DB integration test for the Tier-3 supervised + // plain-decide branch (Task 6 fix round 1, finding 4): drives the real + // approve/deny route against genuine role/permission state to prove + // the live-RBAC re-check (buildAuthContextForIntent + checkToolPermission) + // actually wires up end to end — both are mocked wholesale in the unit + // suite (approvals.test.ts), so this is the only coverage that exercises + // the real modules against real Postgres. + 'src/routes/approvalsDecideSupervised.integration.test.ts', // Co-located real-DB integration test for the create-path atomicity + // tenant isolation (Task 7): injects a DB-level fault into the // intent_created outbox insert to prove {intent insert + fan-out + outbox} From 92e17ad5c7e15feba543d93508e38cf81b0a328c Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 03:04:48 -0600 Subject: [PATCH 11/17] feat(ai): pin four-eyes intent effect digests; fail release on drift Adds effectDigest.ts (SHA-256 over resolved tool-specific target content: run_script body, quote send revision, invoice/contract state, org status). createIntent computes and stores it for four_eyes intents only, inside the creation transaction; the release worker recomputes it immediately before execution and CASes to failed:content_changed on a mismatch, closing the TOCTOU gap where an approver signs off on a reference whose target mutates during the (up to 60-minute) four_eyes approval window. Co-Authored-By: Claude Fable 5 --- apps/api/src/jobs/intentReleaseWorker.test.ts | 96 +++++++- apps/api/src/jobs/intentReleaseWorker.ts | 30 +++ .../actionIntents/effectDigest.test.ts | 219 ++++++++++++++++++ .../services/actionIntents/effectDigest.ts | 198 ++++++++++++++++ .../actionIntents/intentService.test.ts | 89 ++++++- .../services/actionIntents/intentService.ts | 16 ++ 6 files changed, 646 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/services/actionIntents/effectDigest.test.ts create mode 100644 apps/api/src/services/actionIntents/effectDigest.ts diff --git a/apps/api/src/jobs/intentReleaseWorker.test.ts b/apps/api/src/jobs/intentReleaseWorker.test.ts index daf0bd974..164a9c6de 100644 --- a/apps/api/src/jobs/intentReleaseWorker.test.ts +++ b/apps/api/src/jobs/intentReleaseWorker.test.ts @@ -5,7 +5,7 @@ import { canonicalizeArguments, computeArgumentDigest } from '@breeze/shared/can // Hoisted shared mock state // --------------------------------------------------------------------------- -const { schema, dbState, intentServiceMock, actorContextMock, tenantStatusMock, aiToolsMock, aiGuardrailsMock, authMock, auditMock, metricsMock, sentryMock, toolTimeoutsMock, googleHeadlessMock, m365HeadlessMock } = vi.hoisted(() => { +const { schema, dbState, intentServiceMock, actorContextMock, tenantStatusMock, aiToolsMock, aiGuardrailsMock, authMock, auditMock, metricsMock, sentryMock, toolTimeoutsMock, googleHeadlessMock, m365HeadlessMock, effectDigestMock } = vi.hoisted(() => { const col = (name: string) => ({ name }); const actionIntentsTbl = { id: col('id') }; const approvalRequestsTbl = { id: col('id'), intentId: col('intent_id'), status: col('status') }; @@ -48,6 +48,14 @@ const { schema, dbState, intentServiceMock, actorContextMock, tenantStatusMock, isHeadlessM365Tool: vi.fn(() => false), executeM365ToolHeadless: vi.fn(), }, + // Task 7: computeEffectDigest itself is unit-tested in + // services/actionIntents/effectDigest.test.ts (the resolver map). Mocked + // wholesale here, same treatment as buildAuthContextForIntent/ + // getActiveOrgTenant/checkToolPermission above — this file only needs to + // prove the WORKER calls it and reacts correctly to a mismatch, not + // re-derive scripts/quotes/invoices table mocks it has no other reason + // to know about. + effectDigestMock: { computeEffectDigest: vi.fn(async () => null as string | null) }, }; }); @@ -93,6 +101,9 @@ vi.mock('../services/actionIntents/intentService', () => ({ vi.mock('../services/actionIntents/actorContext', () => ({ buildAuthContextForIntent: actorContextMock.buildAuthContextForIntent, })); +vi.mock('../services/actionIntents/effectDigest', () => ({ + computeEffectDigest: effectDigestMock.computeEffectDigest, +})); vi.mock('../services/tenantStatus', () => ({ getActiveOrgTenant: tenantStatusMock.getActiveOrgTenant, })); @@ -209,6 +220,12 @@ function baseIntent(overrides: Partial = {}): ActionIntent { executedAt: null, result: null, errorCode: null, + // Task 7: NULL by default (matches supervised intents and legacy/ + // unpinnable four_eyes intents) — the worker's effect-digest check + // short-circuits on null and never calls computeEffectDigest, so the + // existing fixtures/tests above don't need to know effectDigest exists. + // Tests that DO exercise the check override this explicitly. + effectDigest: null, ...overrides, } as ActionIntent; } @@ -295,6 +312,7 @@ describe('releaseApprovedIntent', () => { resetGoogleSecretActions(); googleHeadlessMock.isHeadlessGoogleTool.mockReturnValue(false); m365HeadlessMock.isHeadlessM365Tool.mockReturnValue(false); + effectDigestMock.computeEffectDigest.mockResolvedValue(null); }); it('double delivery: CAS approved->executing returns false — exits without touching anything else', async () => { @@ -637,6 +655,81 @@ describe('releaseApprovedIntent', () => { expect(metricsMock.recordActionIntentMetric).toHaveBeenCalledWith(intent.source, intent.actionName, 'executed'); }); + // Task 7 — effect-digest revalidation (tier3-supervised-four-eyes design + // §4.1): a four_eyes intent whose stored effect_digest no longer matches + // the freshly recomputed one (e.g. the approved script's body was edited + // during the approval window) must fail closed and never execute — this + // is the TOCTOU gap argumentDigest alone cannot close (see + // effectDigest.ts's header comment). + describe('effect-digest revalidation', () => { + it('content_changed: recomputed digest no longer matches the stored one — fails before executeTool, audit records the code', async () => { + const intent = baseIntent({ effectDigest: 'a'.repeat(64) }); + primeThroughRevalidation(intent); + effectDigestMock.computeEffectDigest.mockResolvedValueOnce('b'.repeat(64)); // drifted + intentServiceMock.transitionIntent.mockResolvedValueOnce(true); // executing -> failed + + await releaseApprovedIntent(intent.id); + + expect(effectDigestMock.computeEffectDigest).toHaveBeenCalledWith( + intent.actionName, + intent.arguments, + expect.anything(), + ); + expect(aiToolsMock.executeTool).not.toHaveBeenCalled(); + expect(intentServiceMock.transitionIntent).toHaveBeenLastCalledWith( + intent.id, + 'executing', + 'failed', + expect.objectContaining({ errorCode: 'content_changed' }), + ); + expect(auditMock.writeAuditEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + result: 'failure', + details: expect.objectContaining({ errorCode: 'content_changed' }), + }), + ); + expect(metricsMock.recordActionIntentMetric).toHaveBeenCalledWith(intent.source, intent.actionName, 'executed'); + }); + + it('proceeds to execute when the recomputed digest still matches the stored one', async () => { + const digest = 'c'.repeat(64); + const intent = baseIntent({ effectDigest: digest }); + primeThroughRevalidation(intent); + effectDigestMock.computeEffectDigest.mockResolvedValueOnce(digest); // unchanged + aiToolsMock.executeTool.mockResolvedValueOnce(JSON.stringify({ ok: true })); + intentServiceMock.transitionIntent.mockResolvedValueOnce(true); // executing -> completed + + await releaseApprovedIntent(intent.id); + + expect(aiToolsMock.executeTool).toHaveBeenCalledWith(intent.actionName, intent.arguments, fakeAuth); + expect(intentServiceMock.transitionIntent).toHaveBeenLastCalledWith( + intent.id, + 'executing', + 'completed', + expect.anything(), + ); + }); + + it('a NULL stored effect digest (supervised, or an unpinnable four_eyes intent) skips the check entirely', async () => { + const intent = baseIntent({ effectDigest: null }); + primeThroughRevalidation(intent); + aiToolsMock.executeTool.mockResolvedValueOnce(JSON.stringify({ ok: true })); + intentServiceMock.transitionIntent.mockResolvedValueOnce(true); // executing -> completed + + await releaseApprovedIntent(intent.id); + + expect(effectDigestMock.computeEffectDigest).not.toHaveBeenCalled(); + expect(aiToolsMock.executeTool).toHaveBeenCalled(); + expect(intentServiceMock.transitionIntent).toHaveBeenLastCalledWith( + intent.id, + 'executing', + 'completed', + expect.anything(), + ); + }); + }); + it('fails a session-aware tool with session_required and never calls executeTool', async () => { // Not google_* or m365_disable_user/m365_reset_password (both headless as // of Task 9) — a generic session-aware, non-headless tool name so this @@ -869,6 +962,7 @@ describe('secret-bearing release', () => { resetGoogleSecretActions(); googleHeadlessMock.isHeadlessGoogleTool.mockReturnValue(false); m365HeadlessMock.isHeadlessM365Tool.mockReturnValue(false); + effectDigestMock.computeEffectDigest.mockResolvedValue(null); }); it('seals a google_reset_password credential instead of storing prose', async () => { diff --git a/apps/api/src/jobs/intentReleaseWorker.ts b/apps/api/src/jobs/intentReleaseWorker.ts index 29e3b444d..1e85bc124 100644 --- a/apps/api/src/jobs/intentReleaseWorker.ts +++ b/apps/api/src/jobs/intentReleaseWorker.ts @@ -9,6 +9,7 @@ import { writeAuditEvent, requestLikeFromSnapshot } from '../services/auditEvent import { recordActionIntentEvent, recordActionIntentMetric } from '../services/actionIntents/metrics'; import { transitionIntent } from '../services/actionIntents/intentService'; import { revalidateApprovedIntentForRelease } from '../services/actionIntents/revalidateRelease'; +import { computeEffectDigest } from '../services/actionIntents/effectDigest'; import { executeTool, requiresLiveSession } from '../services/aiTools'; import { dbAccessContextFromAuth } from '../middleware/auth'; import { getToolTimeout, withToolTimeout } from '../services/toolTimeouts'; @@ -314,6 +315,35 @@ export async function releaseApprovedIntent(intentId: string): Promise { } const { auth } = revalidation; + // Effect-digest revalidation (tier3-supervised-four-eyes design §4.1, + // services/actionIntents/effectDigest.ts) — the TOCTOU gap argumentDigest + // alone cannot close: an approver approves a REFERENCE ("run script ", + // "send quote "), and the referenced content can drift during the (up + // to 60-minute) four_eyes approval window while the intent's own arguments + // stay byte-identical. `intent.effectDigest` is NULL for supervised + // intents (never pinned — 5-minute self-approved window) and for + // legacy/unpinnable four_eyes intents (no resolver existed, or the target + // didn't exist yet, at creation); both skip this check by design. A + // non-null stored digest that no longer matches the freshly-recomputed one + // means the target changed underneath the approval — fail closed, never + // execute. + if (intent.effectDigest !== null) { + // Runs in its own short system-scoped context (same discipline as Step 2 + // above) — this point in the function is between DB contexts (Step 2's + // box already closed), and `db` falls back to the raw, GUC-less pool + // outside any withDbAccessContext/withSystemDbAccessContext, which RLS + // would silently filter to zero rows rather than error on (see db/index.ts's + // getCurrentDb). Without this wrap every resolver would read "not found" + // and this check would fail EVERY pinned release, not just drifted ones. + const recomputedEffectDigest = await withSystemDbAccessContext(() => + computeEffectDigest(intent.actionName, intent.arguments, db), + ); + if (recomputedEffectDigest !== intent.effectDigest) { + await failIntent(intent, 'content_changed', { details: { actionName: intent.actionName } }); + return; + } + } + // Phase-1 deferral: the headless worker still cannot run session-aware M365 // Delegant/inline tools. Google Tier-3 tools ARE headless-executable // (org-keyed connection, resolved by intent.orgId) as of Phase 2, and M365 diff --git a/apps/api/src/services/actionIntents/effectDigest.test.ts b/apps/api/src/services/actionIntents/effectDigest.test.ts new file mode 100644 index 000000000..f81225582 --- /dev/null +++ b/apps/api/src/services/actionIntents/effectDigest.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Database } from '../../db'; +import { computeEffectDigest } from './effectDigest'; + +/** + * Generic fake `Database`: every `.select(...).from(...).where(...)` chain + * resolves to the next array shifted off `queue`, whether the resolver calls + * `.limit(1)` (single-row lookups) or `.orderBy(...)` (the quote-lines + * fetch, which has no limit). Resolvers that issue N sequential queries + * (manage_quotes:send: quote then lines; void_payment: payment then invoice) + * consume the queue in call order — tests supply rows in that same order. + */ +function makeFakeDb(queue: unknown[][]): { database: Database; select: ReturnType } { + const chain = { + limit: vi.fn(async () => queue.shift() ?? []), + orderBy: vi.fn(async () => queue.shift() ?? []), + }; + const select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => chain), + })), + })); + return { database: { select } as unknown as Database, select }; +} + +describe('computeEffectDigest', () => { + describe('unpinnable tools', () => { + it('returns null for a tool with no resolver entry', async () => { + const { database, select } = makeFakeDb([]); + const result = await computeEffectDigest('list_scripts', { orgId: 'org-1' }, database); + expect(result).toBeNull(); + expect(select).not.toHaveBeenCalled(); + }); + + it('returns null for a multiplexer tool whose action has no resolver entry', async () => { + const { database, select } = makeFakeDb([]); + const result = await computeEffectDigest('manage_quotes', { action: 'decline', quoteId: 'q-1' }, database); + expect(result).toBeNull(); + expect(select).not.toHaveBeenCalled(); + }); + }); + + describe('run_script', () => { + it('produces the same digest for the same script content', async () => { + const args = { scriptId: 'script-1', deviceIds: ['device-1'] }; + const db1 = makeFakeDb([[{ content: '#!/bin/bash\necho hi' }]]); + const db2 = makeFakeDb([[{ content: '#!/bin/bash\necho hi' }]]); + const d1 = await computeEffectDigest('run_script', args, db1.database); + const d2 = await computeEffectDigest('run_script', args, db2.database); + expect(d1).not.toBeNull(); + expect(d1).toMatch(/^[0-9a-f]{64}$/); + expect(d1).toBe(d2); + }); + + it('produces a different digest when the script body changed', async () => { + const args = { scriptId: 'script-1', deviceIds: ['device-1'] }; + const before = makeFakeDb([[{ content: 'echo original' }]]); + const after = makeFakeDb([[{ content: 'echo TAMPERED' }]]); + const digestBefore = await computeEffectDigest('run_script', args, before.database); + const digestAfter = await computeEffectDigest('run_script', args, after.database); + expect(digestBefore).not.toBe(digestAfter); + }); + + it('returns null when scriptId is missing from args', async () => { + const { database, select } = makeFakeDb([]); + const result = await computeEffectDigest('run_script', { deviceIds: ['device-1'] }, database); + expect(result).toBeNull(); + expect(select).not.toHaveBeenCalled(); + }); + + it('returns null when the script does not exist (deleted/typoed id)', async () => { + const { database } = makeFakeDb([[]]); + const result = await computeEffectDigest('run_script', { scriptId: 'ghost' }, database); + expect(result).toBeNull(); + }); + }); + + describe('manage_quotes:send', () => { + const updatedAt = new Date('2026-08-01T00:00:00Z'); + + it('hashes quote updated_at + line-item snapshot; a line edit changes the digest', async () => { + const args = { action: 'send', quoteId: 'quote-1' }; + const before = makeFakeDb([ + [{ updatedAt }], + [{ id: 'line-1', quantity: '1', unitPrice: '10.00', lineTotal: '10.00', sortOrder: 0 }], + ]); + const after = makeFakeDb([ + [{ updatedAt }], // header untouched + [{ id: 'line-1', quantity: '2', unitPrice: '10.00', lineTotal: '20.00', sortOrder: 0 }], // qty changed + ]); + const digestBefore = await computeEffectDigest('manage_quotes', args, before.database); + const digestAfter = await computeEffectDigest('manage_quotes', args, after.database); + expect(digestBefore).not.toBeNull(); + expect(digestBefore).not.toBe(digestAfter); + }); + + it('returns null when the quote does not exist', async () => { + const { database } = makeFakeDb([[]]); + const result = await computeEffectDigest('manage_quotes', { action: 'send', quoteId: 'ghost' }, database); + expect(result).toBeNull(); + }); + + it('does not resolve for a different action on the same tool (e.g. update)', async () => { + const { database, select } = makeFakeDb([]); + const result = await computeEffectDigest('manage_quotes', { action: 'update', quoteId: 'quote-1' }, database); + expect(result).toBeNull(); + expect(select).not.toHaveBeenCalled(); + }); + }); + + describe('manage_invoices', () => { + it('issue hashes the invoice updated_at', async () => { + const { database } = makeFakeDb([[{ updatedAt: new Date('2026-08-01T00:00:00Z') }]]); + const result = await computeEffectDigest( + 'manage_invoices', + { action: 'issue', invoiceId: 'inv-1' }, + database, + ); + expect(result).toMatch(/^[0-9a-f]{64}$/); + }); + + it('record_payment hashes the invoice updated_at', async () => { + const { database } = makeFakeDb([[{ updatedAt: new Date('2026-08-01T00:00:00Z') }]]); + const result = await computeEffectDigest( + 'manage_invoices', + { action: 'record_payment', invoiceId: 'inv-1', payment: { amount: 10 } }, + database, + ); + expect(result).toMatch(/^[0-9a-f]{64}$/); + }); + + it('void_payment resolves the owning invoice through the paymentId, then hashes its updated_at', async () => { + const { database, select } = makeFakeDb([ + [{ invoiceId: 'inv-1' }], // invoice_payments lookup by paymentId + [{ updatedAt: new Date('2026-08-01T00:00:00Z') }], // invoices lookup by invoiceId + ]); + const result = await computeEffectDigest( + 'manage_invoices', + { action: 'void_payment', paymentId: 'pay-1' }, + database, + ); + expect(result).toMatch(/^[0-9a-f]{64}$/); + expect(select).toHaveBeenCalledTimes(2); + }); + + it('void_payment returns null when the payment does not exist', async () => { + const { database } = makeFakeDb([[]]); + const result = await computeEffectDigest( + 'manage_invoices', + { action: 'void_payment', paymentId: 'ghost' }, + database, + ); + expect(result).toBeNull(); + }); + + it('returns null for a non-approval-gated action (e.g. update_header)', async () => { + const { database, select } = makeFakeDb([]); + const result = await computeEffectDigest( + 'manage_invoices', + { action: 'update_header', invoiceId: 'inv-1', patch: {} }, + database, + ); + expect(result).toBeNull(); + expect(select).not.toHaveBeenCalled(); + }); + }); + + describe('manage_contracts', () => { + it('activate hashes the contract updated_at', async () => { + const { database } = makeFakeDb([[{ updatedAt: new Date('2026-08-01T00:00:00Z') }]]); + const result = await computeEffectDigest( + 'manage_contracts', + { action: 'activate', contractId: 'contract-1' }, + database, + ); + expect(result).toMatch(/^[0-9a-f]{64}$/); + }); + + it('cancel hashes the contract updated_at, differing when the contract revised', async () => { + const before = makeFakeDb([[{ updatedAt: new Date('2026-08-01T00:00:00Z') }]]); + const after = makeFakeDb([[{ updatedAt: new Date('2026-08-02T00:00:00Z') }]]); + const args = { action: 'cancel', contractId: 'contract-1' }; + const digestBefore = await computeEffectDigest('manage_contracts', args, before.database); + const digestAfter = await computeEffectDigest('manage_contracts', args, after.database); + expect(digestBefore).not.toBe(digestAfter); + }); + }); + + describe('manage_organizations:update_org', () => { + it('hashes the current org status', async () => { + const { database } = makeFakeDb([[{ status: 'active' }]]); + const result = await computeEffectDigest( + 'manage_organizations', + { action: 'update_org', orgId: 'org-1', status: 'suspended' }, + database, + ); + expect(result).toMatch(/^[0-9a-f]{64}$/); + }); + + it('differs when the org was suspended between creation and release', async () => { + const active = makeFakeDb([[{ status: 'active' }]]); + const suspended = makeFakeDb([[{ status: 'suspended' }]]); + const args = { action: 'update_org', orgId: 'org-1' }; + const digestActive = await computeEffectDigest('manage_organizations', args, active.database); + const digestSuspended = await computeEffectDigest('manage_organizations', args, suspended.database); + expect(digestActive).not.toBe(digestSuspended); + }); + + it('returns null when the org does not exist', async () => { + const { database } = makeFakeDb([[]]); + const result = await computeEffectDigest( + 'manage_organizations', + { action: 'update_org', orgId: 'ghost' }, + database, + ); + expect(result).toBeNull(); + }); + }); +}); diff --git a/apps/api/src/services/actionIntents/effectDigest.ts b/apps/api/src/services/actionIntents/effectDigest.ts new file mode 100644 index 000000000..79302ec11 --- /dev/null +++ b/apps/api/src/services/actionIntents/effectDigest.ts @@ -0,0 +1,198 @@ +import { createHash } from 'node:crypto'; +import { and, eq, isNull } from 'drizzle-orm'; +import type { Database } from '../../db'; +import { scripts, quotes, quoteLines, invoices, invoicePayments, contracts, organizations } from '../../db/schema'; + +/** + * Effect-digest pinning for four_eyes action intents (spec + * docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md + * §4.1 / TOCTOU motivation). + * + * `argument_digest` (canonicalize.ts) proves the intent's INPUT hasn't changed + * since approval. It says nothing about the TARGET the input references — an + * approver can sign off on "run script " and, minutes later inside the + * four_eyes approval window, someone edits that script's body. The + * argument_digest is unchanged (the intent still says "run script "), but + * the approver approved content they never saw. `effect_digest` closes that + * gap: it hashes the MATERIALIZED content the action will actually operate on + * at creation time, and the release worker (jobs/intentReleaseWorker.ts) + * recomputes it immediately before execution — a mismatch means the target + * drifted underneath the approval and the release fails closed + * (`content_changed`), never executes. + * + * Supervised intents (5-minute window, self-approved) skip this entirely — + * intentService.ts only calls computeEffectDigest for approvalScope === + * 'four_eyes'. + * + * Resolver map keyed by `tool` (e.g. `run_script`) or `tool:action` (e.g. + * `manage_quotes:send`) for the multiplexer tools whose `args.action` + * discriminates the operation. A tool/action pair with no entry has no + * pinnable effect — computeEffectDigest returns null and effect_digest stays + * NULL on the intent, which the worker treats as "nothing to check" (not a + * failure). + * + * DESIGN CHOICE — a resolver whose referenced entity does not exist (e.g. a + * scriptId that was deleted, or simply typoed, between chat turns) returns + * null rather than throwing. computeEffectDigest then returns null and intent + * creation proceeds with effect_digest = NULL. This mirrors argument_digest's + * existing division of labor: creation only PINS content for later + * comparison, it does not validate that the target exists — RBAC/existence + * validation is owned by the tool handler at execution time (both at the + * inline approval path and, for the durable path, inside executeTool itself). + * The one asymmetry this creates: if the entity is created/becomes + * accessible AFTER intent creation but before release, there is no digest to + * detect that (there was nothing to hash at creation) — acceptable, since + * that path already goes through the tool handler's own authorization at + * execution and was never the TOCTOU class this feature targets (a target + * that existed and was approved, then mutated). + */ + +const EFFECT_DIGEST_RESOLVERS: Record< + string, + (args: Record, database: Database) => Promise +> = { + // run_script (Tier 3): pin the script body. A body edit between approval + // and release is exactly the drift this feature exists to catch. Filtered + // to non-deleted scripts, mirroring aiToolsScripts.ts's run_script handler + // — a script soft-deleted after approval resolves to "not found" here too, + // which correctly mismatches against the digest pinned at creation (the + // release fails closed instead of trying to run a deleted script). + run_script: async (args, database) => { + const scriptId = typeof args.scriptId === 'string' ? args.scriptId : null; + if (!scriptId) return null; + const [script] = await database + .select({ content: scripts.content }) + .from(scripts) + .where(and(eq(scripts.id, scriptId), isNull(scripts.deletedAt))) + .limit(1); + return script?.content ?? null; + }, + + // manage_quotes:send: pin the quote's revision (updated_at) plus a + // deterministic snapshot of its line items — a header-only updated_at + // covers header edits, but line edits (add/remove/reprice) don't always + // bump quotes.updated_at (line mutations are separate rows), so the lines + // are hashed explicitly. Ordered by (sortOrder, id) so the material is + // stable regardless of row-fetch order. + 'manage_quotes:send': async (args, database) => { + const quoteId = typeof args.quoteId === 'string' ? args.quoteId : null; + if (!quoteId) return null; + const [quote] = await database + .select({ updatedAt: quotes.updatedAt }) + .from(quotes) + .where(eq(quotes.id, quoteId)) + .limit(1); + if (!quote) return null; + const lines = await database + .select({ + id: quoteLines.id, + quantity: quoteLines.quantity, + unitPrice: quoteLines.unitPrice, + lineTotal: quoteLines.lineTotal, + sortOrder: quoteLines.sortOrder, + }) + .from(quoteLines) + .where(eq(quoteLines.quoteId, quoteId)) + .orderBy(quoteLines.sortOrder, quoteLines.id); + return JSON.stringify({ updatedAt: quote.updatedAt.toISOString(), lines }); + }, + + // manage_invoices issue/record_payment: pin the invoice's revision. + 'manage_invoices:issue': async (args, database) => resolveInvoiceUpdatedAt(args.invoiceId, database), + 'manage_invoices:record_payment': async (args, database) => resolveInvoiceUpdatedAt(args.invoiceId, database), + // manage_invoices:void_payment addresses a PAYMENT, not the invoice + // directly (args carries only `paymentId` — see aiToolsBilling.ts's + // MANAGE_INVOICES_REQUIRED_PARAMS). Resolve the owning invoice through the + // payment row first. + 'manage_invoices:void_payment': async (args, database) => { + const paymentId = typeof args.paymentId === 'string' ? args.paymentId : null; + if (!paymentId) return null; + const [payment] = await database + .select({ invoiceId: invoicePayments.invoiceId }) + .from(invoicePayments) + .where(eq(invoicePayments.id, paymentId)) + .limit(1); + if (!payment) return null; + return resolveInvoiceUpdatedAt(payment.invoiceId, database); + }, + + // manage_contracts activate/cancel: pin the contract's revision. + 'manage_contracts:activate': async (args, database) => resolveContractUpdatedAt(args.contractId, database), + 'manage_contracts:cancel': async (args, database) => resolveContractUpdatedAt(args.contractId, database), + + // manage_organizations:update_org: pin the org's CURRENT status — the + // field an approver's mental model of "what am I updating" is most likely + // to be invalidated by (e.g. someone else suspended/churned the org while + // this update sat in the approval queue). + 'manage_organizations:update_org': async (args, database) => { + const orgId = typeof args.orgId === 'string' ? args.orgId : null; + if (!orgId) return null; + const [org] = await database + .select({ status: organizations.status }) + .from(organizations) + .where(and(eq(organizations.id, orgId), isNull(organizations.deletedAt))) + .limit(1); + return org?.status ?? null; + }, +}; + +async function resolveInvoiceUpdatedAt( + invoiceIdArg: unknown, + database: Database, +): Promise { + const invoiceId = typeof invoiceIdArg === 'string' ? invoiceIdArg : null; + if (!invoiceId) return null; + const [invoice] = await database + .select({ updatedAt: invoices.updatedAt }) + .from(invoices) + .where(eq(invoices.id, invoiceId)) + .limit(1); + return invoice?.updatedAt ? invoice.updatedAt.toISOString() : null; +} + +async function resolveContractUpdatedAt( + contractIdArg: unknown, + database: Database, +): Promise { + const contractId = typeof contractIdArg === 'string' ? contractIdArg : null; + if (!contractId) return null; + const [contract] = await database + .select({ updatedAt: contracts.updatedAt }) + .from(contracts) + .where(eq(contracts.id, contractId)) + .limit(1); + return contract?.updatedAt ? contract.updatedAt.toISOString() : null; +} + +/** + * Resolves and hashes a tool call's materialized effect content, or returns + * null when the tool/action has no pinnable effect (or its referenced entity + * doesn't exist — see the DESIGN CHOICE note above). `database` is required, + * not imported ambiently: callers pass their own already-imported `db` + * (../../db) singleton. `db`'s methods proxy through the request-scoped + * AsyncLocalStorage context (see db/index.ts's withDbAccessContext), so + * passing that SAME singleton reference is what makes "compute inside the + * creation transaction" work — the digest read lands on whatever + * transaction the caller currently has open, without this module needing to + * import '../../db' (and its real Postgres client construction) itself. + * That keeps this module — and effectDigest.test.ts — free of any dependency + * on a live/mocked database module; tests just pass a fake `database`. + */ +export async function computeEffectDigest( + toolName: string, + args: Record, + database: Database, +): Promise { + const action = typeof args.action === 'string' ? args.action : undefined; + const resolver = (action && EFFECT_DIGEST_RESOLVERS[`${toolName}:${action}`]) || EFFECT_DIGEST_RESOLVERS[toolName]; + if (!resolver) return null; + + const material = await resolver(args, database); + if (material === null) return null; + + return createHash('sha256').update(material as string | Buffer).digest('hex'); +} + +// Exported for effectDigest.test.ts only — lets tests enumerate/target +// specific resolvers without hardcoding the tool/action key strings twice. +export const __EFFECT_DIGEST_RESOLVER_KEYS = Object.keys(EFFECT_DIGEST_RESOLVERS); diff --git a/apps/api/src/services/actionIntents/intentService.test.ts b/apps/api/src/services/actionIntents/intentService.test.ts index eedfcf3f6..ac02147b1 100644 --- a/apps/api/src/services/actionIntents/intentService.test.ts +++ b/apps/api/src/services/actionIntents/intentService.test.ts @@ -5,7 +5,7 @@ import { canonicalizeArguments, computeArgumentDigest } from './canonicalize'; // Hoisted shared mock state // --------------------------------------------------------------------------- -const { schema, dbState, authMock, guardrailMock, aiToolsState, permState, pushState, metricsMock, intentApproversState } = vi.hoisted(() => { +const { schema, dbState, authMock, guardrailMock, aiToolsState, permState, pushState, metricsMock, intentApproversState, effectDigestState } = vi.hoisted(() => { const col = (name: string) => ({ name }); const actionIntentsTbl = { id: col('id'), @@ -68,6 +68,13 @@ const { schema, dbState, authMock, guardrailMock, aiToolsState, permState, pushS // getUserPermissions round-trips, so it's mocked wholesale here rather // than reconstructed from db-table mocks. intentApproversState: { resolveIntentApprovers: vi.fn(async () => [] as string[]) }, + // Task 7 (effect-digest pinning): effectDigest.ts has its own dedicated + // unit suite (effectDigest.test.ts) covering the resolver map itself — + // mocked wholesale here so this file stays a test of createActionIntent's + // WIRING (calls it for four_eyes, skips it for supervised, persists the + // result) rather than re-deriving script/quote/invoice/contract/org + // table mocks this suite has no other reason to know about. + effectDigestState: { computeEffectDigest: vi.fn(async () => null as string | null) }, }; }); @@ -181,6 +188,10 @@ vi.mock('./metrics', () => ({ recordActionIntentEvent: metricsMock.recordActionIntentEvent, })); +vi.mock('./effectDigest', () => ({ + computeEffectDigest: effectDigestState.computeEffectDigest, +})); + vi.mock('drizzle-orm', () => ({ eq: vi.fn((...args: unknown[]) => ({ op: 'eq', args })), and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), @@ -214,6 +225,7 @@ import { type CreateActionIntentInput, } from './intentService'; import { db, withDbAccessContext } from '../../db'; +import { computeEffectDigest } from './effectDigest'; // --------------------------------------------------------------------------- // Test fixtures @@ -337,6 +349,7 @@ beforeEach(() => { // No eligible approvers by default — tests that need a fan-out opt in via // mockResolvedValueOnce. intentApproversState.resolveIntentApprovers.mockResolvedValue([]); + effectDigestState.computeEffectDigest.mockResolvedValue(null); }); // --------------------------------------------------------------------------- @@ -724,6 +737,80 @@ describe('createActionIntent — supervised/four_eyes scope', () => { }); }); +// --------------------------------------------------------------------------- +// Task 7 — effect-digest pinning wiring (spec +// docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md +// §4.1). computeEffectDigest itself is unit-tested in effectDigest.test.ts; +// this suite only asserts createActionIntent CALLS it correctly — four_eyes +// only, with the tool name/args/db it's supposed to, and persists whatever +// it returns onto the inserted row. +// --------------------------------------------------------------------------- + +describe('createActionIntent — effect-digest pinning wiring', () => { + it('computes and persists the effect digest for a four_eyes intent', async () => { + guardrailMock.checkGuardrails.mockReturnValue({ + tier: 3, + allowed: true, + requiresApproval: true, + description: 'Run a script on one or more devices', + approvalScope: 'four_eyes', + }); + intentApproversState.resolveIntentApprovers.mockResolvedValueOnce([REQUESTER_ID]); + effectDigestState.computeEffectDigest.mockResolvedValueOnce('d'.repeat(64)); + dbState.insertActionIntentsResults.push(echoInsertedIntent({ id: 'intent-fe-digest' })); + dbState.insertApprovalRequestsResults.push([{ id: 'approval-fe-digest' }]); + + await createActionIntent(makeAuth(), baseInput({ idempotencyKey: 'key-fe-digest' })); + + expect(computeEffectDigest).toHaveBeenCalledWith( + 'run_script', + { scriptId: 'script-1', deviceIds: ['device-1'] }, + db, + ); + expect(dbState.insertedActionIntentValues[0]?.effectDigest).toBe('d'.repeat(64)); + }); + + it('never calls computeEffectDigest for a supervised intent, and stores a null effect digest', async () => { + guardrailMock.checkGuardrails.mockReturnValue({ + tier: 3, + allowed: true, + requiresApproval: true, + description: 'Run a script on one or more devices', + approvalScope: 'supervised', + }); + intentApproversState.resolveIntentApprovers.mockResolvedValueOnce([]); + dbState.insertActionIntentsResults.push(echoInsertedIntent({ id: 'intent-sv-digest' })); + dbState.insertApprovalRequestsResults.push([{ id: 'approval-sv-digest' }]); + + await createActionIntent(makeAuth(), baseInput({ idempotencyKey: 'key-sv-digest' })); + + expect(computeEffectDigest).not.toHaveBeenCalled(); + expect(dbState.insertedActionIntentValues[0]?.effectDigest).toBeNull(); + }); + + it('stores a null effect digest for a four_eyes intent whose tool/action has no resolver', async () => { + guardrailMock.checkGuardrails.mockReturnValue({ + tier: 3, + allowed: true, + requiresApproval: true, + description: 'Send an email', + approvalScope: 'four_eyes', + }); + intentApproversState.resolveIntentApprovers.mockResolvedValueOnce([REQUESTER_ID]); + // Default beforeEach mock already resolves null — no resolver override needed. + dbState.insertActionIntentsResults.push(echoInsertedIntent({ id: 'intent-fe-unpinnable' })); + dbState.insertApprovalRequestsResults.push([{ id: 'approval-fe-unpinnable' }]); + + await createActionIntent( + makeAuth(), + baseInput({ toolName: 'm365_send_mail', input: { to: ['a@example.com'] }, idempotencyKey: 'key-fe-unpinnable' }), + ); + + expect(computeEffectDigest).toHaveBeenCalledWith('m365_send_mail', { to: ['a@example.com'] }, db); + expect(dbState.insertedActionIntentValues[0]?.effectDigest).toBeNull(); + }); +}); + // --------------------------------------------------------------------------- // Connection-hold regression (#1105 class) — approver resolution must not // run inside the write transaction. diff --git a/apps/api/src/services/actionIntents/intentService.ts b/apps/api/src/services/actionIntents/intentService.ts index bc6cd4439..b49b9d2a5 100644 --- a/apps/api/src/services/actionIntents/intentService.ts +++ b/apps/api/src/services/actionIntents/intentService.ts @@ -19,6 +19,7 @@ import { dispatchApprovalPushToTokens, getUserPushTokens } from '../expoPush'; import { canonicalizeArguments, computeArgumentDigest } from './canonicalize'; import { recordActionIntentEvent } from './metrics'; import { resolveIntentApprovers } from './intentApprovers'; +import { computeEffectDigest } from './effectDigest'; /** Statuses the partial `action_intents_org_idem_uniq` index dedupes on * (IMPORTANT-4 — migration 2026-07-18-action-intents.sql). Kept as a single @@ -370,6 +371,20 @@ export async function createActionIntent( let creation: CreationResult; try { creation = await withSystemDbAccessContext(async (): Promise => { + // Effect-digest pinning (tier3-supervised-four-eyes design §4.1, + // effectDigest.ts) — four_eyes only; supervised intents (5-minute + // window, self-approved) skip pinning entirely. Computed INSIDE this + // transaction, via the ambient `db` (same connection/snapshot the + // insert below runs on), so the pinned content and the row it's + // attached to are read/written atomically — no window where a + // concurrent edit lands between "read the target" and "create the + // intent". A tool/action with no resolver (or a target that doesn't + // exist yet) yields null, which leaves effect_digest NULL on the row — + // the release worker treats a NULL stored digest as "nothing to + // check", not a failure. + const effectDigest = + approvalScope === 'four_eyes' ? await computeEffectDigest(input.toolName, input.input, db) : null; + const [inserted] = await db .insert(actionIntents) .values({ @@ -401,6 +416,7 @@ export async function createActionIntent( correlationId: randomUUID(), approvalScope, classificationVersion: CLASSIFICATION_VERSION, + effectDigest, // `expiresAt` is the legacy column the pre-split reaper still reads; // `approvalExpiresAt` is the new Task-2 column the post-split reaper // reads. Dual-write the SAME value to both for rolling-upgrade From af62f3112ec63928eec86f2e00eb06fffb6e3b97 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 03:19:51 -0600 Subject: [PATCH 12/17] feat(api): live-authorized paginated approvals list + count; transport-neutral mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /pending now joins action_intents and drops rows whose intent is no longer pending_approval, or whose caller no longer qualifies live (four_eyes: current approvals:decide + org access; supervised: still the requester) — a demoted approver stops seeing (and count-ing) a row it can no longer decide. Adds keyset (createdAt, id) pagination (limit capped at 50, opaque cursor, additive nextCursor field) and a new GET /pending/count using the identical filter set. Both routes are now also mounted transport-neutrally at /api/v1/approvals, alongside the existing /api/v1/mobile/approvals alias, with the same mobileDeviceBlockedMiddleware so a blocked device can't dodge the check via the new prefix. Co-Authored-By: Claude Fable 5 --- apps/api/src/index.ts | 12 ++ apps/api/src/routes/approvals.test.ts | 172 ++++++++++++++++++++++---- apps/api/src/routes/approvals.ts | 171 ++++++++++++++++++++++--- 3 files changed, 316 insertions(+), 39 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index db87b8e6f..6336a197b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1036,6 +1036,18 @@ api.route('/update-rings', updateRingRoutes); api.use('/mobile/*', mobileDeviceBlockedMiddleware); api.route('/mobile', mobileRoutes); api.route('/mobile/approvals', approvalRoutes); +// Task 8 (tier3-supervised-four-eyes): transport-neutral alias so a web/CLI +// caller doesn't need the `/mobile` prefix to reach the same live-authorized +// pending/decide surface. `/api/v1/mobile/approvals` stays mounted above +// unchanged (mobile app's PREFIX constant, apps/mobile/src/services/approvals.ts). +// Same `approvalRoutes` instance, so its own `authMiddleware` applies +// identically either way — but it sits OUTSIDE `/mobile/*`, so it needs its +// own mobileDeviceBlockedMiddleware registration; otherwise a blocked phone +// could dodge the device-block check entirely by calling this prefix instead +// (same reasoning as the /authenticator and /me/approver-devices mounts +// below). +api.use('/approvals/*', mobileDeviceBlockedMiddleware); +api.route('/approvals', approvalRoutes); api.route('/action-intents', actionIntentsRoutes); // /authenticator is where the phone enrols its approver key, so it must be // behind the same check — a revoked handset registering a signing key is diff --git a/apps/api/src/routes/approvals.test.ts b/apps/api/src/routes/approvals.test.ts index 2b19351ab..f8df9c8cb 100644 --- a/apps/api/src/routes/approvals.test.ts +++ b/apps/api/src/routes/approvals.test.ts @@ -448,40 +448,162 @@ beforeEach(() => { }); }); -describe('GET /approvals/pending', () => { - it('returns only pending non-expired approvals for the authed user', async () => { - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ +// GET /pending now joins action_intents (Task 8) — db.select() for this +// route returns `{approval, intent}` pairs via a `.from().leftJoin().where() +// .orderBy()` chain, rather than raw approval_requests rows directly. +function mockPendingJoinResolves(rows: Array<{ approval: unknown; intent: unknown | null }>) { + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ - orderBy: vi.fn().mockResolvedValue([ - { - id: 'a1', - userId: TEST_USER.id, - requestingClientLabel: 'Claude Desktop', - requestingMachineLabel: "Todd's MacBook Pro", - requestingClientId: null, - requestingSessionId: null, - actionLabel: 'Delete 4 devices in Acme Corp', - actionToolName: 'breeze.devices.delete', - actionArguments: { ids: ['x'] }, - riskTier: 'high', - riskSummary: 'High impact: deletes data.', - status: 'pending', - expiresAt: new Date(Date.now() + 60_000), - decidedAt: null, - decisionReason: null, - createdAt: new Date(), - }, - ]), + orderBy: vi.fn().mockResolvedValue(rows), }), }), - } as any); + }), + } as any); +} + +function buildPendingApproval(overrides: Record = {}) { + return { + id: 'a1', + userId: TEST_USER.id, + requestingClientLabel: 'Claude Desktop', + requestingMachineLabel: "Todd's MacBook Pro", + requestingClientId: null, + requestingSessionId: null, + actionLabel: 'Delete 4 devices in Acme Corp', + actionToolName: 'breeze.devices.delete', + actionArguments: { ids: ['x'] }, + riskTier: 'high', + riskSummary: 'High impact: deletes data.', + status: 'pending', + expiresAt: new Date(Date.now() + 60_000), + decidedAt: null, + decisionReason: null, + executionId: null, + intentId: null, + isRecursive: false, + createdAt: new Date(), + ...overrides, + }; +} + +describe('GET /approvals/pending', () => { + it('returns only pending non-expired approvals for the authed user', async () => { + mockPendingJoinResolves([{ approval: buildPendingApproval(), intent: null }]); const res = await buildApp().request('/approvals/pending'); expect(res.status).toBe(200); const body = await res.json(); expect(body.approvals).toHaveLength(1); expect(body.approvals[0].id).toBe('a1'); + expect(body.nextCursor).toBeNull(); + }); + + it('excludes a four_eyes intent-linked row once the approver no longer holds approvals:decide (demoted approver)', async () => { + const approval = buildPendingApproval({ id: 'a2', intentId: 'intent-1' }); + const intent = { + id: 'intent-1', + orgId: 'org-9', + status: 'pending_approval', + approvalScope: 'four_eyes', + requestedByUserId: 'requester-9', + }; + mockPendingJoinResolves([{ approval, intent }]); + // Demoted: still org-accessible but no longer holds approvals:decide. + vi.mocked(userCanDecideApprovals).mockReturnValueOnce(false); + + const pendingRes = await buildApp().request('/approvals/pending'); + expect(pendingRes.status).toBe(200); + const pendingBody = await pendingRes.json(); + expect(pendingBody.approvals).toEqual([]); + expect(pendingBody.nextCursor).toBeNull(); + + vi.mocked(userCanDecideApprovals).mockReturnValueOnce(false); + const countRes = await buildApp().request('/approvals/pending/count'); + expect(countRes.status).toBe(200); + expect(await countRes.json()).toEqual({ count: 0 }); + }); + + it('returns a row for a supervised intent when the caller is still the requester', async () => { + const approval = buildPendingApproval({ id: 'a3', intentId: 'intent-2' }); + const intent = { + id: 'intent-2', + orgId: 'org-9', + status: 'pending_approval', + approvalScope: 'supervised', + requestedByUserId: TEST_USER.id, + }; + mockPendingJoinResolves([{ approval, intent }]); + + const res = await buildApp().request('/approvals/pending'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.approvals).toHaveLength(1); + expect(body.approvals[0].id).toBe('a3'); + }); + + it('excludes a supervised intent-linked row when the intent is no longer pending_approval', async () => { + const approval = buildPendingApproval({ id: 'a4', intentId: 'intent-3' }); + const intent = { + id: 'intent-3', + orgId: 'org-9', + status: 'approved', + approvalScope: 'supervised', + requestedByUserId: TEST_USER.id, + }; + mockPendingJoinResolves([{ approval, intent }]); + + const res = await buildApp().request('/approvals/pending'); + const body = await res.json(); + expect(body.approvals).toEqual([]); + }); + + it('paginates a 3-row seed with limit=2, walking the cursor to the last row', async () => { + const rows = [ + { approval: buildPendingApproval({ id: 'r1', createdAt: new Date('2026-08-05T12:00:03.000Z') }), intent: null }, + { approval: buildPendingApproval({ id: 'r2', createdAt: new Date('2026-08-05T12:00:02.000Z') }), intent: null }, + { approval: buildPendingApproval({ id: 'r3', createdAt: new Date('2026-08-05T12:00:01.000Z') }), intent: null }, + ]; + mockPendingJoinResolves(rows); + + const page1 = await buildApp().request('/approvals/pending?limit=2'); + const body1 = await page1.json(); + expect(body1.approvals.map((a: any) => a.id)).toEqual(['r1', 'r2']); + expect(body1.nextCursor).toBeTruthy(); + + const page2 = await buildApp().request( + `/approvals/pending?limit=2&cursor=${encodeURIComponent(body1.nextCursor)}`, + ); + const body2 = await page2.json(); + expect(body2.approvals.map((a: any) => a.id)).toEqual(['r3']); + expect(body2.nextCursor).toBeNull(); + }); + + it('caps limit at 50 even when a larger limit is requested', async () => { + const rows = Array.from({ length: 3 }, (_, i) => ({ + approval: buildPendingApproval({ id: `cap-${i}`, createdAt: new Date(Date.now() - i * 1000) }), + intent: null, + })); + mockPendingJoinResolves(rows); + + const res = await buildApp().request('/approvals/pending?limit=9999'); + const body = await res.json(); + expect(body.approvals).toHaveLength(3); + expect(body.nextCursor).toBeNull(); + }); +}); + +describe('GET /approvals/pending/count', () => { + it('returns a bare integer count matching the same filters as /pending', async () => { + mockPendingJoinResolves([ + { approval: buildPendingApproval({ id: 'c1' }), intent: null }, + { approval: buildPendingApproval({ id: 'c2' }), intent: null }, + ]); + + const res = await buildApp().request('/approvals/pending/count'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ count: 2 }); }); }); diff --git a/apps/api/src/routes/approvals.ts b/apps/api/src/routes/approvals.ts index b3779b6a8..9e75a8c41 100644 --- a/apps/api/src/routes/approvals.ts +++ b/apps/api/src/routes/approvals.ts @@ -53,30 +53,173 @@ export const approvalRoutes = new Hono(); approvalRoutes.use('*', authMiddleware); +// Keyset page size (spec §4.2 / task-8 brief): capped at 50 regardless of what +// the caller asks for, defaulting to 50 when omitted. +const PENDING_PAGE_MAX = 50; + +/** + * Opaque `(createdAt, id)` keyset cursor. Base64 of `|` — + * deliberately simple (no signing) since it only encodes a position in an + * already-authorized, per-caller result set; a forged cursor can at most + * reorder/replay pages of rows the caller could already see. + */ +function encodePendingCursor(createdAt: Date, id: string): string { + return Buffer.from(`${createdAt.toISOString()}|${id}`, 'utf8').toString('base64'); +} + +function decodePendingCursor(raw: string): { createdAt: Date; id: string } | null { + try { + const decoded = Buffer.from(raw, 'base64').toString('utf8'); + const sep = decoded.indexOf('|'); + if (sep === -1) return null; + const iso = decoded.slice(0, sep); + const id = decoded.slice(sep + 1); + if (!id) return null; + const createdAt = new Date(iso); + if (Number.isNaN(createdAt.getTime())) return null; + return { createdAt, id }; + } catch { + return null; + } +} + +// Matches the `ORDER BY created_at DESC, id DESC` the join below is fetched +// in: a row is "after" the cursor (i.e. belongs on the next page) if it sorts +// strictly later in that same DESC sequence. Compared by value, not array +// index, so a since-decided/filtered-out cursor row never stalls the walk. +function isAfterPendingCursor( + row: { createdAt: Date; id: string }, + cursor: { createdAt: Date; id: string }, +): boolean { + const rowMs = row.createdAt.getTime(); + const cursorMs = cursor.createdAt.getTime(); + if (rowMs !== cursorMs) return rowMs < cursorMs; + return row.id < cursor.id; +} + +/** + * The full, already-live-authorized set of this caller's pending approval + * rows (spec docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md + * §4.2 / task-8 brief). Shared by `GET /pending` and `GET /pending/count` so + * the two can never drift on what "pending and visible to me" means. + * + * approval_requests is Shape-6 (user-id-scoped) — `eq(approvalRequests.userId, + * userId)` alone already limits this to the caller's own rows under normal + * RLS. The join onto action_intents (Shape 1, org-scoped) is read under + * system scope, mirroring the decide handler's own linked-intent read: + * regardless of the caller's ambient scope, we need to see the intent's + * current state to decide whether the row is still live — that's an + * app-layer authorization decision made explicitly below, not something RLS + * visibility should gate. + * + * A row with NO intent (executionId/elevationRequestId-linked, or a plain + * dev-seed row — the one_source constraint makes execution_id XOR intent_id) + * has no scope/live-authz story to re-check and passes through unchanged. + * An intent-linked row is included only when its intent is still + * 'pending_approval' AND either: + * - supervised: the caller is still the intent's requester (the row is + * always fanned out to the requester for a supervised intent, but this + * re-derives identity rather than trusting row ownership alone); or + * - four_eyes: the caller currently still holds `approvals:decide` + org + * access for the intent's org — an approver demoted after fan-out must + * stop seeing (and being able to act on) the row, exactly like the + * decide handler's own stale-approver re-check. + */ +async function fetchAuthorizedPendingApprovals( + userId: string, + partnerId: string | null, +): Promise> { + const rows = await runOutsideDbContext(() => + withSystemDbAccessContext(() => + db + .select({ approval: approvalRequests, intent: actionIntents }) + .from(approvalRequests) + .leftJoin(actionIntents, eq(approvalRequests.intentId, actionIntents.id)) + .where( + and( + eq(approvalRequests.userId, userId), + eq(approvalRequests.status, 'pending'), + gt(approvalRequests.expiresAt, new Date()), + ), + ) + .orderBy(desc(approvalRequests.createdAt), desc(approvalRequests.id)), + ), + ); + + // One live-permission resolution per distinct four_eyes org in this page, + // not per row — a caller can have several pending rows against the same + // org, and getUserPermissions is already cached internally, but there's no + // reason to redo the canAccessOrg/userCanDecideApprovals pairing per row. + const fourEyesOrgIds = new Set(); + for (const { intent } of rows) { + if (intent && intent.approvalScope === 'four_eyes') fourEyesOrgIds.add(intent.orgId); + } + + const orgAuthorized = new Map(); + for (const orgId of fourEyesOrgIds) { + const perms = await runOutsideDbContext(() => + withSystemDbAccessContext(() => + getUserPermissions(userId, { partnerId: partnerId ?? undefined, orgId }), + ), + ); + orgAuthorized.set(orgId, !!perms && canAccessOrg(perms, orgId) && userCanDecideApprovals(perms)); + } + + return rows + .filter(({ approval, intent }) => { + if (!approval.intentId) return true; + if (!intent || intent.status !== 'pending_approval') return false; + if (intent.approvalScope === 'supervised') { + return intent.requestedByUserId === userId; + } + return orgAuthorized.get(intent.orgId) ?? false; + }) + .map(({ approval }) => approval); +} + approvalRoutes.get('/pending', async (c) => { const userId = c.get('auth').user.id; - const rows = await db - .select() - .from(approvalRequests) - .where( - and( - eq(approvalRequests.userId, userId), - eq(approvalRequests.status, 'pending'), - gt(approvalRequests.expiresAt, new Date()), - ) - ) - .orderBy(desc(approvalRequests.createdAt)); + const partnerId = c.get('auth').partnerId ?? null; + + const requestedLimit = Number(c.req.query('limit')); + const limit = + Number.isFinite(requestedLimit) && requestedLimit > 0 + ? Math.min(Math.floor(requestedLimit), PENDING_PAGE_MAX) + : PENDING_PAGE_MAX; + + const cursorParam = c.req.query('cursor'); + const cursor = cursorParam ? decodePendingCursor(cursorParam) : null; + + const authorized = await fetchAuthorizedPendingApprovals(userId, partnerId); + const afterCursor = cursor ? authorized.filter((r) => isAfterPendingCursor(r, cursor)) : authorized; + const page = afterCursor.slice(0, limit); + const nextCursor = + afterCursor.length > limit + ? encodePendingCursor(page[page.length - 1]!.createdAt, page[page.length - 1]!.id) + : null; // Batched lookup: one query resolves the customer tenant for ALL M365 - // mutation rows in this list (no N+1). - const tenants = await lookupCustomerTenants(rows); + // mutation rows in this page (no N+1). + const tenants = await lookupCustomerTenants(page); return c.json({ - approvals: rows.map((r) => + approvals: page.map((r) => serialize(r, (r.executionId && tenants.get(r.executionId)) || null), ), + nextCursor, }); }); +// Registered BEFORE the `/:id` param route below so Hono never captures +// 'count' as an :id. Same filters as `/pending`, unpaginated — the whole +// live-authorized set's length, not a raw table count(*) (which couldn't +// account for the app-layer four_eyes/supervised live-authz filter above). +approvalRoutes.get('/pending/count', async (c) => { + const userId = c.get('auth').user.id; + const partnerId = c.get('auth').partnerId ?? null; + const authorized = await fetchAuthorizedPendingApprovals(userId, partnerId); + return c.json({ count: authorized.length }); +}); + const denySchema = z.object({ reason: z.string().max(500).optional(), }); From 8aac906b0d45b6df74a10a983658ce829461c44d Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 03:42:24 -0600 Subject: [PATCH 13/17] feat(ai): supervised chat bridge, update_org status escalation, durable four-eyes contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveApprovalScope now takes `input` and resolves manage_organizations: update_org (four_eyes only when `status` is present, else supervised) and s1_isolate_device (four_eyes only on isolate:false containment release, else supervised) from argument content instead of a static tool/action table. Both are exempted from the contract test's static exactly-one-scope invariant via new TIER3_INPUT_AWARE_ACTIONS/TIER3_INPUT_AWARE_TOOLS sets and covered by dedicated both-branches tests. - aiAgentSdk's chat SSE approval_required event for the tier-3 durable-intent path now carries approvalScope alongside the existing selfApprovalRequestId, so the web approval card can distinguish a supervised self-approve from a four_eyes sole-operator self-approve (AiStreamEvent type updated in packages/shared). Push dispatch is unchanged: it was already fully owned and gated to four_eyes inside intentService.ts's createActionIntent; the only push call site in aiAgentSdk.ts is the unrelated Tier-2 legacy per_step bridge. - New durable-executable contract test (intentReleaseWorker.durable.contract.test.ts) asserts every four_eyes-classified tool (whole-tool or per-action) is releasable by the headless worker (isSessionRequiredForRelease, newly exported) rather than stuck needing a live chat session — it passes as-is, since Phase 2's Google/M365 headless dispatch already covers every four_eyes member. Co-Authored-By: Claude Fable 5 --- ...tentReleaseWorker.durable.contract.test.ts | 38 +++++++++ apps/api/src/jobs/intentReleaseWorker.ts | 26 ++++-- .../services/aiAgentSdk.approvalWait.test.ts | 69 ++++++++++++++- apps/api/src/services/aiAgentSdk.ts | 9 ++ ...iGuardrails.approvalScope.contract.test.ts | 50 ++++++++++- apps/api/src/services/aiGuardrails.ts | 85 +++++++++++++++---- apps/api/src/services/aiToolsOrgs.ts | 4 +- packages/shared/src/types/ai.ts | 2 +- 8 files changed, 255 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/jobs/intentReleaseWorker.durable.contract.test.ts diff --git a/apps/api/src/jobs/intentReleaseWorker.durable.contract.test.ts b/apps/api/src/jobs/intentReleaseWorker.durable.contract.test.ts new file mode 100644 index 000000000..d9cd50de9 --- /dev/null +++ b/apps/api/src/jobs/intentReleaseWorker.durable.contract.test.ts @@ -0,0 +1,38 @@ +/** + * Durable-executable contract (2026-08-05 tier3-supervised-four-eyes design, + * task 9): a `four_eyes` intent's whole reason for existing is to survive + * past the requesting chat session — a second `approvals:decide` holder may + * decide it minutes or (for mcp_api) up to a day later, long after the + * inline session that created it is gone. If the tool is ALSO + * `session_required` (no headless dispatch path — services/aiTools.ts's + * requiresLiveSession, minus the Google/M365 headless carve-outs), the + * durable release worker (jobs/intentReleaseWorker.ts) can never execute it: + * the approval could be granted and just sit there forever. + * + * No vi.mock — like aiGuardrails.approvalScope.contract.test.ts, this needs + * the REAL registries on both sides (the tier-3 classification tables AND + * the worker's session-required predicate) to be a meaningful contract. + */ +import { describe, it, expect } from 'vitest'; +import { TIER3_FOUR_EYES_ACTIONS, TIER3_FOUR_EYES_TOOLS } from '../services/aiGuardrails'; +import { isSessionRequiredForRelease } from './intentReleaseWorker'; + +describe('durable release: four_eyes tools must not be session_required', () => { + const fourEyesTools = Array.from( + new Set([...TIER3_FOUR_EYES_TOOLS, ...Object.keys(TIER3_FOUR_EYES_ACTIONS)]), + ); + + it('has at least one tool to check (guards against an accidentally-empty fixture)', () => { + expect(fourEyesTools.length).toBeGreaterThan(0); + }); + + it('every four_eyes-classified tool is durably releasable', () => { + const sessionRequired = fourEyesTools.filter((tool) => isSessionRequiredForRelease(tool)); + expect( + sessionRequired, + `these four_eyes tools require a live chat session and can never be released by the ` + + `durable worker once approved — either give them a headless dispatch path or move ` + + `them off four_eyes: ${sessionRequired.join(', ')}`, + ).toEqual([]); + }); +}); diff --git a/apps/api/src/jobs/intentReleaseWorker.ts b/apps/api/src/jobs/intentReleaseWorker.ts index 1e85bc124..06ffd560f 100644 --- a/apps/api/src/jobs/intentReleaseWorker.ts +++ b/apps/api/src/jobs/intentReleaseWorker.ts @@ -167,6 +167,26 @@ function auditReleaseFailure( } } +/** + * True iff this durable worker cannot release `toolName` because it requires + * a live chat SSE session (services/aiTools.ts's requiresLiveSession) and has + * no headless Google/M365 dispatch path (googleToolsHeadless.ts / + * m365ToolsHeadless.ts). Exported so + * jobs/intentReleaseWorker.durable.contract.test.ts (tier3-supervised-four-eyes + * design task 9) can assert every four_eyes-classified tool is durably + * releasable — a four_eyes intent's whole reason for existing is to survive + * past the requesting chat session (a second approver may decide it minutes + * or hours later), so if the tool is ALSO session_required here, an approved + * four_eyes intent could sit forever with nothing able to execute it. + */ +export function isSessionRequiredForRelease(toolName: string): boolean { + return ( + !isHeadlessGoogleTool(toolName) + && !isHeadlessM365Tool(toolName) + && requiresLiveSession(toolName) + ); +} + /** * CAS `executing -> failed` with the given `error_code`, then (only if the * CAS actually won) writes the failure audit/metric. `executed: true` also @@ -353,11 +373,7 @@ export async function releaseApprovedIntent(intentId: string): Promise { // session_required fail on "not a headless Google tool AND not a headless // M365 tool". See docs/superpowers/specs/ // 2026-07-19-action-intents-phase2-google-headless-design.md. - if ( - !isHeadlessGoogleTool(intent.actionName) - && !isHeadlessM365Tool(intent.actionName) - && requiresLiveSession(intent.actionName) - ) { + if (isSessionRequiredForRelease(intent.actionName)) { await failIntent(intent, 'session_required', { details: { actionName: intent.actionName } }); return; } diff --git a/apps/api/src/services/aiAgentSdk.approvalWait.test.ts b/apps/api/src/services/aiAgentSdk.approvalWait.test.ts index b3cc490f4..45043f182 100644 --- a/apps/api/src/services/aiAgentSdk.approvalWait.test.ts +++ b/apps/api/src/services/aiAgentSdk.approvalWait.test.ts @@ -201,12 +201,13 @@ async function until(fn: () => boolean, ms = 2000): Promise { } } -function tier3Guardrail() { +function tier3Guardrail(approvalScope: 'supervised' | 'four_eyes' = 'four_eyes') { vi.mocked(checkGuardrails).mockReturnValue({ allowed: true, tier: 3, requiresApproval: true, description: 'Execute command', + approvalScope, } as any); } @@ -271,6 +272,72 @@ describe('shared approval-wait budget (#3089)', () => { }); }); +// ============================================ +// Tier-3 approval scope propagation (2026-08-05 tier3-supervised-four-eyes) +// ============================================ + +describe('tier-3 approval scope propagation to the chat SSE approval event', () => { + it('supervised: approval event carries approvalScope + selfApprovalRequestId; aiAgentSdk never dispatches push itself', async () => { + tier3Guardrail('supervised'); + mockInsertReturning({ id: 'exec-supervised' }); + mockUpdateChain(); + mockCreateActionIntent.mockResolvedValue( + makeIntentSnapshot({ id: 'intent-supervised', requesterApprovalRequestId: 'appr-self' }), + ); + mockWaitForIntentDecision.mockResolvedValue('rejected'); + const session = makeActiveSession(); + + await createSessionPreToolUse(session)('execute_command', { deviceId: 'd-1' }); + + expect(session.eventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'approval_required', + executionId: 'exec-supervised', + approvalScope: 'supervised', + selfApprovalRequestId: 'appr-self', + intentBacked: true, + }), + ); + // Push for the durable intent path is dispatched (and gated to four_eyes) + // entirely inside services/actionIntents/intentService.ts's + // createActionIntent — mocked wholesale here. This just proves aiAgentSdk + // itself never makes an independent push call on the tier-3 path (its + // one push call site is the unrelated Tier-2 legacy per_step bridge), so + // there is no second, ungated dispatch to worry about for either scope. + expect(mockDispatchApprovalPushToTokens).not.toHaveBeenCalled(); + expect(mockGetUserPushTokens).not.toHaveBeenCalled(); + }); + + it('four_eyes: approval event carries approvalScope; aiAgentSdk still never dispatches push itself', async () => { + tier3Guardrail('four_eyes'); + mockInsertReturning({ id: 'exec-four-eyes' }); + mockUpdateChain(); + mockCreateActionIntent.mockResolvedValue( + makeIntentSnapshot({ id: 'intent-four-eyes', requesterApprovalRequestId: null }), + ); + mockWaitForIntentDecision.mockResolvedValue('rejected'); + const session = makeActiveSession(); + + await createSessionPreToolUse(session)('execute_command', { deviceId: 'd-1' }); + + expect(session.eventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'approval_required', + executionId: 'exec-four-eyes', + approvalScope: 'four_eyes', + selfApprovalRequestId: undefined, + intentBacked: true, + }), + ); + // four_eyes push fan-out is real production behavior (intentService.ts, + // gated on approvalScope === 'four_eyes') but happens inside the mocked + // createActionIntent here, not in aiAgentSdk.ts — same "no second + // dispatch site" assertion as the supervised case above. + expect(mockDispatchApprovalPushToTokens).not.toHaveBeenCalled(); + expect(mockGetUserPushTokens).not.toHaveBeenCalled(); + }); +}); + // ============================================ // settleApprovalWaits // ============================================ diff --git a/apps/api/src/services/aiAgentSdk.ts b/apps/api/src/services/aiAgentSdk.ts index 3cf13a565..746b3dc3e 100644 --- a/apps/api/src/services/aiAgentSdk.ts +++ b/apps/api/src/services/aiAgentSdk.ts @@ -964,6 +964,15 @@ export function createSessionPreToolUse(session: ActiveSession): PreToolUseCallb // org the requester holds no row and this stays undefined; the // card keeps its waiting state (four-eyes preserved). selfApprovalRequestId: intent.requesterApprovalRequestId ?? undefined, + // The tier3-supervised-four-eyes split (Task 1's checkGuardrails): + // 'supervised' means the card's self-approve button is the + // requester's OWN authorization, no second approver needed; + // 'four_eyes' preserves the pre-split waiting-for-someone-else + // semantics even when selfApprovalRequestId also happens to be + // set (the four_eyes sole-operator branch). The web card + // (AiApprovalDialog) uses this to decide whether the self-approve + // button is itself the whole decision or just this user's half of one. + approvalScope: guardrailCheck.approvalScope, // The intent's real server-side deadline, so the self-approve card's // countdown reflects actual expiry (created_at + CHAT_EXPIRY_MS) // rather than a mount-relative client constant that can silently drift diff --git a/apps/api/src/services/aiGuardrails.approvalScope.contract.test.ts b/apps/api/src/services/aiGuardrails.approvalScope.contract.test.ts index 341d1ea62..0adc82d8c 100644 --- a/apps/api/src/services/aiGuardrails.approvalScope.contract.test.ts +++ b/apps/api/src/services/aiGuardrails.approvalScope.contract.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect } from 'vitest'; import { TIER3_ACTIONS, TIER3_FOUR_EYES_ACTIONS, TIER3_FOUR_EYES_TOOLS, TIER3_SUPERVISED_ACTIONS, TIER3_SUPERVISED_TOOLS, + TIER3_INPUT_AWARE_ACTIONS, TIER3_INPUT_AWARE_TOOLS, checkGuardrails, resolveApprovalScope, } from './aiGuardrails'; import { getToolTier, getAllRegisteredToolNames } from './aiTools'; @@ -17,6 +18,10 @@ describe('tier-3 approval scope classification', () => { it('classifies every per-action tier-3 pair in exactly one scope', () => { for (const [tool, actions] of Object.entries(TIER3_ACTIONS)) { for (const action of actions) { + // Input-aware pairs (e.g. manage_organizations:update_org) are + // resolved dynamically by resolveApprovalScope's override hooks, not + // these static tables — covered by their own both-branches tests below. + if (TIER3_INPUT_AWARE_ACTIONS.has(`${tool}:${action}`)) continue; const inFourEyes = TIER3_FOUR_EYES_ACTIONS[tool]?.includes(action) ?? false; const inSupervised = TIER3_SUPERVISED_ACTIONS[tool]?.includes(action) ?? false; expect(inFourEyes !== inSupervised, `${tool}:${action} must be in exactly one scope table`).toBe(true); @@ -27,6 +32,9 @@ describe('tier-3 approval scope classification', () => { it('classifies every base-tier-3 tool in exactly one whole-tool scope set', () => { for (const tool of getAllRegisteredToolNames()) { if (getToolTier(tool) !== 3) continue; + // Input-aware tools (e.g. s1_isolate_device) are resolved dynamically — + // covered by their own both-branches tests below. + if (TIER3_INPUT_AWARE_TOOLS.has(tool)) continue; const inFourEyes = TIER3_FOUR_EYES_TOOLS.has(tool); const inSupervised = TIER3_SUPERVISED_TOOLS.has(tool); expect(inFourEyes !== inSupervised, `${tool} must be in exactly one whole-tool scope set`).toBe(true); @@ -41,12 +49,46 @@ describe('tier-3 approval scope classification', () => { }); it('defaults unclassified to four_eyes (fail-safe)', () => { - expect(resolveApprovalScope('some_future_unclassified_tool', undefined)).toBe('four_eyes'); + expect(resolveApprovalScope('some_future_unclassified_tool', undefined, {})).toBe('four_eyes'); }); - it('s1_isolate_device is whole-tool four-eyes-exempt via supervised set', () => { - // boolean `isolate` discriminator — cannot be action-classified (spec §3.1) - expect(TIER3_SUPERVISED_TOOLS.has('s1_isolate_device')).toBe(true); + it('update_org is input-aware: exempt from the static per-action tables', () => { + expect(TIER3_INPUT_AWARE_ACTIONS.has('manage_organizations:update_org')).toBe(true); + expect(TIER3_FOUR_EYES_ACTIONS.manage_organizations ?? []).not.toContain('update_org'); + expect(TIER3_SUPERVISED_ACTIONS.manage_organizations ?? []).not.toContain('update_org'); + }); + + it('update_org escalates to four_eyes only when a status change is present', () => { + expect( + resolveApprovalScope('manage_organizations', 'update_org', { orgId: 'o1', status: 'suspended' }), + ).toBe('four_eyes'); + expect( + resolveApprovalScope('manage_organizations', 'update_org', { orgId: 'o1', name: 'Renamed' }), + ).toBe('supervised'); + }); + + it('checkGuardrails surfaces update_org\'s input-aware scope', () => { + const withStatus = checkGuardrails('manage_organizations', { action: 'update_org', orgId: 'o1', status: 'suspended' }); + expect(withStatus.tier).toBe(3); + expect(withStatus.approvalScope).toBe('four_eyes'); + const withoutStatus = checkGuardrails('manage_organizations', { action: 'update_org', orgId: 'o1', name: 'Renamed' }); + expect(withoutStatus.tier).toBe(3); + expect(withoutStatus.approvalScope).toBe('supervised'); + }); + + it('s1_isolate_device is input-aware: exempt from the static whole-tool sets', () => { + expect(TIER3_INPUT_AWARE_TOOLS.has('s1_isolate_device')).toBe(true); + expect(TIER3_FOUR_EYES_TOOLS.has('s1_isolate_device')).toBe(false); + expect(TIER3_SUPERVISED_TOOLS.has('s1_isolate_device')).toBe(false); + }); + + it('s1_isolate_device escalates to four_eyes only on isolate:false (containment release)', () => { + // isolate:false — release, reverses a prior mitigation. + expect(resolveApprovalScope('s1_isolate_device', undefined, { deviceId: 'd1', isolate: false })).toBe('four_eyes'); + // isolate:true — urgent protective containment, must not wait. + expect(resolveApprovalScope('s1_isolate_device', undefined, { deviceId: 'd1', isolate: true })).toBe('supervised'); + // isolate missing — fail toward the urgent-containment default, not the stricter one. + expect(resolveApprovalScope('s1_isolate_device', undefined, { deviceId: 'd1' })).toBe('supervised'); }); it('checkGuardrails surfaces the scope on tier-3 results', () => { diff --git a/apps/api/src/services/aiGuardrails.ts b/apps/api/src/services/aiGuardrails.ts index 54248f142..1a6a35cd1 100644 --- a/apps/api/src/services/aiGuardrails.ts +++ b/apps/api/src/services/aiGuardrails.ts @@ -201,6 +201,8 @@ export const TIER3_ACTIONS: Record = { manage_quotes: ['send'], // Org lifecycle (issue #2366) — tenant-shape mutations require approval. // add_contact stays at the tool's base tier (it returns guidance only). + // update_org's approval SCOPE (not its tier) is input-aware — see + // resolveApprovalScope's override hook below. manage_organizations: ['create_org', 'update_org', 'create_site'], // s1_threat_action is registered at base Tier 3 (see TIER3_FOUR_EYES_TOOLS / // TIER3_SUPERVISED_TOOLS below for its whole-tool catch-all), but its @@ -217,15 +219,24 @@ export const TIER3_ACTIONS: Record = { // plain click, gated on their existing RBAC. Unclassified tier-3 surfaces // resolve four_eyes (fail-safe); the contract test forbids relying on that. // -// Two independent classification axes, both required for full coverage: +// Three classification mechanisms, together covering the full tier-3 surface: // 1. Per-action pairs drawn from TIER3_ACTIONS above (TIER3_*_ACTIONS). // 2. Whole registered tools whose BASE tier is 3 (TIER3_*_TOOLS) — this -// covers pure whole-tool surfaces (execute_command, s1_isolate_device) -// AND the catch-all for action-multiplexed base-Tier-3 tools whose -// action falls outside every TIER1/2/3_ACTIONS table (e.g. security_scan -// 'scan'/'status', which are not itself in TIER3_ACTIONS). A tool can -// legitimately appear in both an *_ACTIONS table and the complementary -// whole-tool set (manage_services, security_scan, s1_threat_action). +// covers pure whole-tool surfaces (execute_command) AND the catch-all +// for action-multiplexed base-Tier-3 tools whose action falls outside +// every TIER1/2/3_ACTIONS table (e.g. security_scan 'scan'/'status', +// which are not itself in TIER3_ACTIONS). A tool can legitimately +// appear in both an *_ACTIONS table and the complementary whole-tool +// set (manage_services, security_scan, s1_threat_action). +// 3. Input-aware overrides in resolveApprovalScope, for tool/action pairs +// whose scope depends on ARGUMENT CONTENT, not just the tool/action +// name — manage_organizations:update_org (status present vs a plain +// rename) and s1_isolate_device (boolean `isolate`, not an `action` +// string, so it can't even be an action-classified pair). These are +// deliberately NOT listed in the static tables above; they are +// exempted from the "classified in exactly one static table" contract +// test via TIER3_INPUT_AWARE_ACTIONS / TIER3_INPUT_AWARE_TOOLS below +// and instead get dedicated both-branches tests. // // See docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md §3.2 // for the full classification rationale. @@ -238,9 +249,11 @@ export const TIER3_FOUR_EYES_ACTIONS: Record = { manage_invoices: ['issue', 'void', 'record_payment', 'void_payment'], manage_contracts: ['activate', 'cancel'], manage_quotes: ['send'], - // update_org: status-split (rename/plain edits -> supervised) is Task 9; - // for this task the whole action resolves four_eyes. - manage_organizations: ['create_org', 'update_org'], + // update_org is deliberately ABSENT here: its scope is input-aware (a + // `status` change is four_eyes, a plain rename is supervised) and is + // resolved by resolveApprovalScope's override hook, not this static table. + // See TIER3_INPUT_AWARE_ACTIONS. + manage_organizations: ['create_org'], manage_tickets: ['move_org'], // Destroys or rewinds state. manage_hyperv_checkpoints: ['delete', 'apply'], @@ -321,10 +334,10 @@ export const TIER3_SUPERVISED_ACTIONS: Record = { export const TIER3_SUPERVISED_TOOLS = new Set([ // The customer's "regular work on a PC" (spec §3.2's explicit supervised list). 'execute_command', 'run_script', - // boolean `isolate` discriminator — cannot be action-classified (spec §3.1). - // Isolate is urgent protective containment (stays supervised); unisolate - // (release) sharing this tool is a known, documented v1 tradeoff. - 's1_isolate_device', + // s1_isolate_device is deliberately ABSENT here: its boolean `isolate` + // discriminator cannot be action-classified (spec §3.1), so its scope is + // resolved by resolveApprovalScope's override hook instead of this static + // set. See TIER3_INPUT_AWARE_TOOLS. 'manage_services', 'security_scan', // whole-tool catch-all complementing their _ACTIONS entries above 'manage_startup_items', 'take_screenshot', 'analyze_screen', @@ -339,10 +352,50 @@ export const TIER3_SUPERVISED_TOOLS = new Set([ 'trigger_agent_upgrade', 'trigger_agent_restart', ]); +/** + * Tier-3 (tool, action) pairs whose approval scope is resolved from INPUT + * content by resolveApprovalScope's override hooks, not a static lookup in + * TIER3_FOUR_EYES_ACTIONS / TIER3_SUPERVISED_ACTIONS. Exists purely so + * aiGuardrails.approvalScope.contract.test.ts can exempt these pairs from the + * "classified in exactly one static table" invariant — each one instead has + * its own dedicated both-branches test. + */ +export const TIER3_INPUT_AWARE_ACTIONS: ReadonlySet = new Set([ + 'manage_organizations:update_org', +]); + +/** + * Whole-tool counterpart of TIER3_INPUT_AWARE_ACTIONS — base-tier-3 tools + * whose scope is resolved from input content rather than TIER3_FOUR_EYES_TOOLS + * / TIER3_SUPERVISED_TOOLS membership (e.g. s1_isolate_device's boolean + * `isolate`, which has no `action` string to key a per-action pair on). + */ +export const TIER3_INPUT_AWARE_TOOLS: ReadonlySet = new Set([ + 's1_isolate_device', +]); + export function resolveApprovalScope( toolName: string, action: string | undefined, + input: Record, ): 'supervised' | 'four_eyes' { + // Input-aware overrides (spec §3.1) — scope depends on argument CONTENT, + // not just the tool/action name, so these cannot live in the static + // TIER3_*_ACTIONS / TIER3_*_TOOLS tables above. Checked first since neither + // pair is (or should be) also listed in a static table. + if (toolName === 'manage_organizations' && action === 'update_org') { + // A status change (suspend/churn/reactivate) severs or restores agent + // tenant access — externally binding, same class as the other + // TIER3_FOUR_EYES_ACTIONS members — vs a plain name edit, which is inert. + return 'status' in input ? 'four_eyes' : 'supervised'; + } + if (toolName === 's1_isolate_device') { + // isolate:false is containment RELEASE (reverses a prior mitigation — + // same rationale as s1_threat_action's rollback); isolate:true or + // missing is urgent protective containment, which must not wait on a + // second approver. + return input.isolate === false ? 'four_eyes' : 'supervised'; + } if (action && TIER3_FOUR_EYES_ACTIONS[toolName]?.includes(action)) return 'four_eyes'; if (action && TIER3_SUPERVISED_ACTIONS[toolName]?.includes(action)) return 'supervised'; if (TIER3_FOUR_EYES_TOOLS.has(toolName)) return 'four_eyes'; @@ -1074,7 +1127,7 @@ export function checkGuardrails( tier: 3, allowed: true, requiresApproval: true, - approvalScope: resolveApprovalScope(toolName, action), + approvalScope: resolveApprovalScope(toolName, action, input), description: buildApprovalDescription(toolName, action, input) }; } @@ -1097,7 +1150,7 @@ export function checkGuardrails( requiresApproval: true, // Only tier 3 gets a scope — a future base-Tier-4 tool would be blocked // (no approval path at all), not a bigger approval scope. - ...(baseTier === 3 ? { approvalScope: resolveApprovalScope(toolName, action) } : {}), + ...(baseTier === 3 ? { approvalScope: resolveApprovalScope(toolName, action, input) } : {}), description: buildApprovalDescription(toolName, action, input) }; } diff --git a/apps/api/src/services/aiToolsOrgs.ts b/apps/api/src/services/aiToolsOrgs.ts index 225b19cea..380df1005 100644 --- a/apps/api/src/services/aiToolsOrgs.ts +++ b/apps/api/src/services/aiToolsOrgs.ts @@ -475,7 +475,9 @@ export function registerOrgTools(aiTools: Map): void { 'Create and manage organizations and sites (new-customer intake). Actions: create_org (name required; creates the ' + 'org under the caller\'s partner WITH a default "Main Office" site — partner scope only), update_org (name/status ' + 'patch; suspending or churning an org severs its agents), create_site (orgId + name + optional address object), ' + - 'add_contact (not yet supported — returns guidance). create_org, update_org, and create_site require approval.', + 'add_contact (not yet supported — returns guidance). create_org, update_org, and create_site require approval: ' + + 'update_org needs a second approver only when it includes a status change (suspend/churn/reactivate); a plain ' + + 'name edit can be self-approved by the requester.', input_schema: { type: 'object' as const, properties: { diff --git a/packages/shared/src/types/ai.ts b/packages/shared/src/types/ai.ts index 540384326..c04a457d8 100644 --- a/packages/shared/src/types/ai.ts +++ b/packages/shared/src/types/ai.ts @@ -118,7 +118,7 @@ export type AiStreamEvent = | { type: 'content_delta'; delta: string } | { type: 'tool_use_start'; toolName: string; toolUseId: string; input: Record } | { type: 'tool_result'; toolUseId: string; output: unknown; isError: boolean } - | { type: 'approval_required'; executionId: string; approvalRequestId?: string; selfApprovalRequestId?: string; intentExpiresAt?: string; toolName: string; input: Record; description: string; requiresAdminApproval?: boolean; deviceContext?: { hostname: string; displayName?: string; status: string; lastSeenAt?: string; activeSessions?: Array<{ username: string; activityState?: string; idleMinutes?: number; sessionType: string }> }; intentBacked?: boolean } + | { type: 'approval_required'; executionId: string; approvalRequestId?: string; selfApprovalRequestId?: string; approvalScope?: 'supervised' | 'four_eyes'; intentExpiresAt?: string; toolName: string; input: Record; description: string; requiresAdminApproval?: boolean; deviceContext?: { hostname: string; displayName?: string; status: string; lastSeenAt?: string; activeSessions?: Array<{ username: string; activityState?: string; idleMinutes?: number; sessionType: string }> }; intentBacked?: boolean } | { type: 'plan_approval_required'; planId: string; steps: ActionPlanStep[] } | { type: 'plan_step_start'; planId: string; stepIndex: number; toolName: string } | { type: 'plan_step_complete'; planId: string; stepIndex: number; toolName: string; isError: boolean } From 9ae3626a964cd1571fa0792290835d9442288fb7 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 03:50:13 -0600 Subject: [PATCH 14/17] fix(ai): widen durable four-eyes contract to include input-aware tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fourEyesTools was built only from the static TIER3_FOUR_EYES_TOOLS/ TIER3_FOUR_EYES_ACTIONS tables, so s1_isolate_device (moved to TIER3_INPUT_AWARE_TOOLS) and manage_organizations (only covered by accident via create_org) were structurally invisible to the durable-executable contract even though they can resolve four_eyes at runtime via resolveApprovalScope's input-aware overrides. Union in TIER3_INPUT_AWARE_TOOLS and the tool-name projection of TIER3_INPUT_AWARE_ACTIONS — a conservative superset is correct since the assertion is about session-requirement, not resolved scope. Co-Authored-By: Claude Fable 5 --- ...tentReleaseWorker.durable.contract.test.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/api/src/jobs/intentReleaseWorker.durable.contract.test.ts b/apps/api/src/jobs/intentReleaseWorker.durable.contract.test.ts index d9cd50de9..87938a5ec 100644 --- a/apps/api/src/jobs/intentReleaseWorker.durable.contract.test.ts +++ b/apps/api/src/jobs/intentReleaseWorker.durable.contract.test.ts @@ -14,12 +14,31 @@ * the worker's session-required predicate) to be a meaningful contract. */ import { describe, it, expect } from 'vitest'; -import { TIER3_FOUR_EYES_ACTIONS, TIER3_FOUR_EYES_TOOLS } from '../services/aiGuardrails'; +import { + TIER3_FOUR_EYES_ACTIONS, TIER3_FOUR_EYES_TOOLS, + TIER3_INPUT_AWARE_ACTIONS, TIER3_INPUT_AWARE_TOOLS, +} from '../services/aiGuardrails'; import { isSessionRequiredForRelease } from './intentReleaseWorker'; describe('durable release: four_eyes tools must not be session_required', () => { + // Deliberately a SUPERSET of "definitely four_eyes": the input-aware tools + // (s1_isolate_device) and the tool half of the input-aware pairs + // (manage_organizations, via update_org) resolve four_eyes on SOME inputs + // at runtime but are invisible to the static TIER3_FOUR_EYES_TOOLS / + // TIER3_FOUR_EYES_ACTIONS tables by construction (that's the whole point + // of pulling them out into TIER3_INPUT_AWARE_* — see resolveApprovalScope). + // Folding them in here is still correct because this assertion is about + // session-requirement, which is a property of the TOOL, not the scope it + // resolves to on a given call — a false positive (a tool that's never + // actually four_eyes) only makes the check stricter than required, never + // wrong; omitting a true one would make it silently blind. const fourEyesTools = Array.from( - new Set([...TIER3_FOUR_EYES_TOOLS, ...Object.keys(TIER3_FOUR_EYES_ACTIONS)]), + new Set([ + ...TIER3_FOUR_EYES_TOOLS, + ...Object.keys(TIER3_FOUR_EYES_ACTIONS), + ...TIER3_INPUT_AWARE_TOOLS, + ...[...TIER3_INPUT_AWARE_ACTIONS].map((pair) => pair.split(':')[0]), + ]), ); it('has at least one tool to check (guards against an accidentally-empty fixture)', () => { From 8a176846fbaf7e5935d915ae37af1fa9726908ca Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 04:49:44 -0600 Subject: [PATCH 15/17] test(ai): supervised/four-eyes end-to-end integration coverage Adds intentSupervisedFourEyes.integration.test.ts, covering what the existing decide-path integration suites don't: a four_eyes intent fans out to both other eligible admins (never the requester), an approve at t+30min still succeeds and releases inside the new 60-minute four_eyes window (via direct DB timestamp manipulation, not a mocked clock), and a disabled second admin correctly engages the four_eyes sole-operator fallback instead of reclassifying to supervised. approvalsDecideSupervised.integration.test.ts already covers the supervised happy-path + revoked-permission; approvalsDecideAtomicity. integration.test.ts already covers the fan-in fault-injection rollback. Named in vitest.integration.config.ts's include list for discoverability (the file already lives under src/__tests__/integration/, which both that config's glob and vitest.config.ts's wholesale exclude already cover automatically). Co-Authored-By: Claude Fable 5 --- ...tentSupervisedFourEyes.integration.test.ts | 489 ++++++++++++++++++ apps/api/vitest.integration.config.ts | 10 + 2 files changed, 499 insertions(+) create mode 100644 apps/api/src/__tests__/integration/intentSupervisedFourEyes.integration.test.ts diff --git a/apps/api/src/__tests__/integration/intentSupervisedFourEyes.integration.test.ts b/apps/api/src/__tests__/integration/intentSupervisedFourEyes.integration.test.ts new file mode 100644 index 000000000..9e6722be7 --- /dev/null +++ b/apps/api/src/__tests__/integration/intentSupervisedFourEyes.integration.test.ts @@ -0,0 +1,489 @@ +/** + * Real-Postgres end-to-end coverage for the tier3-supervised-four-eyes split + * (spec docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md) + * that the earlier per-task suites do NOT already exercise: + * + * (a) a `four_eyes` intent fans out approval rows to BOTH other eligible + * admins and creates NONE for the requester (the multi-approver branch, + * `services/actionIntents/intentService.ts` lines ~541-547) — distinct + * from `approvalsDecideAtomicity.integration.test.ts`'s two-approver + * fixture, which asserts row COUNT but never asserts WHICH users own + * the rows or that the requester is excluded. + * (b) a `four_eyes` approval decided at t+30min — past the OLD 5-minute + * supervised-style window, but inside the NEW 60-minute four_eyes + * window (`FOUR_EYES_CHAT_EXPIRY_MS`, intentService.ts) — still + * succeeds and the durable release worker (`intentReleaseWorker.ts`) + * actually executes it. Timestamps are moved by direct DB UPDATE + * (real elapsed-time simulation), not a mocked clock, so the decide + * route's genuine `expiresAt <= new Date()` comparison and the + * worker's genuine `release_by`-gated claim CAS are both exercised + * against real Postgres `now()`. + * (c) when the only OTHER eligible approver is a disabled admin, + * `resolveIntentApprovers`' `status = 'active'` join filters them out + * and the sole-operator fallback engages for the requester — who still + * gets a `four_eyes`-scoped row (not reclassified `supervised`). + * + * `approvalsDecideSupervised.integration.test.ts` already covers the + * supervised happy-path + revoked-permission; `approvalsDecideAtomicity. + * integration.test.ts` already covers the fan-in fault-injection rollback. + * Neither touches the elapsed-time release path or the disabled-admin + * fallback, which is what this file adds. + * + * Lives under `src/__tests__/integration/`, which both `src/**\/*.test.ts` + * wildcards (`vitest.integration.config.ts`'s `include` glob and + * `vitest.config.ts`'s wholesale `src/__tests__/integration/**` exclude) + * already cover automatically — unlike the co-located route/job suites this + * file's header cites, it needs no explicit per-file dual-hand-list entry. + * Named explicitly in `vitest.integration.config.ts`'s `include` anyway, + * purely for discoverability (the same pattern `staleBackupReaper. + * integration.test.ts` uses). + */ +import './setup'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { randomUUID } from 'crypto'; +import { and, eq } from 'drizzle-orm'; +import { Hono } from 'hono'; + +// Mock the Google SDK client at construction ONLY, exactly like +// intentReleaseWorkerGoogleHeadless.integration.test.ts — the real connection +// load + secret decrypt + org RLS read still run against Postgres; only the +// outbound Google API call is faked. Hoisted so the (hoisted) vi.mock factory +// can close over the same spy the assertions read. +const h = vi.hoisted(() => { + const usersUpdate = vi.fn(async () => ({ data: {} })); + return { usersUpdate }; +}); + +vi.mock('../../services/googleClient', () => ({ + getDirectoryClient: vi.fn(() => ({ + users: { update: h.usersUpdate, get: vi.fn() }, + members: {}, + groups: {}, + mobiledevices: {}, + })), + getGmailClient: vi.fn(() => ({})), + getCalendarClient: vi.fn(() => ({})), + getLicensingClient: vi.fn(() => ({})), + normalizeGoogleError: (err: unknown) => ({ + code: 'google_error', + message: err instanceof Error ? err.message : String(err), + }), + GoogleApiError: class GoogleApiError extends Error {}, +})); + +import { db, withSystemDbAccessContext } from '../../db'; +import { getTestDb } from './setup'; +import { actionIntents } from '../../db/schema/actionIntents'; +import { approvalRequests } from '../../db/schema/approvals'; +import { googleWorkspaceConnections } from '../../db/schema/google'; +import { encryptSecret } from '../../services/secretCrypto'; +import { createActionIntent } from '../../services/actionIntents/intentService'; +import { PERMISSIONS } from '../../services/permissions'; +import { buildOrgAccessClosures, type AuthContext } from '../../middleware/auth'; +import { createAccessToken, type TokenPayload } from '../../services/jwt'; +import { + assignUserToOrganization, + createOrganization, + createPartner, + createRole, + createUser, + grantRolePermissions, +} from './db-utils'; +import { approvalRoutes } from '../../routes/approvals'; +import { releaseApprovedIntent } from '../../jobs/intentReleaseWorker'; + +const runDb = it.runIf(!!process.env.DATABASE_URL); + +// google_suspend_user is TIER3_FOUR_EYES_TOOLS-classified (aiGuardrails.ts) +// and headless-executable (Phase 2), giving this suite a real end-to-end +// worker path to prove execution without faking anything but the outbound +// Google network call. +const TOOL_NAME = 'google_suspend_user'; +const GOOGLE_EXECUTE = { resource: 'google', action: 'execute' } as const; + +const THIRTY_MIN_MS = 30 * 60 * 1000; + +/** Real org-scope AuthContext, same shape authMiddleware produces (reuses + * buildOrgAccessClosures so org-access semantics can't drift from the live + * path). Mirrors the sibling decide-path integration suites' helper. */ +function orgAuth( + user: { id: string; email: string }, + orgId: string, + partnerId: string, + roleId: string, +): AuthContext { + const { orgCondition, canAccessOrg } = buildOrgAccessClosures([orgId]); + return { + principal: { kind: 'user_session' }, + user: { id: user.id, email: user.email, name: 'Test User', isPlatformAdmin: false }, + token: { + sub: user.id, + email: user.email, + roleId, + orgId, + partnerId, + scope: 'organization', + type: 'access', + mfa: true, + }, + partnerId, + orgId, + scope: 'organization', + accessibleOrgIds: [orgId], + orgCondition, + canAccessOrg, + }; +} + +/** A real access token, minted the same way the sibling decide-path suites' + * approverAccessToken/requesterAccessToken helpers are. */ +async function accessTokenFor( + user: { id: string; email: string }, + orgId: string, + partnerId: string, + roleId: string, +): Promise { + const payload: Omit = { + sub: user.id, + email: user.email, + roleId, + orgId, + partnerId, + scope: 'organization', + mfa: false, + aep: 1, + mep: 1, + sid: randomUUID(), + }; + return createAccessToken(payload); +} + +interface TwoAdminScenario { + partnerId: string; + orgId: string; + requester: { id: string; email: string }; + requesterRoleId: string; + admin1: { id: string; email: string }; + admin2: { id: string; email: string }; + adminRoleId: string; +} + +/** + * Seeds one org under one partner + a requester (holding google:execute, + * the RBAC entry TOOL_PERMISSIONS maps google_suspend_user to — needed for + * both the create-path and the release worker's requester-RBAC revalidation) + * plus TWO active admins sharing one org role that holds approvals:decide — + * the population that fans a four_eyes intent out to exactly two rows, + * neither of them the requester. + */ +async function seedTwoAdminScenario(): Promise { + const partner = await createPartner(); + const org = await createOrganization({ partnerId: partner.id }); + + const requesterRole = await createRole({ scope: 'organization', orgId: org.id }); + await grantRolePermissions(requesterRole.id, [GOOGLE_EXECUTE]); + + const adminRole = await createRole({ scope: 'organization', orgId: org.id }); + await grantRolePermissions(adminRole.id, [PERMISSIONS.APPROVALS_DECIDE]); + + const requester = await createUser({ + partnerId: partner.id, + orgId: org.id, + email: `requester-${randomUUID()}@fourEyes.test`, + }); + await assignUserToOrganization(requester.id, org.id, requesterRole.id); + + const admin1 = await createUser({ + partnerId: partner.id, + orgId: org.id, + email: `admin1-${randomUUID()}@fourEyes.test`, + }); + await assignUserToOrganization(admin1.id, org.id, adminRole.id); + + const admin2 = await createUser({ + partnerId: partner.id, + orgId: org.id, + email: `admin2-${randomUUID()}@fourEyes.test`, + }); + await assignUserToOrganization(admin2.id, org.id, adminRole.id); + + return { + partnerId: partner.id, + orgId: org.id, + requester: { id: requester.id, email: requester.email }, + requesterRoleId: requesterRole.id, + admin1: { id: admin1.id, email: admin1.email }, + admin2: { id: admin2.id, email: admin2.email }, + adminRoleId: adminRole.id, + }; +} + +async function createFourEyesIntent(s: TwoAdminScenario): Promise<{ + intentId: string; + approvalRequestIds: string[]; +}> { + const auth = orgAuth(s.requester, s.orgId, s.partnerId, s.requesterRoleId); + const snapshot = await createActionIntent(auth, { + toolName: TOOL_NAME, + input: { userEmail: 'target@customer.example', reason: 'offboarding' }, + source: 'chat', + }); + expect(snapshot.status).toBe('pending_approval'); + return { intentId: snapshot.id, approvalRequestIds: snapshot.approvalRequestIds }; +} + +/** Seed the org's single Google connection (encrypted SA key). The key + * content is irrelevant because getDirectoryClient is mocked — but the + * decrypt must SUCCEED for the connection to be considered available. */ +async function seedGoogleConnection(orgId: string): Promise { + const encryptedKey = encryptSecret('{"fake":"key"}'); + await getTestDb() + .insert(googleWorkspaceConnections) + .values({ + orgId, + customerDomain: 'customer.example', + adminEmail: 'admin@customer.example', + serviceAccountEmail: 'sa@proj.iam.gserviceaccount.com', + serviceAccountKey: encryptedKey!, + status: 'active', + }); +} + +/** + * Backdates a pending four_eyes intent's approval window by 30 minutes: + * `approval_requests.created_at` moves 30 minutes into the past and + * `approval_requests.expires_at` moves 30 minutes EARLIER than it was (from + * now+60min to now+30min) — simulating that decide is happening at t+30min + * of the 60-minute four_eyes window, not t+0. Direct DB timestamp + * manipulation (not a mocked clock), matching the pattern the task brief + * calls for: the whole point is to prove the REAL `expiresAt <= new Date()` + * comparison in the decide route (`approvalRequests.expiresAt`, the column it + * actually reads) still passes 30 minutes in. + * + * `action_intents.approval_expires_at` is mirrored the same way for + * narrative consistency (nothing on the decide/release path reads it), but + * `action_intents.created_at`/`expires_at` are deliberately left untouched: + * a DB-level immutability trigger (`action_intents_block_content_update`, + * migration 2026-08-14-intent-approval-scope-and-deadlines.sql) blocks any + * UPDATE touching those two columns with `action_intents content is + * immutable` — `approval_expires_at` is not in that trigger's guarded column + * list, so it alone is safely mutable here. + */ +async function backdateThirtyMinutes(intentId: string): Promise { + const now = Date.now(); + await withSystemDbAccessContext(async () => { + await db + .update(actionIntents) + .set({ + approvalExpiresAt: new Date(now + THIRTY_MIN_MS), + }) + .where(eq(actionIntents.id, intentId)); + await db + .update(approvalRequests) + .set({ + createdAt: new Date(now - THIRTY_MIN_MS), + expiresAt: new Date(now + THIRTY_MIN_MS), + }) + .where(eq(approvalRequests.intentId, intentId)); + }); +} + +async function approveViaRoute( + approver: { id: string; email: string }, + orgId: string, + partnerId: string, + roleId: string, + approvalRowId: string, +): Promise { + const token = await accessTokenFor(approver, orgId, partnerId, roleId); + const app = new Hono(); + app.route('/approvals', approvalRoutes); + return app.request(`/approvals/${approvalRowId}/approve`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); +} + +async function readIntent(intentId: string) { + return withSystemDbAccessContext(async () => { + const [row] = await db.select().from(actionIntents).where(eq(actionIntents.id, intentId)).limit(1); + return row!; + }); +} + +beforeEach(() => { + h.usersUpdate.mockClear(); + h.usersUpdate.mockResolvedValue({ data: {} }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('tier3-supervised-four-eyes split: end-to-end coverage (real Postgres, breeze_app)', () => { + runDb( + 'four_eyes fan-out: rows land for BOTH admins, none for the requester', + async () => { + const s = await seedTwoAdminScenario(); + const { intentId, approvalRequestIds } = await createFourEyesIntent(s); + + expect(approvalRequestIds).toHaveLength(2); + + const [intent] = await withSystemDbAccessContext(() => + db.select({ approvalScope: actionIntents.approvalScope }).from(actionIntents).where(eq(actionIntents.id, intentId)), + ); + expect(intent?.approvalScope).toBe('four_eyes'); + + const rows = await withSystemDbAccessContext(() => + db + .select({ userId: approvalRequests.userId }) + .from(approvalRequests) + .where(eq(approvalRequests.intentId, intentId)), + ); + const ownerIds = rows.map((r) => r.userId).sort(); + expect(ownerIds).toEqual([s.admin1.id, s.admin2.id].sort()); + expect(ownerIds).not.toContain(s.requester.id); + + const requesterRows = await withSystemDbAccessContext(() => + db + .select({ id: approvalRequests.id }) + .from(approvalRequests) + .where(and(eq(approvalRequests.intentId, intentId), eq(approvalRequests.userId, s.requester.id))), + ); + expect(requesterRows).toHaveLength(0); + }, + ); + + runDb( + 'four_eyes approve at t+30min (past the old 5-min window, inside the 60-min four_eyes window) — release worker executes it', + async () => { + const s = await seedTwoAdminScenario(); + await seedGoogleConnection(s.orgId); + const { intentId, approvalRequestIds } = await createFourEyesIntent(s); + expect(approvalRequestIds).toHaveLength(2); + + await backdateThirtyMinutes(intentId); + + const [admin1Row] = await withSystemDbAccessContext(() => + db + .select({ id: approvalRequests.id }) + .from(approvalRequests) + .where(and(eq(approvalRequests.intentId, intentId), eq(approvalRequests.userId, s.admin1.id))), + ); + expect(admin1Row).toBeTruthy(); + + // Decide happens at REAL wall-clock "now" — 30 minutes after the + // (backdated) creation time, still inside the 60-minute four_eyes + // window (expires_at was moved to now+30min, not into the past). + const res = await approveViaRoute(s.admin1, s.orgId, s.partnerId, s.adminRoleId, admin1Row!.id); + expect(res.status).toBe(200); + + const approvedIntent = await readIntent(intentId); + expect(approvedIntent.status).toBe('approved'); + expect(approvedIntent.releaseBy).toBeInstanceOf(Date); + // The release lease is stamped fresh off approval time (the "59:59 + // trap" fix, RELEASE_LEASE_MS) — NOT derived from the backdated + // approval_expires_at — so it must be comfortably in the future. + expect(approvedIntent.releaseBy!.getTime()).toBeGreaterThan(Date.now()); + + // Sibling expiry: admin2's row is expired in the same commit. + const [admin2Row] = await withSystemDbAccessContext(() => + db + .select({ status: approvalRequests.status }) + .from(approvalRequests) + .where(and(eq(approvalRequests.intentId, intentId), eq(approvalRequests.userId, s.admin2.id))), + ); + expect(admin2Row?.status).toBe('expired'); + + // The durable release worker (no BullMQ needed — releaseApprovedIntent + // is the exact function the real worker's job processor calls) claims + // the still-fresh release_by lease and runs the tool for real. + await releaseApprovedIntent(intentId); + + expect(h.usersUpdate).toHaveBeenCalledTimes(1); + expect(h.usersUpdate).toHaveBeenCalledWith({ + userKey: 'target@customer.example', + requestBody: { suspended: true }, + }); + + const executedIntent = await readIntent(intentId); + expect(executedIntent.status).toBe('completed'); + expect(executedIntent.errorCode).toBeNull(); + expect(executedIntent.executedAt).not.toBeNull(); + }, + ); + + runDb( + 'disabled second admin: sole-operator fallback engages for the requester, still four_eyes-scoped', + async () => { + const partner = await createPartner(); + const org = await createOrganization({ partnerId: partner.id }); + + // ONE role holding approvals:decide, shared by the requester (making + // them eligible) and a second admin who is DISABLED. + // resolveIntentApprovers' `users.status = 'active'` join (intentApprovers.ts) + // filters the disabled admin out of the eligible set entirely — not just + // out of the fan-out, but out of "is anyone else eligible" too, which is + // exactly what lets the sole-operator branch engage instead of the + // no-eligible-approvers cancel path. + const adminRole = await createRole({ scope: 'organization', orgId: org.id }); + await grantRolePermissions(adminRole.id, [PERMISSIONS.APPROVALS_DECIDE, GOOGLE_EXECUTE]); + + const requester = await createUser({ + partnerId: partner.id, + orgId: org.id, + email: `requester-${randomUUID()}@soleop.test`, + }); + await assignUserToOrganization(requester.id, org.id, adminRole.id); + + const disabledAdmin = await createUser({ + partnerId: partner.id, + orgId: org.id, + email: `disabled-admin-${randomUUID()}@soleop.test`, + status: 'disabled', + }); + await assignUserToOrganization(disabledAdmin.id, org.id, adminRole.id); + + const auth = orgAuth( + { id: requester.id, email: requester.email }, + org.id, + partner.id, + adminRole.id, + ); + const snapshot = await createActionIntent(auth, { + toolName: TOOL_NAME, + input: { userEmail: 'target@customer.example', reason: 'offboarding' }, + source: 'chat', + }); + + // Sole-operator fallback: exactly one row, owned by the requester — + // NOT the no-eligible-approvers auto-cancel (which would land here if + // the disabled admin were still counted as "someone else exists"). + expect(snapshot.status).toBe('pending_approval'); + expect(snapshot.approvalRequestIds).toHaveLength(1); + expect(snapshot.requesterApprovalRequestId).toBe(snapshot.approvalRequestIds[0]); + + const [intent] = await withSystemDbAccessContext(() => + db + .select({ approvalScope: actionIntents.approvalScope }) + .from(actionIntents) + .where(eq(actionIntents.id, snapshot.id)), + ); + // Still four_eyes — the sole-operator branch is a fan-out shortcut + // inside the four_eyes classification, never a reclassification to + // supervised (that would silently drop the assurance/step-up ladder + // the decide route's sole-operator branch enforces). + expect(intent?.approvalScope).toBe('four_eyes'); + + const rows = await withSystemDbAccessContext(() => + db + .select({ userId: approvalRequests.userId }) + .from(approvalRequests) + .where(eq(approvalRequests.intentId, snapshot.id)), + ); + expect(rows).toHaveLength(1); + expect(rows[0]?.userId).toBe(requester.id); + }, + ); +}); diff --git a/apps/api/vitest.integration.config.ts b/apps/api/vitest.integration.config.ts index 6534d540c..64be74e37 100644 --- a/apps/api/vitest.integration.config.ts +++ b/apps/api/vitest.integration.config.ts @@ -183,6 +183,16 @@ export default defineConfig({ // mocked route suite can only assert the predicate's shape and cannot // see the ON DELETE CASCADE at all. 'src/routes/enrollmentKeysPurgeExpired.integration.test.ts', + // Co-located real-DB end-to-end coverage for the tier3-supervised-four-eyes + // split (Task 10): four_eyes fan-out ownership (both admins, never the + // requester), a t+30min approve/release proving the new 60-minute + // four_eyes window (vs. the old 5-minute supervised one) via direct DB + // timestamp manipulation, and the disabled-second-admin sole-operator + // fallback. Lives under `src/__tests__/integration/**`, already covered + // by the shared glob above and the unit runner's wholesale + // `src/__tests__/integration/**` exclude; named here for discoverability + // only (same pattern `staleBackupReaper.integration.test.ts` uses). + 'src/__tests__/integration/intentSupervisedFourEyes.integration.test.ts', ], exclude: [ // Uses fresh request-pool modules and manages its own temporary role; From 1dce5f27898718804df2e0f95057c38026ce4813 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 04:54:10 -0600 Subject: [PATCH 16/17] fix(ai): update intentFanout/intentSelfApproveGuard fixtures for the four-eyes split execute_command was reclassified `supervised` by an earlier task in this plan (Task 1, aiGuardrails.ts), which collapses its approval fan-out to exactly one requester-owned row. intentFanout.integration.test.ts and intentSelfApproveGuard.integration.test.ts still seeded intents with execute_command and asserted the pre-split multi-approver/four_eyes sole-operator behavior, so they were either failing outright (wrong row count/owner) or passing for the wrong reason (silently routed through the unrelated supervised decide branch instead of the four_eyes one they exist to cover). Switches every intent-creation call in both files to restore_snapshot (four_eyes-classified), the same fix approvalsDecideAtomicity.integration.test.ts already made for its own fixture. Co-Authored-By: Claude Fable 5 --- .../intentFanout.integration.test.ts | 37 ++++++++++++++----- ...intentSelfApproveGuard.integration.test.ts | 28 +++++++++++--- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/apps/api/src/__tests__/integration/intentFanout.integration.test.ts b/apps/api/src/__tests__/integration/intentFanout.integration.test.ts index b767cf1ec..13871fc2a 100644 --- a/apps/api/src/__tests__/integration/intentFanout.integration.test.ts +++ b/apps/api/src/__tests__/integration/intentFanout.integration.test.ts @@ -216,13 +216,18 @@ describe('createActionIntent — approver fan-out across org+partner axes (real const s = seeded!; const auth = requesterAuth(s.requester, s.orgId, s.partnerId, s.requesterRoleId); - // execute_command is a base Tier-3 tool (registerScriptTools, - // aiToolsScripts.ts) — no `action` field needed to hit TIER3_ACTIONS, - // and createActionIntent never verifies the device exists (that happens - // later, at release/execution time), so a bare random UUID is fine here. + // restore_snapshot is a base Tier-3 tool classified whole-tool + // `four_eyes` (TIER3_FOUR_EYES_TOOLS, aiGuardrails.ts) — required for + // this fixture's two-approver fan-out. execute_command was used here + // pre-tier3-supervised-four-eyes-split, but that split classifies it + // `supervised`, which fans out to exactly ONE (requester-owned) row and + // silently broke this test's `toHaveLength(2)` assertion below — the + // same fix `approvalsDecideAtomicity.integration.test.ts` made for its + // own fixture. createActionIntent never verifies the snapshot/device + // exist (that happens at release time), so bare random UUIDs are fine. const snapshot = await createActionIntent(auth, { - toolName: 'execute_command', - input: { deviceId: randomUUID(), commandType: 'kill_process' }, + toolName: 'restore_snapshot', + input: { snapshotId: randomUUID(), deviceId: randomUUID() }, source: 'chat', }); @@ -268,9 +273,16 @@ describe('createActionIntent — approver fan-out across org+partner axes (real const s = seededSolo!; const auth = requesterAuth(s.requester, s.orgId, s.partnerId, s.requesterRoleId); + // restore_snapshot (four_eyes) so this actually exercises the four_eyes + // SOLE-OPERATOR branch (`intentService.ts`'s `else if (requesterEligible)`) + // this test is documented to cover. execute_command is `supervised` + // post-split, whose unconditional single-row short-circuit fires BEFORE + // the eligible-approver branch regardless of eligibility — leaving this + // test green for the wrong reason (never reaching the sole-operator + // branch at all) if left unchanged. const snapshot = await createActionIntent(auth, { - toolName: 'execute_command', - input: { deviceId: randomUUID(), commandType: 'kill_process' }, + toolName: 'restore_snapshot', + input: { snapshotId: randomUUID(), deviceId: randomUUID() }, source: 'chat', }); @@ -297,9 +309,14 @@ describe('createActionIntent — approver fan-out across org+partner axes (real it('creates a NEW intent for an identical duplicate request once the prior intent has terminalized (partial idempotency index)', async () => { const s = seeded!; const auth = requesterAuth(s.requester, s.orgId, s.partnerId, s.requesterRoleId); + // restore_snapshot (four_eyes) — this test's assertions ride on the + // seeded scenario's two-approver fan-out (both on the first AND the + // re-derived second creation), which execute_command's post-split + // `supervised` classification no longer produces. See the top test's + // comment for the full rationale. const input = { - toolName: 'execute_command', - input: { deviceId: randomUUID(), commandType: 'kill_process' }, + toolName: 'restore_snapshot', + input: { snapshotId: randomUUID(), deviceId: randomUUID() }, source: 'chat' as const, }; diff --git a/apps/api/src/__tests__/integration/intentSelfApproveGuard.integration.test.ts b/apps/api/src/__tests__/integration/intentSelfApproveGuard.integration.test.ts index 71c6d80b8..446514a9c 100644 --- a/apps/api/src/__tests__/integration/intentSelfApproveGuard.integration.test.ts +++ b/apps/api/src/__tests__/integration/intentSelfApproveGuard.integration.test.ts @@ -161,15 +161,31 @@ async function seedScenario(opts: { withSecondApprover: boolean }): Promise Date: Thu, 6 Aug 2026 05:23:59 -0600 Subject: [PATCH 17/17] fix(ai-mcp): close three tier3-supervised-four-eyes review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final whole-branch review turned up three IMPORTANT gaps: 1. The inline chat-session release path (aiAgentSdk.ts) won the approved->executing CAS and revalidated the requester's authorization, but never recomputed the pinned effect digest the way jobs/intentReleaseWorker.ts does — a four_eyes reference-approval whose target drifted during the approval window could execute stale content if it happened to complete inline instead of via the durable worker. Mirror the worker's computeEffectDigest recheck and CAS to failed:content_changed on mismatch. 2. Supervised approves polluted the four_eyes sole-operator audit signal: a supervised intent's sole approval row is always requester-owned, so every ordinary supervised approve was audited as self_approved_sole_operator (an outcome meant to flag four_eyes L3 self-approval specifically). Gate soleOperatorApproval / details.soleOperator on approvalScope === 'four_eyes' in both routes/approvals.ts and services/actionIntents/intentService.ts; supervised approves now audit as approved with details.approvalMethod: 'supervised_self'. 3. The reaper's pending_approval predicate used bare approval_expires_at < now(), which is SQL NULL (never true) for legacy writer rows that never got the column backfilled — those rows could never be reaped even once their legacy expires_at deadline passed. Fall back to COALESCE(approval_expires_at, expires_at), mirroring the approved branch's existing construct. Co-Authored-By: Claude Fable 5 --- .../intentExpiryReaper.integration.test.ts | 38 +++++- apps/api/src/jobs/intentExpiryReaper.test.ts | 38 +++++- apps/api/src/jobs/intentExpiryReaper.ts | 8 +- apps/api/src/routes/approvals.test.ts | 44 +++++++ apps/api/src/routes/approvals.ts | 13 +- .../actionIntents/intentService.test.ts | 31 +++++ .../services/actionIntents/intentService.ts | 11 +- apps/api/src/services/aiAgentSdk.test.ts | 124 ++++++++++++++++++ apps/api/src/services/aiAgentSdk.ts | 40 ++++++ 9 files changed, 338 insertions(+), 9 deletions(-) diff --git a/apps/api/src/jobs/intentExpiryReaper.integration.test.ts b/apps/api/src/jobs/intentExpiryReaper.integration.test.ts index 71beae67b..eb149bb05 100644 --- a/apps/api/src/jobs/intentExpiryReaper.integration.test.ts +++ b/apps/api/src/jobs/intentExpiryReaper.integration.test.ts @@ -124,7 +124,7 @@ describe('reapExpiredIntents (real PG) — status-split deadline', () => { async function seedIntent(fields: { status: 'pending_approval' | 'approved'; - approvalExpiresAt: Date; + approvalExpiresAt: Date | null; releaseBy: Date | null; expiresAt: Date; }): Promise { @@ -217,6 +217,42 @@ describe('reapExpiredIntents (real PG) — status-split deadline', () => { expect(await readStatus(id)).toBe('expired'); }); + it('reaps a legacy pending_approval intent with a NULL approval_expires_at via the expires_at fallback', async () => { + // A writer that predates (or bypasses) the approval_expires_at backfill: + // the column is NULL, so the bare `approval_expires_at < now()` + // predicate this test guards against is NULL (never true in SQL) — + // without the COALESCE fallback this row would never be reaped. + const past = new Date(Date.now() - 60_000); + + const id = await seedIntent({ + status: 'pending_approval', + approvalExpiresAt: null, + releaseBy: null, + expiresAt: past, + }); + + const n = await withSystemDbAccessContext(() => reapExpiredIntents()); + + expect(n).toBe(1); + expect(await readStatus(id)).toBe('expired'); + }); + + it('does not reap a legacy pending_approval intent with a NULL approval_expires_at whose expires_at fallback is still in the future', async () => { + const future = new Date(Date.now() + 3_600_000); + + const id = await seedIntent({ + status: 'pending_approval', + approvalExpiresAt: null, + releaseBy: null, + expiresAt: future, + }); + + const n = await withSystemDbAccessContext(() => reapExpiredIntents()); + + expect(n).toBe(0); + expect(await readStatus(id)).toBe('pending_approval'); + }); + it('reaps a legacy approved intent with no release_by via the expires_at fallback', async () => { const past = new Date(Date.now() - 60_000); const future = new Date(Date.now() + 3_600_000); diff --git a/apps/api/src/jobs/intentExpiryReaper.test.ts b/apps/api/src/jobs/intentExpiryReaper.test.ts index fcac0a304..9212ffc96 100644 --- a/apps/api/src/jobs/intentExpiryReaper.test.ts +++ b/apps/api/src/jobs/intentExpiryReaper.test.ts @@ -171,20 +171,52 @@ describe('intentExpiryReaper.reapExpiredIntents', () => { expect(recordActionIntentEvent).toHaveBeenCalledTimes(2); }); - it('splits the deadline by status: pending_approval on approval_expires_at, approved on release_by falling back to expires_at', async () => { + it('splits the deadline by status: pending_approval on approval_expires_at falling back to expires_at, approved on release_by falling back to expires_at', async () => { executeMock.mockResolvedValueOnce({ rows: [] }); await reapExpiredIntents(); const query = sqlText(executeMock.mock.calls[0]?.[0]); - // pending_approval branch checks approval_expires_at, never expires_at. - expect(query).toMatch(/status\s*=\s*'pending_approval'\s+AND\s+approval_expires_at\s*<\s*now\(\)/); + // pending_approval branch checks approval_expires_at with an expires_at + // fallback for legacy writer rows that never set approval_expires_at. + expect(query).toContain('COALESCE( approval_expires_at , expires_at )'); + expect(query).toMatch(/status\s*=\s*'pending_approval'\s+AND\s+COALESCE/); // approved branch checks release_by with an expires_at fallback for // legacy rows that predate the release-lease column. expect(query).toContain('COALESCE( release_by , expires_at )'); expect(query).toMatch(/status\s*=\s*'approved'\s+AND\s+COALESCE/); }); + it('reaps a legacy pending_approval row with a NULL approval_expires_at once expires_at has passed', async () => { + // Legacy-writer row: approval_expires_at was never backfilled, but + // expires_at is the pre-split deadline and it has passed. Without the + // COALESCE fallback, `approval_expires_at < now()` on a NULL column is + // NULL (never true in SQL), so this row would never be reaped. + const past = new Date(Date.now() - 60_000); + executeMock.mockResolvedValueOnce({ + rows: [ + { + id: 'intent-legacy-null-approval-deadline', + org_id: 'org-1', + action_name: 'breeze.legacy', + argument_digest: 'digest-legacy', + source: 'chat', + requested_by_user_id: 'user-1', + expires_at: past, + }, + ], + }); + const chain = makeUpdateChain([]); + updateMock.mockReturnValue({ set: chain.set }); + + const reaped = await reapExpiredIntents(); + + expect(reaped).toBe(1); + expect(recordActionIntentEvent).toHaveBeenCalledWith( + expect.objectContaining({ intentId: 'intent-legacy-null-approval-deadline', outcome: 'expired' }), + ); + }); + it('the 59:59 trap: an approved intent past approval_expires_at but with releaseBy still in the future is NOT reaped', async () => { // The reaper's WHERE clause runs against a real database and this test // mocks db.execute at the boundary, so it cannot exercise Postgres's diff --git a/apps/api/src/jobs/intentExpiryReaper.ts b/apps/api/src/jobs/intentExpiryReaper.ts index 1648b404d..d1e9d9476 100644 --- a/apps/api/src/jobs/intentExpiryReaper.ts +++ b/apps/api/src/jobs/intentExpiryReaper.ts @@ -29,7 +29,9 @@ import { REVEAL_WINDOW_DAYS } from '../services/actionIntents/resultSecrets'; * respective deadline → `expired`. The two statuses no longer share one * deadline column (tier3-supervised-four-eyes design §4.2): `pending_approval` * rows expire on `approval_expires_at` (the decide-by deadline; backfilled - * from the legacy `expires_at` for pre-split rows, so it is never null). + * from the legacy `expires_at` for pre-split rows) — falling back to + * `expires_at` when `approval_expires_at IS NULL`, since some legacy + * writers still leave the column unset. * `approved` rows expire on `release_by` — the fixed lease the approve * fan-in (`routes/approvals.ts`) stamps when it flips the intent to * `approved` — falling back to `expires_at` when `release_by IS NULL` @@ -145,13 +147,13 @@ export async function reapExpiredIntents(): Promise { FROM ${actionIntents} WHERE ( ${actionIntents.status} = 'pending_approval' - AND ${actionIntents.approvalExpiresAt} < now() + AND COALESCE(${actionIntents.approvalExpiresAt}, ${actionIntents.expiresAt}) < now() ) OR ( ${actionIntents.status} = 'approved' AND COALESCE(${actionIntents.releaseBy}, ${actionIntents.expiresAt}) < now() ) ORDER BY CASE - WHEN ${actionIntents.status} = 'pending_approval' THEN ${actionIntents.approvalExpiresAt} + WHEN ${actionIntents.status} = 'pending_approval' THEN COALESCE(${actionIntents.approvalExpiresAt}, ${actionIntents.expiresAt}) ELSE COALESCE(${actionIntents.releaseBy}, ${actionIntents.expiresAt}) END ASC LIMIT ${MAX_REAP_PER_RUN} diff --git a/apps/api/src/routes/approvals.test.ts b/apps/api/src/routes/approvals.test.ts index f8df9c8cb..163bb30a6 100644 --- a/apps/api/src/routes/approvals.test.ts +++ b/apps/api/src/routes/approvals.test.ts @@ -1473,6 +1473,7 @@ describe('Task 5: decide-handler bound to action_intents', () => { requestedByUserId?: string; boundArgumentDigest?: string | null; intentDigest?: string; + approvalScope?: 'supervised' | 'four_eyes'; }) { const approvalRow = { id: 'appr-1', @@ -1507,6 +1508,10 @@ describe('Task 5: decide-handler bound to action_intents', () => { source: 'mcp_api', status: 'pending_approval', requestedByUserId: opts.requestedByUserId ?? 'requester-1', + // Defaults to four_eyes: every pre-existing test in this describe + // block (including the sole-operator self-approval ones) predates the + // tier3-supervised-four-eyes split and asserts four_eyes behavior. + approvalScope: opts.approvalScope ?? 'four_eyes', }; // 1) pre-fetch select @@ -1712,6 +1717,13 @@ describe('Task 5: decide-handler bound to action_intents', () => { // #2685: the happy path must have actually re-derived the approver set, // not skipped the check. expect(resolveIntentApprovers).toHaveBeenCalledWith('org-9'); + // Scope gate the other direction: a four_eyes sole-operator self-approval + // must NOT carry the supervised-only `approvalMethod` tag — that would + // blur the two signals this gate exists to keep apart. + const call = vi + .mocked(recordActionIntentEvent) + .mock.calls.find((c) => c[0]?.outcome === 'self_approved_sole_operator'); + expect((call?.[0]?.details as Record | undefined)?.approvalMethod).toBeUndefined(); }); // #2685: sole-operator status is RE-DERIVED at decide time, not inferred @@ -1989,6 +2001,38 @@ describe('Task 6: supervised intent plain-decide branch', () => { ); }); + // Sole-operator audit-signal scope gate (finding: soleOperatorApproval must + // stay four_eyes-only): a supervised approve's sole approval row is ALWAYS + // requester === decider (Task 4's single-row fan-out), so it must NOT audit + // as `self_approved_sole_operator` — that outcome exists to flag the + // four_eyes "only eligible approver happened to be the requester" case. + // Supervised approves audit as ordinary `approved`, tagged with + // `details.approvalMethod: 'supervised_self'` so the signal is still + // distinguishable without polluting the four_eyes one. + it('audits a supervised self-approve as outcome=approved with details.approvalMethod=supervised_self, never self_approved_sole_operator', async () => { + mockDecideWithSupervisedIntent({}); + mockSupervisedFanInTx(); + + const res = await buildApp().request('/approvals/appr-1/approve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + + expect(res.status).toBe(200); + expect(recordActionIntentEvent).toHaveBeenCalledWith( + expect.objectContaining({ + orgId: 'org-9', + intentId: 'intent-sv-1', + outcome: 'approved', + details: expect.objectContaining({ approvalMethod: 'supervised_self' }), + }), + ); + expect(recordActionIntentEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ outcome: 'self_approved_sole_operator' }), + ); + }); + it('an ENFORCING partner policy blocks a plain-click supervised approve with step_up_required (fix round 1, finding 5)', async () => { mockDecideWithSupervisedIntent({}); vi.mocked(isEnforcing).mockReturnValue(true); diff --git a/apps/api/src/routes/approvals.ts b/apps/api/src/routes/approvals.ts index 9e75a8c41..a001b4bb4 100644 --- a/apps/api/src/routes/approvals.ts +++ b/apps/api/src/routes/approvals.ts @@ -1307,7 +1307,17 @@ async function decideHandler( // reaper, or a retry already decided it — is a clean no-op: no event here, // but this row's own decision still committed above either way). if (wonIntent && updated.intentId && linkedIntent) { - const soleOperatorApproval = status === 'approved' && linkedIntent.requestedByUserId === userId; + // Gated on four_eyes: a supervised intent's sole approval row is ALWAYS + // owned by the requester (the supervised short-circuit above, enforced + // by the identity gate at the top of this handler), so "requester === + // decider" is true for EVERY supervised approve, not just the four_eyes + // "only eligible approver happened to be the requester" case. Letting + // that through here would mean `self_approved_sole_operator` — and the + // audit signal built on it — fires on ordinary supervised approves, + // burying the four_eyes L3 self-approval signal it exists to isolate. + const isSelfApprove = status === 'approved' && linkedIntent.requestedByUserId === userId; + const soleOperatorApproval = isSelfApprove && linkedIntent.approvalScope === 'four_eyes'; + const supervisedSelfApproval = isSelfApprove && linkedIntent.approvalScope === 'supervised'; recordActionIntentEvent({ orgId: linkedIntent.orgId, intentId: updated.intentId, @@ -1324,6 +1334,7 @@ async function decideHandler( approvalRequestId: updated.id, decidedAssuranceLevel: assurance.decidedAssuranceLevel, decidedVia: assurance.decidedVia, + ...(supervisedSelfApproval ? { approvalMethod: 'supervised_self' as const } : {}), }, }); } diff --git a/apps/api/src/services/actionIntents/intentService.test.ts b/apps/api/src/services/actionIntents/intentService.test.ts index ac02147b1..f09fec89f 100644 --- a/apps/api/src/services/actionIntents/intentService.test.ts +++ b/apps/api/src/services/actionIntents/intentService.test.ts @@ -633,6 +633,37 @@ describe('createActionIntent — supervised/four_eyes scope', () => { expect(pushState.dispatchApprovalPushToTokens).not.toHaveBeenCalled(); }); + // Sole-operator audit-signal scope gate: a supervised intent's single + // fan-out row is ALWAYS requester-owned (Task 4's short-circuit), which is + // the ordinary supervised shape, not a four_eyes "only eligible approver + // happened to be the requester" self-approval. `details.soleOperator` on + // the `created` event must stay four_eyes-only or it pollutes the + // sole-operator audit signal with every supervised creation — see the + // sibling four_eyes assertion below ('four_eyes with no other active + // approver keeps sole-operator fallback') for the case this must NOT be + // confused with. + it('never sets details.soleOperator on a supervised intent creation, even though the row is requester-owned', async () => { + guardrailMock.checkGuardrails.mockReturnValue({ + tier: 3, + allowed: true, + requiresApproval: true, + description: 'Run a script on one or more devices', + approvalScope: 'supervised', + }); + intentApproversState.resolveIntentApprovers.mockResolvedValueOnce([]); + dbState.insertActionIntentsResults.push(echoInsertedIntent({ id: 'intent-supervised-sole' })); + dbState.insertApprovalRequestsResults.push([{ id: 'approval-supervised-sole' }]); + + await createActionIntent(makeAuth(), baseInput({ idempotencyKey: 'key-supervised-sole' })); + + expect(metricsMock.recordActionIntentEvent).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: 'created', + details: expect.objectContaining({ soleOperator: false }), + }), + ); + }); + it('four_eyes chat intent gets a 60-minute approval deadline; supervised keeps 5', async () => { guardrailMock.checkGuardrails.mockReturnValue({ tier: 3, diff --git a/apps/api/src/services/actionIntents/intentService.ts b/apps/api/src/services/actionIntents/intentService.ts index b49b9d2a5..0254f68b7 100644 --- a/apps/api/src/services/actionIntents/intentService.ts +++ b/apps/api/src/services/actionIntents/intentService.ts @@ -637,7 +637,16 @@ export async function createActionIntent( ? { errorCode: creation.intent.errorCode ?? 'no_eligible_approvers' } : { approverCount: creation.approvalRequestIds.length, - soleOperator: creation.fanOutUserIds.length === 1 && creation.fanOutUserIds[0] === requesterId, + // Gated on four_eyes: supervised intents always have exactly one + // fan-out row owned by the requester (the short-circuit at line + // ~530), but that is the *normal* supervised shape, not a + // four_eyes sole-operator L3 self-approval — `soleOperator: true` + // must keep meaning the latter, or it pollutes the sole-operator + // audit signal with every supervised creation. + soleOperator: + approvalScope === 'four_eyes' && + creation.fanOutUserIds.length === 1 && + creation.fanOutUserIds[0] === requesterId, }, }); } diff --git a/apps/api/src/services/aiAgentSdk.test.ts b/apps/api/src/services/aiAgentSdk.test.ts index 6e9dcf8db..e8d099872 100644 --- a/apps/api/src/services/aiAgentSdk.test.ts +++ b/apps/api/src/services/aiAgentSdk.test.ts @@ -143,6 +143,16 @@ vi.mock('./actionIntents/revalidateRelease', () => ({ mockRevalidateApprovedIntentForRelease(...args), })); +// Mocked so the inline release-CAS effect-digest recheck (mirrors +// jobs/intentReleaseWorker.ts's same-named step) is controllable per-test +// without wiring a real resolver's DB reads through the ../db mock. Default: +// resolves to null (no digest computed) — irrelevant to every pre-existing +// test in this file, since none of them set a truthy intentRow.effectDigest. +const mockComputeEffectDigest = vi.fn((..._args: unknown[]) => Promise.resolve(null)); +vi.mock('./actionIntents/effectDigest', () => ({ + computeEffectDigest: (...args: unknown[]) => mockComputeEffectDigest(...args), +})); + // Real actionIntents schema is imported by aiAgentSdk for the inline system // read; the ../db/schema mock above only stubs approvalRequests, so stub the // actionIntents table object the query builder references here too. @@ -2435,6 +2445,120 @@ describe('Task 2: plan index advances only once the step is authorized', () => { ); }); + // Effect-digest revalidation (tier3-supervised-four-eyes design §4.1): the + // inline chat-session release path must recompute and CAS-check the pinned + // effect digest exactly like the durable release worker + // (jobs/intentReleaseWorker.ts) does — a bare content_changed mismatch on + // this path used to silently execute a stale-target action. + it('does NOT advance and CASes to failed:content_changed when the recomputed effect digest mismatches', async () => { + vi.mocked(checkGuardrails).mockReturnValue({ + allowed: true, + tier: 3, + requiresApproval: true, + description: 'Execute command', + } as any); + mockInsertReturning({ id: 'exec-plan-digest-mismatch' }); + mockCreateActionIntent.mockResolvedValue( + makeIntentSnapshot({ id: 'intent-plan-digest-mismatch', approvalRequestIds: ['appr-plan-digest-mismatch'] }), + ); + mockWaitForIntentDecision.mockResolvedValue('approved'); + mockTransitionIntent.mockResolvedValue(true); // wins the release CAS + // The stored digest was pinned at approval time; the freshly-recomputed + // one no longer matches — the referenced content drifted underneath the + // approval window. + const selectChain: Record = { + from: vi.fn(() => selectChain), + where: vi.fn(() => selectChain), + limit: vi.fn(async () => [ + { + id: 'intent-plan-digest-mismatch', + boundArgumentDigest: 'digest', + actionName: 'execute_command', + arguments: { command: 'whoami' }, + effectDigest: 'stored-digest-abc', + }, + ]), + }; + vi.mocked(db.select).mockReturnValue(selectChain as any); + mockComputeEffectDigest.mockResolvedValueOnce('recomputed-digest-xyz'); + const session = makeActiveSession({ + approvalMode: 'action_plan', + activePlanId: 'plan-1', + approvedPlanSteps: new Map([[0, { toolName: 'execute_command', input: { command: 'whoami' } }]]), + }); + + const result = await createSessionPreToolUse(session)('execute_command', { command: 'whoami' }); + + expect(result).toEqual({ + allowed: false, + error: 'The referenced content changed after approval; it was not executed.', + }); + // Never executed: the plan did not advance and no plan_step_start fired. + expect(session.currentPlanStepIndex).toBe(0); + expect(session.eventBus.publish).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'plan_step_start' }), + ); + // The intent was CAS'd executing -> failed with the same error_code the + // durable release worker uses for this exact condition. + expect(mockTransitionIntent).toHaveBeenCalledWith( + 'intent-plan-digest-mismatch', + 'executing', + 'failed', + { errorCode: 'content_changed' }, + ); + }); + + // The mirror image: a stored NULL effect digest (supervised intents never + // pin one; unpinnable four_eyes intents skip it too) must skip the + // recompute entirely and let the step execute normally — proves the check + // is opt-in on a stored digest, not a blanket recompute-and-compare. + it('executes normally and never calls computeEffectDigest when the stored effect digest is null', async () => { + vi.mocked(checkGuardrails).mockReturnValue({ + allowed: true, + tier: 3, + requiresApproval: true, + description: 'Execute command', + } as any); + mockInsertReturning({ id: 'exec-plan-digest-null' }); + mockCreateActionIntent.mockResolvedValue( + makeIntentSnapshot({ id: 'intent-plan-digest-null', approvalRequestIds: ['appr-plan-digest-null'] }), + ); + mockWaitForIntentDecision.mockResolvedValue('approved'); + mockTransitionIntent.mockResolvedValue(true); + const selectChain: Record = { + from: vi.fn(() => selectChain), + where: vi.fn(() => selectChain), + limit: vi.fn(async () => [ + { + id: 'intent-plan-digest-null', + boundArgumentDigest: 'digest', + actionName: 'execute_command', + arguments: { command: 'whoami' }, + effectDigest: null, + }, + ]), + }; + vi.mocked(db.select).mockReturnValue(selectChain as any); + const session = makeActiveSession({ + approvalMode: 'action_plan', + activePlanId: 'plan-1', + approvedPlanSteps: new Map([[0, { toolName: 'execute_command', input: { command: 'whoami' } }]]), + }); + + const result = await createSessionPreToolUse(session)('execute_command', { command: 'whoami' }); + + expect(result).toEqual({ allowed: true, intentId: 'intent-plan-digest-null' }); + expect(session.currentPlanStepIndex).toBe(1); + expect(mockComputeEffectDigest).not.toHaveBeenCalled(); + // No content_changed CAS — only the approved -> executing CAS ran. + expect(mockTransitionIntent).not.toHaveBeenCalledWith( + expect.anything(), + 'executing', + 'failed', + expect.objectContaining({ errorCode: 'content_changed' }), + ); + }); + // Regression guards restored from PR #2853. Task 1 necessarily inverted the // originals (they asserted the pre-Task-1 early-advance behavior); they // become meaningful again now that the advance happens at the authorize diff --git a/apps/api/src/services/aiAgentSdk.ts b/apps/api/src/services/aiAgentSdk.ts index 746b3dc3e..e9327f53b 100644 --- a/apps/api/src/services/aiAgentSdk.ts +++ b/apps/api/src/services/aiAgentSdk.ts @@ -30,6 +30,7 @@ import type { DelegantM365ConnectionRow } from '../db/schema/delegant'; import { createActionIntent, waitForIntentDecision, transitionIntent } from './actionIntents/intentService'; import { revalidateApprovedIntentForRelease } from './actionIntents/revalidateRelease'; import { requiresDurableRelease } from './actionIntents/durableRelease'; +import { computeEffectDigest } from './actionIntents/effectDigest'; import { assertNoPlaintextSecret, isSecretBearingTool, @@ -1134,6 +1135,45 @@ export function createSessionPreToolUse(session: ActiveSession): PreToolUseCallb }); } + // Effect-digest revalidation (tier3-supervised-four-eyes design §4.1, + // services/actionIntents/effectDigest.ts) — mirrors the durable release + // worker's same-named check (jobs/intentReleaseWorker.ts) so the inline + // chat-session release path closes the exact same TOCTOU gap: an + // approver approves a REFERENCE ("run script "), and the referenced + // content can drift during the approval window while the intent's own + // arguments/argumentDigest stay byte-identical. `intentRow.effectDigest` + // is NULL for supervised intents (never pinned) and unpinnable four_eyes + // intents (no resolver, or target didn't exist yet at creation) — both + // skip this check by design, same as the worker. Wrapped in + // withSystemDbAccessContext (via runOutsideDbContext, same discipline as + // the intentRow/winningApproval read above) because the resolver needs + // to read the current target row, which the ambient request context may + // not make visible. + // Truthy check (not `!== null`): the column is either NULL or a + // real 64-char hex digest, never falsy-but-present, so this is + // equivalent for real rows — and it is what correctly treats an + // `undefined` effectDigest (e.g. a narrower row shape) as "no + // stored digest, skip" rather than a spurious mismatch. + if (intentRow.effectDigest) { + const recomputedEffectDigest = await runOutsideDbContext(() => + withSystemDbAccessContext(() => + computeEffectDigest(intentRow.actionName, intentRow.arguments, db), + ), + ); + if (recomputedEffectDigest !== intentRow.effectDigest) { + await transitionIntent(intent.id, 'executing', 'failed', { + errorCode: 'content_changed', + }); + console.error( + `[AI-SDK] inline release effect-digest mismatch for intent ${intent.id}: content_changed`, + ); + return await failMatchedPlanStep({ + allowed: false, + error: 'The referenced content changed after approval; it was not executed.', + }); + } + } + // Won the release: track the intent id so createSessionPostToolUse can // CAS it executing -> completed|failed once the inline tool call // actually finishes (see pendingIntentBySession above).