From 988b716b43bddaa8346f23dc86e954e4e1d50ea7 Mon Sep 17 00:00:00 2001 From: sethzora Date: Sat, 15 Aug 2026 23:32:05 -0400 Subject: [PATCH 1/2] feat: add guarded autopilot mode for Grok --- src/common/adapter/bridgeAllowlist.ts | 15 +++++ src/process/webserver/adapter.ts | 9 +++ src/renderer/utils/model/agentModes.ts | 13 ++++ tests/unit/acpAgentManagerTrust.test.ts | 62 ++++++++++++++++++- tests/unit/agentModes.test.ts | 17 +++++ ...owlistGrokGuardedAutopilot.redteam.test.ts | 38 ++++++++++++ tests/unit/commonAgentModes.test.ts | 7 +++ 7 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 tests/unit/bridgeAllowlistGrokGuardedAutopilot.redteam.test.ts diff --git a/src/common/adapter/bridgeAllowlist.ts b/src/common/adapter/bridgeAllowlist.ts index f2f47c37cb..ba64dd6d8f 100644 --- a/src/common/adapter/bridgeAllowlist.ts +++ b/src/common/adapter/bridgeAllowlist.ts @@ -579,6 +579,21 @@ export function isRemoteDeniedConfigWrite(name: string, data: unknown): boolean return false; } +/** + * Guarded Autopilot is a local-desktop-only control. The paired WebUI's token + * proves a remote browser, not that its holder may arm unattended host tool + * approval. Keep ordinary ACP mode switching available remotely, but reject + * only the Wayland-owned guarded mode at the WebSocket trust boundary. + * + * This value-level guard mirrors `isRemoteDeniedConfigWrite`: a buildProvider + * handler has no remote/local signal, while the WebSocket adapter does. + */ +export function isRemoteDeniedAcpModeChange(name: string, data: unknown): boolean { + if (name !== 'subscribe-acp.set-mode') return false; + const mode = (data as { data?: { mode?: unknown } } | null | undefined)?.data?.mode; + return mode === 'autoGuarded'; +} + /** * Emitter/broadcast (main -> client) names that must NOT be forwarded to a * remote WebSocket peer. Inbound denial (isAllowedForRemote) stops a peer diff --git a/src/process/webserver/adapter.ts b/src/process/webserver/adapter.ts index 2d6092a3b8..8f77fce06f 100644 --- a/src/process/webserver/adapter.ts +++ b/src/process/webserver/adapter.ts @@ -11,6 +11,7 @@ import { registerWebSocketBroadcaster, getBridgeEmitter } from '@/common/adapter import { isAllowedInboundName, isAllowedForRemote, + isRemoteDeniedAcpModeChange, isAllowedOutboundToRemote, isRemoteDeniedConfigWrite, } from '@/common/adapter/bridgeAllowlist'; @@ -95,6 +96,14 @@ export function initWebAdapter(wss: WebSocketServer): void { settleRejectedInvoke(ws, name, data, 'remote-forbidden'); return; } + // Guarded Autopilot arms unattended host-side tool approval. It is a + // local-desktop operator control; a paired WebUI session must never enable + // it, even though ordinary ACP mode changes remain available remotely. + if (isRemoteDeniedAcpModeChange(name, data)) { + console.error('[adapter] Rejected remote attempt to enable Guarded Autopilot:', name); + settleRejectedInvoke(ws, name, data, 'remote-forbidden'); + return; + } const emitter = getBridgeEmitter(); if (emitter) { emitter.emit(name, data); diff --git a/src/renderer/utils/model/agentModes.ts b/src/renderer/utils/model/agentModes.ts index 00623e6a3b..684dca1831 100644 --- a/src/renderer/utils/model/agentModes.ts +++ b/src/renderer/utils/model/agentModes.ts @@ -11,6 +11,7 @@ import { CODEX_MODE_FULL_AUTO, CODEX_MODE_FULL_AUTO_NO_SANDBOX, } from '@/common/types/codex/codexModes'; +import { ACP_AUTO_GUARDED_MODE } from '@/common/types/agentModes'; /** * Agent mode option interface @@ -83,6 +84,18 @@ export const AGENT_MODES: Record = { { value: CODEX_MODE_FULL_AUTO, label: 'Full Auto' }, { value: CODEX_MODE_FULL_AUTO_NO_SANDBOX, label: 'Full Auto (No Sandbox)' }, ], + // Grok Build does not advertise a native full-auto ACP mode. Guarded + // Autopilot is intentionally Wayland-owned: the bridge remains in `default` + // and Wayland auto-approves non-catastrophic permission requests host-side. + // It is deliberately not `yolo`, which bypasses the host guardrail. + grok: [ + { value: 'default', label: 'Default' }, + { + value: ACP_AUTO_GUARDED_MODE, + label: 'Guarded Autopilot', + description: 'Auto-approve normal tool calls; require confirmation for catastrophic commands', + }, + ], cursor: [ { value: 'agent', label: 'Agent', description: 'Full agent capabilities with tool access' }, { value: 'plan', label: 'Plan', description: 'Read-only mode for planning and designing before implementation' }, diff --git a/tests/unit/acpAgentManagerTrust.test.ts b/tests/unit/acpAgentManagerTrust.test.ts index 262728fc32..4ed3cf119e 100644 --- a/tests/unit/acpAgentManagerTrust.test.ts +++ b/tests/unit/acpAgentManagerTrust.test.ts @@ -108,12 +108,12 @@ function makeManager(workspace: string) { return manager; } -function permissionSignal(kind: string) { +function permissionSignal(kind: string, rawInput?: Record) { return { type: 'acp_permission', msg_id: 'msg-1', data: { - toolCall: { toolCallId: 'call-1', kind, title: `${kind} tool` }, + toolCall: { toolCallId: 'call-1', kind, title: `${kind} tool`, rawInput }, options: [ { optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }, { optionId: 'reject-once', name: 'Deny', kind: 'reject_once' }, @@ -184,3 +184,61 @@ describe('AcpAgentManager trusted-workspace gate (#671)', () => { expect(isWorkspaceTrusted).toHaveBeenCalledWith('/specific/ws'); }); }); + +describe('AcpAgentManager Grok Guarded Autopilot gate', () => { + beforeEach(() => { + vi.useFakeTimers(); + isWorkspaceTrusted.mockReset(); + isWorkspaceTrusted.mockReturnValue(false); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('auto-approves an ordinary Grok execute request in guarded mode', async () => { + const mgr = makeManager('/grok/ws'); + (mgr as unknown as { currentMode: string }).currentMode = 'autoGuarded'; + const confirm = vi.spyOn(mgr, 'confirm').mockResolvedValue(undefined); + const addConfirmation = vi.spyOn(mgr as unknown as { addConfirmation: (c: unknown) => void }, 'addConfirmation'); + + await (mgr as unknown as { handleSignalEvent: SignalFn }).handleSignalEvent( + permissionSignal('execute', { command: 'bun run build' }), + 'grok' + ); + await vi.runAllTimersAsync(); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(addConfirmation).not.toHaveBeenCalled(); + }); + + it('holds a catastrophic Grok command for explicit confirmation in guarded mode', async () => { + const mgr = makeManager('/grok/ws'); + (mgr as unknown as { currentMode: string }).currentMode = 'autoGuarded'; + const confirm = vi.spyOn(mgr, 'confirm').mockResolvedValue(undefined); + const addConfirmation = vi.spyOn(mgr as unknown as { addConfirmation: (c: unknown) => void }, 'addConfirmation'); + + await (mgr as unknown as { handleSignalEvent: SignalFn }).handleSignalEvent( + permissionSignal('execute', { command: 'rm -rf ~' }), + 'grok' + ); + await vi.runAllTimersAsync(); + + expect(confirm).not.toHaveBeenCalled(); + expect(addConfirmation).toHaveBeenCalledTimes(1); + }); + + it('keeps ordinary Grok execute requests prompting in Default mode', async () => { + const mgr = makeManager('/grok/ws'); + const confirm = vi.spyOn(mgr, 'confirm').mockResolvedValue(undefined); + const addConfirmation = vi.spyOn(mgr as unknown as { addConfirmation: (c: unknown) => void }, 'addConfirmation'); + + await (mgr as unknown as { handleSignalEvent: SignalFn }).handleSignalEvent( + permissionSignal('execute', { command: 'bun run build' }), + 'grok' + ); + await vi.runAllTimersAsync(); + + expect(confirm).not.toHaveBeenCalled(); + expect(addConfirmation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/agentModes.test.ts b/tests/unit/agentModes.test.ts index d2911ba5ee..170d7b4a75 100644 --- a/tests/unit/agentModes.test.ts +++ b/tests/unit/agentModes.test.ts @@ -52,6 +52,19 @@ describe('AGENT_MODES.wnano', () => { }); }); +describe('AGENT_MODES.grok', () => { + it('exposes only Default and Wayland-owned Guarded Autopilot', () => { + expect(AGENT_MODES.grok).toEqual([ + { value: 'default', label: 'Default' }, + { + value: 'autoGuarded', + label: 'Guarded Autopilot', + description: 'Auto-approve normal tool calls; require confirmation for catastrophic commands', + }, + ]); + }); +}); + describe('getAgentModes', () => { it('returns claude modes for "claude" backend', () => { const modes = getAgentModes('claude'); @@ -63,6 +76,10 @@ describe('getAgentModes', () => { expect(getAgentModes('nonexistent')).toEqual([]); }); + it('returns Grok guarded-autopilot modes', () => { + expect(getAgentModes('grok').map((mode) => mode.value)).toEqual(['default', 'autoGuarded']); + }); + it('returns empty array for undefined', () => { expect(getAgentModes(undefined)).toEqual([]); }); diff --git a/tests/unit/bridgeAllowlistGrokGuardedAutopilot.redteam.test.ts b/tests/unit/bridgeAllowlistGrokGuardedAutopilot.redteam.test.ts new file mode 100644 index 0000000000..50620103f5 --- /dev/null +++ b/tests/unit/bridgeAllowlistGrokGuardedAutopilot.redteam.test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2026 Ferrox Labs + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; + +import { isAllowedForRemote, isRemoteDeniedAcpModeChange } from '@/common/adapter/bridgeAllowlist'; + +/** + * Guarded Autopilot authorizes unattended host-side tool approval. A paired + * WebUI token is not a local-desktop operator grant, so only the local Electron + * renderer may arm it. Ordinary ACP mode changes stay remote-allowed. + */ +describe('isRemoteDeniedAcpModeChange — Guarded Autopilot is local-only', () => { + const NAME = 'subscribe-acp.set-mode'; + const setMode = (mode: unknown) => ({ id: 'x', data: { conversationId: 'conv', mode } }); + + it('denies a paired WebUI attempt to arm Guarded Autopilot', () => { + expect(isRemoteDeniedAcpModeChange(NAME, setMode('autoGuarded'))).toBe(true); + }); + + it('does not over-deny normal ACP modes', () => { + // The generic wire-name allowlist stays open: only a remote WebSocket + // payload carrying autoGuarded is denied by the adapter's value-level gate. + // Local Electron IPC does not traverse the WebSocket adapter at all. + expect(isAllowedForRemote(NAME)).toBe(true); + expect(isRemoteDeniedAcpModeChange(NAME, setMode('default'))).toBe(false); + expect(isRemoteDeniedAcpModeChange(NAME, setMode('plan'))).toBe(false); + }); + + it('does not treat unrelated provider payloads as ACP mode changes', () => { + expect(isRemoteDeniedAcpModeChange('subscribe-acp.set-model', setMode('autoGuarded'))).toBe(false); + expect(isRemoteDeniedAcpModeChange(NAME, undefined)).toBe(false); + expect(isRemoteDeniedAcpModeChange(NAME, { data: {} })).toBe(false); + }); +}); diff --git a/tests/unit/commonAgentModes.test.ts b/tests/unit/commonAgentModes.test.ts index a4f35916a5..9a4f0fb384 100644 --- a/tests/unit/commonAgentModes.test.ts +++ b/tests/unit/commonAgentModes.test.ts @@ -68,4 +68,11 @@ describe('autoGuarded mode (Autopilot guardrail)', () => { expect(mapModeForAcpBridge('bypassPermissions')).toBe('bypassPermissions'); expect(mapModeForAcpBridge('plan')).toBe('plan'); }); + + it('keeps Grok Guarded Autopilot off unsupported Grok ACP mode APIs', () => { + // Grok exposes no native mode list. The renderer stores autoGuarded, but + // the ACP bridge receives only its normal/default mode while Wayland owns + // guarded approval in AcpAgentManager. + expect(mapModeForAcpBridge('autoGuarded')).toBe('default'); + }); }); From ef71700bf9f01c9ef9b68d694e8a52e5b25f67fc Mon Sep 17 00:00:00 2001 From: sethzora Date: Sun, 16 Aug 2026 00:02:00 -0400 Subject: [PATCH 2/2] test(recovery): avoid oversized Windows fixture --- .../services/recovery/recoveryCapture.ts | 38 +++++++++++++++---- .../services/recovery/recoveryCapture.test.ts | 10 +++-- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/process/services/recovery/recoveryCapture.ts b/src/process/services/recovery/recoveryCapture.ts index 7cca733b2d..a70bab65a8 100644 --- a/src/process/services/recovery/recoveryCapture.ts +++ b/src/process/services/recovery/recoveryCapture.ts @@ -186,9 +186,10 @@ async function addFileToEpoch(hash: ReturnType, filePath: str async function addPathToEpoch( hash: ReturnType, candidate: string, - excludedTopLevel: ReadonlySet = new Set() + excludedTopLevel: ReadonlySet = new Set(), + maxEntries = MAX_RECOVERY_INVENTORY_ENTRIES_PER_ROOT ): Promise { - let remaining = MAX_RECOVERY_INVENTORY_ENTRIES_PER_ROOT; + let remaining = maxEntries; let stat: Awaited>; try { stat = await lstat(candidate); @@ -242,8 +243,12 @@ function resolveInventoryUserDataRoot(inventory: RecoveryInventory): string { throw new Error('Recovery mutation epoch requires the authoritative user-data root.'); } -async function addNamespaceToEpoch(hash: ReturnType, userDataRoot: string): Promise { - let remaining = MAX_RECOVERY_INVENTORY_ENTRIES_PER_ROOT; +async function addNamespaceToEpoch( + hash: ReturnType, + userDataRoot: string, + maxEntries = MAX_RECOVERY_INVENTORY_ENTRIES_PER_ROOT +): Promise { + let remaining = maxEntries; const addDirectory = async (directory: string, relativeRoot: string): Promise => { const directoryStat = await lstat(directory); if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) { @@ -273,10 +278,28 @@ async function addNamespaceToEpoch(hash: ReturnType, userData await addDirectory(userDataRoot, ''); } +/** Test-only override: it can tighten the inventory cap but can never raise production's fixed limit. */ +export type RecoveryEpochTestOptions = Readonly<{ maxEntriesPerRoot?: number }>; + +function resolveRecoveryEpochEntryLimit(options: RecoveryEpochTestOptions | undefined): number { + const requested = options?.maxEntriesPerRoot; + if (requested === undefined) return MAX_RECOVERY_INVENTORY_ENTRIES_PER_ROOT; + if (!Number.isSafeInteger(requested) || requested < 1 || requested > MAX_RECOVERY_INVENTORY_ENTRIES_PER_ROOT) { + throw new RangeError( + `Recovery epoch entry limit must be a safe integer between 1 and ${MAX_RECOVERY_INVENTORY_ENTRIES_PER_ROOT}.` + ); + } + return requested; +} + /** Content-bound epoch for Desktop-owned copied state; SQLite has its own online-backup authority. */ -export async function fingerprintDesktopRecoveryState(inventory: RecoveryInventory): Promise { +export async function fingerprintDesktopRecoveryState( + inventory: RecoveryInventory, + testOptions?: RecoveryEpochTestOptions +): Promise { + const maxEntries = resolveRecoveryEpochEntryLimit(testOptions); const hash = createHash('sha256'); - await addNamespaceToEpoch(hash, resolveInventoryUserDataRoot(inventory)); + await addNamespaceToEpoch(hash, resolveInventoryUserDataRoot(inventory), maxEntries); for (const authority of inventory.authorities.filter(({ id }) => EPOCH_AUTHORITIES.has(id))) { hash.update(`authority\0${authority.id}\0`); for (const evidence of authority.evidence) { @@ -285,7 +308,8 @@ export async function fingerprintDesktopRecoveryState(inventory: RecoveryInvento await addPathToEpoch( hash, evidence.path, - authority.id === 'constitution.filesystem' ? new Set(['profiles']) : new Set() + authority.id === 'constitution.filesystem' ? new Set(['profiles']) : new Set(), + maxEntries ); } } diff --git a/tests/unit/process/services/recovery/recoveryCapture.test.ts b/tests/unit/process/services/recovery/recoveryCapture.test.ts index abbcd53c7d..23f5128c98 100644 --- a/tests/unit/process/services/recovery/recoveryCapture.test.ts +++ b/tests/unit/process/services/recovery/recoveryCapture.test.ts @@ -149,21 +149,23 @@ describe('Desktop recovery mutation epoch', () => { await expect(fingerprintDesktopRecoveryState(inventory(config))).rejects.toThrow('refuses hard-linked'); }); - it('bounds content hashing and rejects a 20,001-entry authority tree', async () => { + it('bounds content hashing without a huge physical fixture', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wayland-recovery-epoch-bounded-')); roots.push(root); const userDataRoot = path.join(root, 'user-data'); const config = path.join(root, 'oversize-config'); fs.mkdirSync(userDataRoot); fs.mkdirSync(config); - for (let index = 0; index < 20_001; index += 1) { + for (let index = 0; index < 2; index += 1) { fs.writeFileSync(path.join(config, `${index.toString().padStart(5, '0')}.json`), '{}'); } const value = inventory(config); value.userDataRoot = userDataRoot; - await expect(fingerprintDesktopRecoveryState(value)).rejects.toThrow('bounded content inventory'); - }, 30_000); + await expect(fingerprintDesktopRecoveryState(value, { maxEntriesPerRoot: 1 })).rejects.toThrow( + 'bounded content inventory' + ); + }); }); describe('Desktop-only production capture boundary', () => {