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
32 changes: 32 additions & 0 deletions cli/src/agent/permissionAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
45 changes: 45 additions & 0 deletions cli/src/agent/permissionAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
resolveToolAutoApprovalDecision,
type AutoApprovalDecision
} from '@/modules/common/permission/BasePermissionHandler';
import { shouldDenyAgentShellCommand } from '@/modules/common/permission/productionMutationGuard';

interface PermissionResponseMessage {
id: string;
Expand Down Expand Up @@ -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
});
Comment on lines +73 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the guard to the other ACP permission handlers

This wiring only protects PermissionAdapter-backed agents, but the repo also has ACP remote launchers with their own handlers (GeminiPermissionHandler, OpencodePermissionHandler, and KimiPermissionHandler) that call resolveAutoApprovalDecision and auto-approve in yolo without this guard. In those flavors, the same production mutation shell request still bypasses the new protection, so the guard needs to move into the shared handler path or be added to each handler.

Useful? React with 👍 / 👎.

if (productionGuard.deny) {
void this.autoDenyProductionMutation(request, toolName, input, productionGuard.reason ?? 'Blocked');
return;
}

const mode = this.getPermissionMode?.();
const autoDecision = resolveToolAutoApprovalDecision(mode, toolName, request.toolCallId);

Expand Down Expand Up @@ -137,6 +149,39 @@ export class PermissionAdapter {
);
}

private async autoDenyProductionMutation(
request: PermissionRequest,
toolName: string,
input: unknown,
reason: string
): Promise<void> {
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<void> {
const pending = this.pendingRequests.get(response.id);
if (!pending) {
Expand Down
29 changes: 29 additions & 0 deletions cli/src/modules/common/permission/productionMutationGuard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
92 changes: 92 additions & 0 deletions cli/src/modules/common/permission/productionMutationGuard.ts
Original file line number Diff line number Diff line change
@@ -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)/,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block documented hapi hub nohup starts

The documented persistent hub start command is nohup hapi hub --relay (docs/guide/installation.md:385), but this pattern only denies nohup when the command contains the source-tree forms bun run or src/index.ts. On hosts running the packaged HAPI CLI, a yolo ACP shell request can still start or restart the production hub with the documented command without hitting the guard, so the manual-hub match needs to include hapi hub/binary launch shapes too.

Useful? React with 👍 / 👎.

/manual-hub/,
/(^|[\s;|&])(kill|pkill|fuser)[\s].*(3006|hapi-hub|\/hub\/|src\/index\.ts)/,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block quoted remote kill commands

Quoted SSH commands such as ssh server "fuser -k 3006/tcp" or ssh server "pkill -f hapi-hub" are the normal remote-shell shape, but this pattern only matches when the character immediately before kill|pkill|fuser is start/space/;/|/&. Inside the remote command that character is the quote, so these production :3006/hub kills do not match and shouldDenyAgentShellCommand can fall through to yolo auto-approval, defeating the guard for one of the listed mutation classes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block xargs-based :3006 kills

When the production stop command finds the PID by port before invoking kill, e.g. lsof -ti:3006 | xargs kill -9, the 3006 token appears before kill, so this pattern does not match and none of the other mutation patterns catch it. In yolo ACP sessions that command can still be auto-approved even though it kills the protected hub port this guard is meant to muzzle.

Useful? React with 👍 / 👎.

/systemctl[\s]+(stop|restart|kill|disable|mask)[\s]+hapi-(hub|runner|runner-watchdog)/,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block systemctl commands with leading options

systemctl --help documents the form systemctl [OPTIONS...] COMMAND ..., so destructive calls can legally put flags before the verb, e.g. sudo systemctl --no-pager restart hapi-hub.service. This regex requires the verb immediately after systemctl, so those production restarts/stops miss the guard and can still be yolo-approved.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block direct runner lifecycle commands

When the ACP shell request uses the packaged CLI to manage the runner, e.g. hapi runner stop, hapi runner stop-session <id>, or hapi doctor clean, none of these patterns match because only the systemd service spelling is covered here. These commands are documented in cli/README.md and implemented to stop the runner or active runner sessions, so yolo can still auto-approve the same production runner mutation this guard blocks only when it is spelled as systemctl stop hapi-runner.

Useful? React with 👍 / 👎.

/git reset --hard.*(driver|hapi\/driver)/,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match git resets with -C driver paths

Git accepts the path selector before the subcommand (git -h shows git [-C <path>] ... <command>), so destructive driver resets are commonly written as git -C ~/coding/hapi/driver reset --hard or cd ~/coding/hapi/driver && git reset --hard. In both forms the driver token is before git reset --hard, so this pattern misses the production worktree reset and yolo can still auto-approve it.

Useful? React with 👍 / 👎.

/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();
Comment on lines +29 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Inspect rawInput command before display title

When an ACP permission request carries a generic non-empty title such as Shell plus the actual command in rawInput.command, this returns the title and never inspects the raw command. In that common shell-tool shape, a dangerous command like ssh server "hapi-driver-db-prep.sh ..." is evaluated as just Shell, so the guard returns allow and yolo can auto-approve the production mutation.

Useful? React with 👍 / 👎.

}
if (input.rawInput && typeof input.rawInput === 'object') {
const raw = input.rawInput as Record<string, unknown>;
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)) {
Comment on lines +77 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit mutation matching to shell requests

For non-shell ACP requests whose title is a path or label, this condition still falls through to the deny block whenever the text matches a mutation pattern. A read/edit permission for a file such as scripts/tooling/hapi-use-worktree.sh or documentation mentioning .hapi/hapi.db would be denied even though kind is read/edit and no shell command is being executed, which blocks harmless inspection in yolo/read-only sessions.

Useful? React with 👍 / 👎.

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 };
}
Loading