diff --git a/cli/src/agent/permissionAdapter.test.ts b/cli/src/agent/permissionAdapter.test.ts index ca7094b1ec..31ac8c2996 100644 --- a/cli/src/agent/permissionAdapter.test.ts +++ b/cli/src/agent/permissionAdapter.test.ts @@ -370,6 +370,38 @@ describe('PermissionAdapter', () => { }); }); + it('denies production mutation shell commands even in yolo mode', async () => { + const harness = createHarnessWithMode(() => 'yolo'); + const cmd = 'ssh server "kill 1 && hapi-driver-db-prep.sh test && nohup bun run src/index.ts"'; + + harness.emitPermissionRequest(buildRequest({ + id: 'perm-prod-block', + toolCallId: 'perm-prod-block', + title: cmd, + kind: 'execute', + options: [ + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }, + { optionId: 'allow-once', name: 'Allow', kind: 'allow_once' } + ] + })); + + await flushAsyncWork(); + + expect(harness.respondCalls).toEqual([ + { + sessionId: 'session-1', + request: expect.objectContaining({ id: 'perm-prod-block', title: cmd }), + response: { outcome: 'selected', optionId: 'reject-once' } + } + ]); + expect(harness.getAgentState().completedRequests).toMatchObject({ + 'perm-prod-block': { + status: 'denied', + decision: 'denied' + } + }); + }); + it('auto-approves read-only non-write tools but keeps writes pending', async () => { const harness = createHarnessWithMode(() => 'read-only'); diff --git a/cli/src/agent/permissionAdapter.ts b/cli/src/agent/permissionAdapter.ts index 8b43830c52..1fb75e81da 100644 --- a/cli/src/agent/permissionAdapter.ts +++ b/cli/src/agent/permissionAdapter.ts @@ -8,6 +8,7 @@ import { resolveToolAutoApprovalDecision, type AutoApprovalDecision } from '@/modules/common/permission/BasePermissionHandler'; +import { shouldDenyAgentShellCommand } from '@/modules/common/permission/productionMutationGuard'; interface PermissionResponseMessage { id: string; @@ -68,6 +69,17 @@ export class PermissionAdapter { rawInput: request.rawInput }); const input = deriveToolInput(request); + + const productionGuard = shouldDenyAgentShellCommand({ + title: request.title, + kind: request.kind, + rawInput: request.rawInput + }); + if (productionGuard.deny) { + void this.autoDenyProductionMutation(request, toolName, input, productionGuard.reason ?? 'Blocked'); + return; + } + const mode = this.getPermissionMode?.(); const autoDecision = resolveToolAutoApprovalDecision(mode, toolName, request.toolCallId); @@ -137,6 +149,39 @@ export class PermissionAdapter { ); } + private async autoDenyProductionMutation( + request: PermissionRequest, + toolName: string, + input: unknown, + reason: string + ): Promise { + const optionId = pickOptionId(request, ['reject_once', 'reject_always'], { fallbackToFirst: false }); + const outcome: PermissionResponse = optionId + ? { outcome: 'selected', optionId } + : { outcome: 'cancelled' }; + + await this.backend.respondToPermission(request.sessionId, request, outcome); + + const timestamp = Date.now(); + this.session.updateAgentState((currentState) => ({ + ...currentState, + completedRequests: { + ...currentState.completedRequests, + [request.id]: { + tool: toolName, + arguments: input, + createdAt: timestamp, + completedAt: timestamp, + status: 'denied', + reason, + decision: 'denied' + } + } + } satisfies AgentState)); + + logger.warn(`[ACP] Production mutation denied for ${toolName} (${request.id}): ${reason}`); + } + private async handlePermissionResponse(response: PermissionResponseMessage): Promise { const pending = this.pendingRequests.get(response.id); if (!pending) { diff --git a/cli/src/modules/common/permission/productionMutationGuard.test.ts b/cli/src/modules/common/permission/productionMutationGuard.test.ts new file mode 100644 index 0000000000..e64735279a --- /dev/null +++ b/cli/src/modules/common/permission/productionMutationGuard.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { + matchesProductionMutation, + matchesRemoteProductionMutation, + shouldDenyAgentShellCommand, +} from './productionMutationGuard'; + +describe('productionMutationGuard', () => { + it('blocks the 2026-06-20 incident command shape over ssh', () => { + const cmd = + 'ssh server "kill 60544 && hapi-driver-db-prep.sh cross-flavor && nohup bun run src/index.ts"'; + expect(matchesRemoteProductionMutation(cmd)).toBe(true); + expect( + shouldDenyAgentShellCommand({ title: cmd, kind: 'execute' }).deny, + ).toBe(true); + }); + + it('allows read-only ssh diagnostics', () => { + const cmd = 'ssh server "cd ~/coding/hapi/driver && git status -sb"'; + expect(matchesRemoteProductionMutation(cmd)).toBe(false); + expect(shouldDenyAgentShellCommand({ title: cmd, kind: 'execute' }).deny).toBe(false); + }); + + it('blocks local manual hub nohup without ssh prefix', () => { + const cmd = 'cd hub && nohup bun run src/index.ts >> manual-hub.log'; + expect(matchesProductionMutation(cmd)).toBe(true); + expect(shouldDenyAgentShellCommand({ title: cmd }).deny).toBe(true); + }); +}); diff --git a/cli/src/modules/common/permission/productionMutationGuard.ts b/cli/src/modules/common/permission/productionMutationGuard.ts new file mode 100644 index 0000000000..f4549ef412 --- /dev/null +++ b/cli/src/modules/common/permission/productionMutationGuard.ts @@ -0,0 +1,92 @@ +/** + * Block production HAPI mutations from agent shell (manual hub, stack switch, :3006 kill). + * Shared by CLI PermissionAdapter (HAPI ACP/yolo path) and operator hook scripts. + */ + +const MUTATION_PATTERNS: RegExp[] = [ + /hapi-driver-db-prep/, + /hapi-use-worktree/, + /hapi-use-driver/, + /hapi-driver-rebuild.*--activate/, + /hapi-watch-activate-driver/, + /hapi_stack_switch_yes=1/, + /nohup.*(bun run|src\/index\.ts)/, + /manual-hub/, + /(^|[\s;|&])(kill|pkill|fuser)[\s].*(3006|hapi-hub|\/hub\/|src\/index\.ts)/, + /systemctl[\s]+(stop|restart|kill|disable|mask)[\s]+hapi-(hub|runner|runner-watchdog)/, + /git reset --hard.*(driver|hapi\/driver)/, + /embeddedassets.*driver/, + /(\.hapi\/hapi\.db|hapi\.db\.bak)/, +]; + +const REMOTE_SSH_PATTERN = /(^|[\s|&;])(ssh|scp|rsync)[\s]|wsl[\s].*ssh/; + +export function extractShellCommandLine(input: { + title?: string | null; + kind?: string | null; + rawInput?: unknown; +}): string { + if (typeof input.title === 'string' && input.title.trim()) { + return input.title.trim(); + } + if (input.rawInput && typeof input.rawInput === 'object') { + const raw = input.rawInput as Record; + for (const key of ['command', 'cmd', 'script']) { + const value = raw[key]; + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } + } + return ''; +} + +export function matchesProductionMutation(command: string): boolean { + if (!command.trim()) { + return false; + } + const lc = command.toLowerCase(); + return MUTATION_PATTERNS.some((re) => re.test(lc)); +} + +export function matchesRemoteProductionMutation(command: string): boolean { + if (!command.trim()) { + return false; + } + const lc = command.toLowerCase(); + return REMOTE_SSH_PATTERN.test(lc) && matchesProductionMutation(command); +} + +export function shouldDenyAgentShellCommand(input: { + title?: string | null; + kind?: string | null; + rawInput?: unknown; +}): { deny: boolean; command: string; reason?: string } { + const command = extractShellCommandLine(input); + if (!command) { + return { deny: false, command: '' }; + } + + const lcKind = (input.kind ?? '').toLowerCase(); + const looksLikeShell = + lcKind === 'execute' || + lcKind === 'shell' || + lcKind === 'run_terminal_cmd' || + /^(ssh|curl|git|kill|nohup|hapi-|sudo|systemctl)\b/i.test(command); + + if (!looksLikeShell && !matchesProductionMutation(command)) { + return { deny: false, command }; + } + + if (matchesProductionMutation(command)) { + return { + deny: true, + command, + reason: matchesRemoteProductionMutation(command) + ? 'Production HAPI mutation over SSH blocked (Windows estate muzzle).' + : 'Production HAPI mutation blocked (manual hub / stack switch / :3006).', + }; + } + + return { deny: false, command }; +}