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/__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 { + 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/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/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/jobs/intentExpiryReaper.integration.test.ts b/apps/api/src/jobs/intentExpiryReaper.integration.test.ts index 4d47b5c25..eb149bb05 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,181 @@ 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 | null; + 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 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); + + 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..9212ffc96 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,93 @@ describe('intentExpiryReaper.reapExpiredIntents', () => { expect(reaped).toBe(2); expect(recordActionIntentEvent).toHaveBeenCalledTimes(2); }); + + 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 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 + // 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..d1e9d9476 100644 --- a/apps/api/src/jobs/intentExpiryReaper.ts +++ b/apps/api/src/jobs/intentExpiryReaper.ts @@ -25,11 +25,25 @@ 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) — 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` + * (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 +133,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 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 COALESCE(${actionIntents.approvalExpiresAt}, ${actionIntents.expiresAt}) + 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.durable.contract.test.ts b/apps/api/src/jobs/intentReleaseWorker.durable.contract.test.ts new file mode 100644 index 000000000..87938a5ec --- /dev/null +++ b/apps/api/src/jobs/intentReleaseWorker.durable.contract.test.ts @@ -0,0 +1,57 @@ +/** + * 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, + 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), + ...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)', () => { + 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.test.ts b/apps/api/src/jobs/intentReleaseWorker.test.ts index 15858142b..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 () => { @@ -312,6 +330,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 @@ -609,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 @@ -841,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 6bfd2f8dd..06ffd560f 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'; @@ -166,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 @@ -244,10 +265,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', @@ -307,6 +335,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 @@ -316,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/routes/approvals.test.ts b/apps/api/src/routes/approvals.test.ts index 573bba476..163bb30a6 100644 --- a/apps/api/src/routes/approvals.test.ts +++ b/apps/api/src/routes/approvals.test.ts @@ -51,15 +51,62 @@ 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(), })); +// 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, + })), +})); + +// 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 @@ -209,6 +256,9 @@ 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'; +import { loadPartnerPolicy, isEnforcing } from '../services/authenticatorPolicy'; function buildApp() { const app = new Hono(); @@ -224,15 +274,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[]; @@ -244,16 +297,71 @@ 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; } +// 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. 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[]; +}) { + 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 = { + select: txSelectExistsStub(), + 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({ @@ -305,6 +413,27 @@ 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); + // 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', @@ -319,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 }); }); }); @@ -582,24 +833,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 approvalReturning = vi.fn().mockResolvedValue([linkedRow]); - const approvalSet = vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ returning: approvalReturning }), + const { approvalSet, aiSet } = mockDecideTxWithExecutionMirror({ + linkedRow, + aiReturning: [{ id: 'exec-42' }], }); - 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); @@ -616,22 +853,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' }); @@ -1074,22 +1296,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', @@ -1104,12 +1314,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) }), @@ -1119,17 +1331,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', @@ -1155,14 +1367,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 }; } @@ -1172,7 +1384,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 }), ); @@ -1227,7 +1439,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 () => { @@ -1240,19 +1455,25 @@ 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; boundArgumentDigest?: string | null; intentDigest?: string; + approvalScope?: 'supervised' | 'four_eyes'; }) { const approvalRow = { id: 'appr-1', @@ -1287,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 @@ -1302,41 +1527,55 @@ 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 = { + // 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: 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 () => { @@ -1352,6 +1591,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 +1672,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' }), ); @@ -1468,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 @@ -1606,13 +1862,436 @@ 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 = { + // Fix round 1, finding 2: intent-first lock ahead of the three updates. + select: txSelectForUpdateStub(), + 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 (non-enforcing partner policy — fix round 1, finding 5)', 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 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( + expect.objectContaining({ id: 'intent-sv-1', actionName: 'execute_command' }), + ); + expect(checkToolPermission).toHaveBeenCalledWith( + 'execute_command', + { deviceId: 'dev-1', commandType: 'kill_process' }, + expect.anything(), + ); + }); + + // 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); + + 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(); + + 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 = { + // Fix round 1, finding 2: intent-first lock ahead of the three updates. + select: txSelectForUpdateStub(), + 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', @@ -1703,31 +2382,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 b3fc07ef4..a001b4bb4 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'; @@ -13,9 +13,19 @@ 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 { loadPartnerPolicy, isEnforcing } from '../services/authenticatorPolicy'; import { getUserPermissions, userCanDecideApprovals, canAccessOrg } from '../services/permissions'; import { generateApprovalAssertionOptions } from '../services/approverWebAuthn'; import { issueMobileAssertionNonce } from '../services/mobileHwKey'; @@ -43,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(), }); @@ -288,52 +441,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; + // 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. + // + // 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 { - // 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) => { + 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 }) @@ -379,7 +539,34 @@ 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); + } + } 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); + } } } } @@ -480,6 +667,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 () => { @@ -524,34 +715,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, @@ -560,54 +744,83 @@ 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 }; + }), + ); + if (!revalidation.ok) { + 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: revalidation.errorCode }, + }); + 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, + }), + ), ); - 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 ( + !deciderPerms || + !canAccessOrg(deciderPerms, linkedIntent.orgId) || + !userCanDecideApprovals(deciderPerms) + ) { recordActionIntentEvent({ orgId: linkedIntent.orgId, intentId: linkedIntent.id, @@ -616,15 +829,72 @@ 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 }, }); - return c.json({ error: 'not_sole_approver' }, 403); + 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); + } } } } @@ -638,115 +908,297 @@ 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 — 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 (skipAssuranceLadder) { + 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): 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' && + 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 => { + // 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({ + 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. + // + // 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) { + // 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; + + // 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), + ), + ); - const [updated] = result; + 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 }, + }); + } + } + } - // 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. - } + 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 @@ -848,108 +1300,46 @@ 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'; - 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, - }) - .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, - }, - }); - } + // 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) { + // 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, + 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, + ...(supervisedSelfApproval ? { approvalMethod: 'supervised_self' as const } : {}), + }, + }); } - 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/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/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/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) { diff --git a/apps/api/src/services/actionIntents/intentService.test.ts b/apps/api/src/services/actionIntents/intentService.test.ts index ac5d8f43d..f09fec89f 100644 --- a/apps/api/src/services/actionIntents/intentService.test.ts +++ b/apps/api/src/services/actionIntents/intentService.test.ts @@ -5,13 +5,15 @@ 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'), 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 @@ -24,7 +26,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[][], @@ -60,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) }, }; }); @@ -76,10 +91,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 ?? []; + }), })), }; } @@ -168,10 +188,25 @@ 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 })), 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]) : ''), + '', + ), + })), })); // --------------------------------------------------------------------------- @@ -190,6 +225,7 @@ import { type CreateActionIntentInput, } from './intentService'; import { db, withDbAccessContext } from '../../db'; +import { computeEffectDigest } from './effectDigest'; // --------------------------------------------------------------------------- // Test fixtures @@ -255,9 +291,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 +313,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(); @@ -287,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); }); // --------------------------------------------------------------------------- @@ -540,6 +603,245 @@ 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(); + }); + + // 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, + 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'); + }); +}); + +// --------------------------------------------------------------------------- +// 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. @@ -632,6 +934,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 b5272790b..0254f68b7 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'; @@ -12,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 @@ -94,12 +102,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 +135,39 @@ 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; +/** + * 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 + * 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 +216,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 +239,8 @@ function toSnapshot( errorCode: intent.errorCode, approvalRequestIds, requesterApprovalRequestId, + approvalExpiresAt: intent.approvalExpiresAt, + fanOutUserIds, }; } @@ -233,6 +283,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 +307,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. @@ -315,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({ @@ -344,7 +414,18 @@ export async function createActionIntent( riskTier: guardrail.tier, idempotencyKey, 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 + // 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 +503,42 @@ export async function createActionIntent( isRecursive: false, }); - if (eligibleApprovers.length > 0) { + // 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, + // 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. + ({ approvalRequestIds, requesterApprovalRequestId, fanOutUserIds } = + await insertSingleApproverRow(requesterId)); + } else if (eligibleApprovers.length > 0) { const rows = await db .insert(approvalRequests) .values(eligibleApprovers.map(approvalRowFor)) @@ -433,15 +549,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; @@ -493,7 +602,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]; @@ -525,12 +637,21 @@ 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, }, }); } - return toSnapshot(creation.intent, creation.approvalRequestIds, creation.requesterApprovalRequestId); + return toSnapshot(creation.intent, creation.approvalRequestIds, creation.requesterApprovalRequestId, creation.fanOutUserIds); } // --------------------------------------------------------------------------- @@ -623,15 +744,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) diff --git a/apps/api/src/services/aiAgentSdk.approvalWait.test.ts b/apps/api/src/services/aiAgentSdk.approvalWait.test.ts index 0952435b6..45043f182 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, }; } @@ -199,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); } @@ -269,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.test.ts b/apps/api/src/services/aiAgentSdk.test.ts index 7e780cfb8..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. @@ -246,6 +256,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, }; } @@ -2433,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 ecf364f66..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, @@ -223,7 +224,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 +235,12 @@ const lastApprovalBySession = new WeakMap = new Set([ 'per_step_user', 'action_intent', + 'supervised_self', 'pam', 'plan_step', ]); @@ -960,6 +965,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 @@ -1121,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). @@ -1422,10 +1475,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 }; 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..0adc82d8c --- /dev/null +++ b/apps/api/src/services/aiGuardrails.approvalScope.contract.test.ts @@ -0,0 +1,104 @@ +/** + * 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, + TIER3_INPUT_AWARE_ACTIONS, TIER3_INPUT_AWARE_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) { + // 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); + } + } + }); + + 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); + } + }); + + 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('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', () => { + 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..1a6a35cd1 100644 --- a/apps/api/src/services/aiGuardrails.ts +++ b/apps/api/src/services/aiGuardrails.ts @@ -201,9 +201,208 @@ 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 + // `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. +// +// 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) 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. +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 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'], + 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', + // 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', + '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', +]); + +/** + * 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'; + 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 +1065,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 +1127,7 @@ export function checkGuardrails( tier: 3, allowed: true, requiresApproval: true, + approvalScope: resolveApprovalScope(toolName, action, input), description: buildApprovalDescription(toolName, action, input) }; } @@ -941,6 +1148,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, input) } : {}), 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 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/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"]}), 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..64be74e37 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} @@ -175,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; 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. 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. 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 }