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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/common/adapter/bridgeAllowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 31 additions & 7 deletions src/process/services/recovery/recoveryCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,10 @@ async function addFileToEpoch(hash: ReturnType<typeof createHash>, filePath: str
async function addPathToEpoch(
hash: ReturnType<typeof createHash>,
candidate: string,
excludedTopLevel: ReadonlySet<string> = new Set()
excludedTopLevel: ReadonlySet<string> = new Set(),
maxEntries = MAX_RECOVERY_INVENTORY_ENTRIES_PER_ROOT
): Promise<void> {
let remaining = MAX_RECOVERY_INVENTORY_ENTRIES_PER_ROOT;
let remaining = maxEntries;
let stat: Awaited<ReturnType<typeof lstat>>;
try {
stat = await lstat(candidate);
Expand Down Expand Up @@ -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<typeof createHash>, userDataRoot: string): Promise<void> {
let remaining = MAX_RECOVERY_INVENTORY_ENTRIES_PER_ROOT;
async function addNamespaceToEpoch(
hash: ReturnType<typeof createHash>,
userDataRoot: string,
maxEntries = MAX_RECOVERY_INVENTORY_ENTRIES_PER_ROOT
): Promise<void> {
let remaining = maxEntries;
const addDirectory = async (directory: string, relativeRoot: string): Promise<void> => {
const directoryStat = await lstat(directory);
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
Expand Down Expand Up @@ -273,10 +278,28 @@ async function addNamespaceToEpoch(hash: ReturnType<typeof createHash>, 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<string> {
export async function fingerprintDesktopRecoveryState(
inventory: RecoveryInventory,
testOptions?: RecoveryEpochTestOptions
): Promise<string> {
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) {
Expand All @@ -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
);
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/process/webserver/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { registerWebSocketBroadcaster, getBridgeEmitter } from '@/common/adapter
import {
isAllowedInboundName,
isAllowedForRemote,
isRemoteDeniedAcpModeChange,
isAllowedOutboundToRemote,
isRemoteDeniedConfigWrite,
} from '@/common/adapter/bridgeAllowlist';
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/utils/model/agentModes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,6 +84,18 @@ export const AGENT_MODES: Record<string, AgentModeOption[]> = {
{ 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' },
Expand Down
62 changes: 60 additions & 2 deletions tests/unit/acpAgentManagerTrust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,12 @@ function makeManager(workspace: string) {
return manager;
}

function permissionSignal(kind: string) {
function permissionSignal(kind: string, rawInput?: Record<string, unknown>) {
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' },
Expand Down Expand Up @@ -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);
});
});
17 changes: 17 additions & 0 deletions tests/unit/agentModes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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([]);
});
Expand Down
38 changes: 38 additions & 0 deletions tests/unit/bridgeAllowlistGrokGuardedAutopilot.redteam.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
7 changes: 7 additions & 0 deletions tests/unit/commonAgentModes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
10 changes: 6 additions & 4 deletions tests/unit/process/services/recovery/recoveryCapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading