Files
oh-my-pi/packages/coding-agent/src/tools/approval.ts
T
oldschoola 4d26453a0b feat(coding-agent): restore per-tool approval policies with safer defaults
Re-introduces the per-tool approval system from luzidd's commit 39124f3 (which
is no longer reachable from main) and improves it before re-landing.

What's restored:
- ApprovalPolicy (allow/deny/prompt) plus DEFAULT_APPROVAL_POLICIES.
- ACTION_EXCEPTIONS registry (LSP read-only, bash critical patterns).
- getApprovalPolicy() six-level resolution order.
- ExtensionToolWrapper.execute() gate before extension handlers.
- --auto-approve / --yolo CLI flag and tools.approval.<tool> user config.
- docs/approval-mode.md user guide.

What's improved over the original:
- Replaced unchecked 'as any' casts with typed unknown narrowing helpers.
- Validate userConfig values: invalid strings, numbers, etc. fall through to
  the built-in default instead of being silently honoured (typo no longer
  locks a tool out or grants implicit approval).
- Expanded CRITICAL_BASH_PATTERNS: chmod -R /, chown -R /, bash <(curl ...),
  writes to /etc/passwd|shadow|sudoers, shutdown/reboot/halt/init 0,
  kill -9 1, nc -e / nc -c reverse shells. Pattern shapes require a
  command-position boundary so 'npm run reboot-tests' and 'echo "shutdown the
  queue"' don't false-positive.
- Added DEBUG_READONLY_ACTIONS exception so DAP inspection actions (threads,
  stack_trace, variables, scopes, read_memory, …) auto-allow while
  execution-side actions (launch, attach, continue, evaluate, write_memory,
  set_breakpoint, …) still prompt.
- formatApprovalPrompt: labels mcp__<server>__<tool> calls as MCP server
  tools, surfaces ssh host + command, recognises the modern § hashline header
  for edit, and truncates >240-char fields so a heredoc-sized body cannot
  blow out the confirmation dialog.
- Test suite grown from 40 to 57 cases — new coverage for invalid user
  config, the extended critical-bash patterns, benign-keyword negatives,
  debug exceptions, MCP/ssh prompt formatting, and command truncation.

Verification:
- bun test packages/coding-agent/test/tools/approval.test.ts -> 57 pass
- bun x biome check . -> clean
- bun run check:ts across all 9 workspaces -> clean
2026-05-26 20:53:33 +02:00

343 lines
11 KiB
TypeScript

/**
* Tool approval policies for safe mode.
*
* VSCode-style per-tool approval with:
* - Built-in defaults (read-only tools auto-allowed, destructive tools require approval)
* - User allowlist via config (`tools.approval.<toolName>: allow|deny|prompt`)
* - Action-based exceptions (tool-level policy can be overridden for specific actions)
* - CLI override (`--auto-approve` / `--yolo`) bypasses all prompts
*
* Resolution is intentionally minimal and pure — no I/O, no async, no settings
* lookups inside this module. Callers thread a plain user-config record in and
* read the resulting `{ required, reason }` shape.
*/
export type ApprovalPolicy = "allow" | "deny" | "prompt";
const POLICY_VALUES: ReadonlySet<ApprovalPolicy> = new Set(["allow", "deny", "prompt"]);
/** Best-effort conversion of an arbitrary user-supplied value to a policy. */
function normalizePolicy(value: unknown): ApprovalPolicy | undefined {
if (typeof value !== "string") return undefined;
const lowered = value.trim().toLowerCase();
return POLICY_VALUES.has(lowered as ApprovalPolicy) ? (lowered as ApprovalPolicy) : undefined;
}
/** Narrow an arbitrary tool input to a record without losing safety. */
function asRecord(input: unknown): Record<string, unknown> | undefined {
return typeof input === "object" && input !== null ? (input as Record<string, unknown>) : undefined;
}
/** Read a string field from an unknown input. Returns `""` when missing or non-string. */
function readString(input: unknown, key: string): string {
const record = asRecord(input);
const value = record?.[key];
return typeof value === "string" ? value : "";
}
/**
* Action-based exception rule. Allows fine-grained control over tool approval
* based on input parameters (e.g., LSP read-only actions, dangerous bash patterns).
*/
export interface ActionException {
/** Check if this exception applies to the given input. */
matches: (input: unknown) => boolean;
/** Policy to apply when matched. */
policy: ApprovalPolicy;
/** If true, this exception overrides user config (for safety). */
override?: boolean;
/** Human-readable reason surfaced in the prompt. */
reason?: string;
}
/**
* Built-in tool default policies.
*
* Read-only tools are auto-allowed. Destructive/execution tools require approval.
* Unknown tools (including MCP `*__*` tools and custom extensions) fall through
* to `_default`.
*/
export const DEFAULT_APPROVAL_POLICIES: Record<string, ApprovalPolicy> = {
// Read-only tools — auto-allow.
read: "allow",
find: "allow",
search: "allow",
ast_grep: "allow",
web_search: "allow",
hindsight_recall: "allow",
inspect_image: "allow",
job: "allow", // Polling/status check.
// Tools with action-based exceptions.
lsp: "prompt", // Default prompt; readonly actions exempted in ACTION_EXCEPTIONS.
bash: "prompt", // Default prompt; critical patterns override user allow in ACTION_EXCEPTIONS.
debug: "prompt", // Default prompt; inspection actions exempted in ACTION_EXCEPTIONS.
// Destructive tools — require approval.
write: "prompt",
edit: "prompt",
ast_edit: "prompt",
browser: "prompt",
task: "prompt",
eval: "prompt",
ssh: "prompt",
hindsight_retain: "prompt",
checkpoint: "prompt",
rewind: "prompt",
// Interactive/meta tools — auto-allow.
ask: "allow",
todo_write: "allow",
irc: "allow",
yield: "allow",
resolve: "allow",
// Fallback for unknown tools (custom + MCP).
_default: "prompt",
};
/**
* Bash patterns that ALWAYS trigger approval prompt even if `bash` is user-allowed.
*
* Kept intentionally tight — the cost of a false positive is one extra prompt;
* the cost of a false negative is data loss or a compromised host. New patterns
* should target shapes that are virtually never legitimate in automation.
*/
export const CRITICAL_BASH_PATTERNS = [
// Recursive destruction.
/\brm\s+-[a-z]*[rRfF][a-z]*\s+\//i, // rm -rf /, rm -fr /, rm -r /, rm -f /…
/\bsudo\s+rm\b/i, // any `sudo rm`.
/\bchmod\s+-R\s+[0-7]+\s+\//i, // `chmod -R 777 /`.
/\bchown\s+-R\s+\S+\s+\//i, // `chown -R user /`.
// Fork bomb (a few common spacings).
/:\(\)\s*\{\s*:\s*\|\s*:/i,
// Disk / filesystem destruction.
/>\s*\/dev\/sd[a-z]/i, // write to disk device.
/\bmkfs(\.|\b)/i, // format filesystem.
/\bdd\s+if=.+of=\/dev\//i, // dd to a device.
/\bshred\s+\/dev\//i,
/\bcryptsetup\b/i,
// System-config destruction.
/>\s*\/etc\/(?:passwd|shadow|sudoers)\b/i,
// Remote-fetch-then-execute (curl/wget piped to a shell or process-subbed).
/\b(?:curl|wget|fetch)\b[^|]*\|\s*(?:bash|sh|zsh|fish)\b/i,
/\b(?:bash|sh|zsh)\s+<\(\s*(?:curl|wget|fetch)\b/i,
// Process/host control.
/\bkill\s+-9\s+1\b/, // kill PID 1.
// Process/host control — must sit at command position so `npm run reboot-tests`
// or `echo 'shutdown the queue'` don't false-positive.
/(?:^|[\s;&|(])(?:shutdown|poweroff|reboot|halt)(?:\s|$|[;|&])/i,
/(?:^|[\s;&|(])init\s+0\b/i,
// Network-shell exfil.
/\bnc\b[^|;]*\s-[a-zA-Z]*[ec][a-zA-Z]*\s/i, // `nc -e` / `nc -c`.
] as const;
/**
* LSP actions that don't mutate the workspace or the language server.
* Anything not in this set (rename, code_actions with apply, rename_file, reload,
* raw `request`) falls through to prompt.
*/
export const LSP_READONLY_ACTIONS: ReadonlySet<string> = new Set([
"diagnostics",
"definition",
"type_definition",
"implementation",
"references",
"hover",
"symbols",
"status",
"capabilities",
]);
/**
* DAP debug actions that only read program state (no mutation, no execution).
* The execution-side actions (`launch`, `attach`, `continue`, `step_*`, `pause`,
* `evaluate`, `terminate`, breakpoint mutations, memory writes) still prompt.
*/
export const DEBUG_READONLY_ACTIONS: ReadonlySet<string> = new Set([
"output",
"threads",
"stack_trace",
"scopes",
"variables",
"disassemble",
"read_memory",
"loaded_sources",
"modules",
"sessions",
]);
/**
* Action-based exception rules.
*
* Rules are evaluated in two passes (see {@link getApprovalPolicy}): overriding
* rules win over user config, non-overriding rules trail it.
*
* Use cases:
* - LSP / debug: exempt read-only actions from prompting.
* - Bash: force prompts for dangerous patterns regardless of allowlist.
*/
export const ACTION_EXCEPTIONS: Record<string, ActionException[]> = {
lsp: [
{
matches: input => LSP_READONLY_ACTIONS.has(readString(input, "action").toLowerCase()),
policy: "allow",
override: false, // user can still pin `lsp: prompt` to require all actions.
},
],
debug: [
{
matches: input => DEBUG_READONLY_ACTIONS.has(readString(input, "action").toLowerCase()),
policy: "allow",
override: false,
},
],
bash: [
{
matches: input => {
const cmd = readString(input, "command");
return cmd !== "" && CRITICAL_BASH_PATTERNS.some(p => p.test(cmd));
},
policy: "prompt",
override: true, // safety: user `bash: allow` cannot bypass this.
reason: "Critical pattern detected",
},
],
};
/**
* Resolve approval policy for a tool call.
*
* Resolution order (first match wins):
* 1. Overriding action exceptions (safety rules — user config cannot bypass).
* 2. User config for the specific tool (validated; invalid values ignored).
* 3. Non-overriding action exceptions (performance optimizations).
* 4. Built-in default for the tool.
* 5. User's `_default` (only consulted for tools without a built-in default).
* 6. System fallback (`prompt`).
*/
export function getApprovalPolicy(
toolName: string,
input: unknown,
userConfig: Record<string, unknown> = {},
): { policy: ApprovalPolicy; reason?: string } {
const exceptions = ACTION_EXCEPTIONS[toolName] ?? [];
// 1. Overriding exceptions (safety rules).
for (const exception of exceptions) {
if (exception.override && exception.matches(input)) {
return { policy: exception.policy, reason: exception.reason };
}
}
// 2. User config for the specific tool — validated.
if (Object.hasOwn(userConfig, toolName)) {
const validated = normalizePolicy(userConfig[toolName]);
if (validated) return { policy: validated };
// Fall through silently — invalid values do not lock the user out of the tool.
}
// 3. Non-overriding exceptions (performance optimizations).
for (const exception of exceptions) {
if (!exception.override && exception.matches(input)) {
return { policy: exception.policy, reason: exception.reason };
}
}
// 4. Built-in default for the tool.
if (Object.hasOwn(DEFAULT_APPROVAL_POLICIES, toolName)) {
return { policy: DEFAULT_APPROVAL_POLICIES[toolName] };
}
// 5. User-provided `_default` (only for tools without a built-in default).
if (Object.hasOwn(userConfig, "_default")) {
const validated = normalizePolicy(userConfig._default);
if (validated) return { policy: validated };
}
// 6. System fallback.
return { policy: DEFAULT_APPROVAL_POLICIES._default };
}
/**
* Check if a tool call requires user approval.
*
* @throws Error if policy is 'deny'
* @returns Object with required flag and optional reason for the prompt
*/
export function requiresApproval(
toolName: string,
input: unknown,
userConfig: Record<string, unknown> = {},
): { required: boolean; reason?: string } {
const { policy, reason } = getApprovalPolicy(toolName, input, userConfig);
if (policy === "deny") {
throw new Error(
`Tool "${toolName}" is blocked by user policy.\n` +
`To allow: remove "tools.approval.${toolName}: deny" from config.`,
);
}
if (policy === "prompt") return { required: true, reason };
return { required: false };
}
const MAX_PROMPT_FIELD_LEN = 240;
function truncateForPrompt(value: string): string {
if (value.length <= MAX_PROMPT_FIELD_LEN) return value;
return `${value.slice(0, MAX_PROMPT_FIELD_LEN - 1)}…`;
}
/** MCP-style tool names: `mcp__<server>__<tool>` or `<server>__<tool>`. */
function isMcpToolName(toolName: string): boolean {
return toolName.startsWith("mcp__") || toolName.includes("__");
}
/**
* Format tool call details for the approval prompt.
*
* The output is intentionally compact: one line per fact. Long fields are
* truncated so a heredoc body or a giant param payload doesn't blow out the
* confirmation dialog.
*/
export function formatApprovalPrompt(toolName: string, input: unknown, reason?: string): string {
const parts: string[] = [`Allow tool: ${toolName}`];
if (isMcpToolName(toolName) && !Object.hasOwn(DEFAULT_APPROVAL_POLICIES, toolName)) {
parts.push("Origin: MCP server tool");
}
if (reason) parts.push(`Reason: ${reason}`);
const record = asRecord(input);
if (!record) return parts.join("\n");
if (toolName === "bash" && typeof record.command === "string") {
parts.push(`Command: ${truncateForPrompt(record.command)}`);
} else if (toolName === "write" && typeof record.path === "string") {
parts.push(`Path: ${record.path}`);
} else if (toolName === "edit" && typeof record.input === "string") {
const match = record.input.match(/§([^\n]+)/) ?? record.input.match(/@([^\n]+)/);
if (match) parts.push(`File: ${match[1]}`);
} else if (toolName === "lsp" && typeof record.action === "string") {
parts.push(`Action: ${record.action}`);
if (typeof record.file === "string") parts.push(`File: ${record.file}`);
} else if (toolName === "debug" && typeof record.action === "string") {
parts.push(`Action: ${record.action}`);
if (typeof record.program === "string") parts.push(`Program: ${record.program}`);
} else if (toolName === "ssh" && typeof record.command === "string") {
if (typeof record.host === "string") parts.push(`Host: ${record.host}`);
parts.push(`Command: ${truncateForPrompt(record.command)}`);
}
return parts.join("\n");
}