From 6d7457663f3d2f7e0ab207f08794f25e45ee43dd Mon Sep 17 00:00:00 2001 From: Cakrawala <48281037+WahidinAji@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:45:09 +0700 Subject: [PATCH] feat(coding-agent): support per-command bash approval rules --- docs/settings.md | 22 ++++ packages/agent/src/types.ts | 5 +- packages/coding-agent/CHANGELOG.md | 1 + .../src/config/settings-schema.ts | 11 ++ packages/coding-agent/src/tools/approval.ts | 56 ++++++++-- packages/coding-agent/src/tools/bash.ts | 79 ++++++++++++++ .../coding-agent/test/tools/approval.test.ts | 102 +++++++++++++++++- 7 files changed, 262 insertions(+), 14 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index eecd5e942..e22eef033 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -147,6 +147,28 @@ tools: read: allow ``` +### Bash command approval patterns + +`tools.approval` sets default policy by tool name. For bash, you can add ordered command rules with `bash.patterns`; the first matching rule wins. Patterns support literal text plus `*` as a wildcard. + +```yaml +tools: + approvalMode: write + approval: + bash: allow + +bash: + patterns: + - match: "git *" + approval: allow + - match: "rm -rf *" + approval: deny + - match: "*" + approval: allow +``` + +Valid rule approvals are `allow`, `prompt`, and `deny`. Critical bash commands still require confirmation unless a matching rule explicitly denies them; broad allow rules such as `match: "*"` do not bypass the critical-command guard. + ### Worked example: global vs. project ```yaml diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 9fe6078ab..478c5aa57 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -610,11 +610,14 @@ export type ToolLoadMode = "essential" | "discoverable"; * - bare tier ("read" / "write" / "exec") — static classification. * - object form — adds a `reason` (shown in the prompt) and/or `override: true` * (force-prompt even in modes that would otherwise auto-approve this tier). + * `policy: "deny"` blocks the call at the approval gate. * - function — dynamic, given parsed args. Returns either form above. * * Omitted approvals are treated as "exec" by callers that enforce approvals. */ -export type ToolApprovalDecision = ToolTier | { tier: ToolTier; reason?: string; override?: boolean }; +export type ToolApprovalDecision = + | ToolTier + | { tier: ToolTier; reason?: string; override?: boolean; policy?: "allow" | "deny" | "prompt" }; export type ToolApproval = ToolApprovalDecision | ((args: unknown) => ToolApprovalDecision); /** diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 99c0834c0..5176994a3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -600,6 +600,7 @@ ### Changed - Enhanced Anthropic credential and usage management to support organization-scoped accounts, including displaying organization names in /usage, /logout, omp token --list, and OAuth login success messages, resolving active-account matching for shared organizations, and deduplicating identities during migration. +- Added ordered `bash.patterns` command approval rules so selected bash commands can be allowed, prompted, or denied by command pattern. ## [16.5.0] - 2026-07-13 diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 3febaa5b7..b7750dae0 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -3287,6 +3287,17 @@ export const SETTINGS_SCHEMA = { description: "Automatically background long-running bash commands and deliver the result later", }, }, + "bash.patterns": { + type: "array", + default: [], + ui: { + tab: "shell", + group: "Bash", + label: "Bash Approval Patterns", + description: + "Ordered bash command approval rules. Each item has match and approval fields; only '*' wildcards are supported.", + }, + }, // Bash interceptor "bashInterceptor.enabled": { diff --git a/packages/coding-agent/src/tools/approval.ts b/packages/coding-agent/src/tools/approval.ts index 9b39eb0a7..bc0870902 100644 --- a/packages/coding-agent/src/tools/approval.ts +++ b/packages/coding-agent/src/tools/approval.ts @@ -20,6 +20,7 @@ export interface ResolvedApproval { tier: ToolTier; reason?: string; override: boolean; + source?: "tool" | "user" | "mode"; } const POLICY_VALUES: ReadonlySet = new Set(["allow", "deny", "prompt"]); @@ -50,7 +51,7 @@ function isToolTier(value: unknown): value is ToolTier { return typeof value === "string" && TIER_VALUES.has(value as ToolTier); } -function normalizeDecision(value: unknown): Omit { +function normalizeDecision(value: unknown): Omit & { policy?: ApprovalPolicy } { if (isToolTier(value)) { return { tier: value, override: false }; } @@ -59,9 +60,11 @@ function normalizeDecision(value: unknown): Omit { const record = value as Record; const tier = isToolTier(record.tier) ? record.tier : "exec"; const reason = typeof record.reason === "string" && record.reason.length > 0 ? record.reason : undefined; + const policy = normalizePolicy(record.policy); return { tier, override: record.override === true, + ...(policy ? { policy } : {}), ...(reason ? { reason } : {}), }; } @@ -69,7 +72,10 @@ function normalizeDecision(value: unknown): Omit { return { tier: "exec", override: false }; } -function getToolDecision(tool: ApprovalSubject, args: unknown): Omit { +function getToolDecision( + tool: ApprovalSubject, + args: unknown, +): Omit & { policy?: ApprovalPolicy } { const approval = tool.approval; const decision: ToolApprovalDecision | undefined = typeof approval === "function" ? approval(args) : approval; return normalizeDecision(decision); @@ -110,34 +116,61 @@ export function resolveApproval( const decision = getToolDecision(tool, args); const userPolicy = Object.hasOwn(userConfig, tool.name) ? normalizePolicy(userConfig[tool.name]) : undefined; + if (decision.policy === "deny") { + return { + policy: "deny", + tier: decision.tier, + override: decision.override, + source: "tool", + ...(decision.reason ? { reason: decision.reason } : {}), + }; + } + if (userPolicy === "deny") { + return { policy: "deny", tier: decision.tier, override: decision.override, source: "user" }; + } + if (mode === "yolo") { - return { policy: userPolicy ?? "allow", tier: decision.tier, override: false }; + return { + policy: userPolicy ?? "allow", + tier: decision.tier, + override: false, + source: userPolicy ? "user" : "mode", + }; } if (decision.override) { - if (userPolicy === "deny") { - return { policy: "deny", tier: decision.tier, override: true }; - } return { - policy: "prompt", + policy: decision.policy === "allow" ? "allow" : "prompt", tier: decision.tier, override: true, + source: "tool", + ...(decision.reason ? { reason: decision.reason } : {}), + }; + } + + if (decision.policy === "allow" || decision.policy === "prompt") { + return { + policy: decision.policy, + tier: decision.tier, + override: false, + source: "tool", ...(decision.reason ? { reason: decision.reason } : {}), }; } if (userPolicy) { - return { policy: userPolicy, tier: decision.tier, override: false }; + return { policy: userPolicy, tier: decision.tier, override: false, source: "user" }; } if (modeApprovesTier(mode, decision.tier)) { - return { policy: "allow", tier: decision.tier, override: false }; + return { policy: "allow", tier: decision.tier, override: false, source: "mode" }; } return { policy: "prompt", tier: decision.tier, override: false, + source: "mode", ...(decision.reason ? { reason: decision.reason } : {}), }; } @@ -154,9 +187,12 @@ export function requiresApproval( mode: ApprovalMode, userConfig: Record = {}, ): { required: boolean; reason?: string } { - const { policy, reason } = resolveApproval(tool, args, mode, userConfig); + const { policy, reason, source } = resolveApproval(tool, args, mode, userConfig); if (policy === "deny") { + if (source === "tool") { + throw new Error(`Tool "${tool.name}" is blocked by tool policy.${reason ? `\nReason: ${reason}` : ""}`); + } throw new Error( `Tool "${tool.name}" is blocked by user policy.\n` + `To allow: remove "tools.approval.${tool.name}: deny" from config.`, diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 588867a60..967df4742 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -51,6 +51,8 @@ export const BASH_DEFAULT_PREVIEW_LINES = DEFAULT_TERMINAL_PREVIEW_LINES; const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000; +const BASH_APPROVAL_SHELL_CONTROL_RE = /[\n\r;&|<>`$()]/u; +const BASH_PATTERN_APPROVAL_VALUES = new Set(["allow", "deny", "prompt"]); /** * Shape a shell command line for an ACP-conformant `terminal/create` request. @@ -127,6 +129,63 @@ export const CRITICAL_BASH_PATTERNS = [ /\bnc\b[^|;]*\s-[a-zA-Z]*[ec][a-zA-Z]*\s/i, // `nc -e` / `nc -c`. ] as const; +type BashPatternApproval = "allow" | "deny" | "prompt"; + +interface BashApprovalPatternRule { + match: string; + approval: BashPatternApproval; +} + +function normalizeBashApprovalPattern(value: string): string { + return value.trim().replace(/\s+/gu, " "); +} + +function bashApprovalPatternToRegExp(pattern: string): RegExp { + const escaped = normalizeBashApprovalPattern(pattern) + .split("*") + .map(part => part.replace(/[\\^$+?.()|[\]{}]/gu, "\\$&")) + .join(".*"); + return new RegExp(`^${escaped}$`, "u"); +} + +function normalizeBashPatternApproval(value: unknown): BashPatternApproval | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + return BASH_PATTERN_APPROVAL_VALUES.has(normalized) ? (normalized as BashPatternApproval) : undefined; +} + +function getBashApprovalPatternRules(value: unknown): BashApprovalPatternRule[] { + if (!Array.isArray(value)) return []; + return value + .map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) return undefined; + const record = item as Record; + if (typeof record.match !== "string") return undefined; + const match = normalizeBashApprovalPattern(record.match); + const approval = normalizeBashPatternApproval(record.approval); + return match.length > 0 && approval ? { match, approval } : undefined; + }) + .filter((rule): rule is BashApprovalPatternRule => !!rule); +} + +function commandMatchesBashApprovalPattern(command: string, pattern: string): boolean { + const normalizedCommand = normalizeBashApprovalPattern(command); + if (normalizedCommand.length === 0) return false; + return bashApprovalPatternToRegExp(pattern).test(normalizedCommand); +} + +function findBashApprovalPatternRule( + command: string, + rules: readonly BashApprovalPatternRule[], +): BashApprovalPatternRule | undefined { + return rules.find(rule => { + if (rule.approval === "allow" && BASH_APPROVAL_SHELL_CONTROL_RE.test(normalizeBashApprovalPattern(command))) { + return false; + } + return commandMatchesBashApprovalPattern(command, rule.match); + }); +} + async function saveBashOriginalArtifact(session: ToolSession, originalText: string): Promise { try { const alloc = await session.allocateOutputArtifact?.("bash-original"); @@ -384,9 +443,29 @@ export class BashTool implements AgentTool { const rawCommand = (args as Partial).command; const command = typeof rawCommand === "string" ? rawCommand : ""; + const patternRules = getBashApprovalPatternRules(this.session.settings.get("bash.patterns")); + const patternRule = patternRules.find(rule => commandMatchesBashApprovalPattern(command, rule.match)); + if (patternRule?.approval === "deny") { + return { + tier: "exec", + override: true, + policy: "deny", + reason: `Blocked by bash pattern: ${patternRule.match}`, + }; + } if (command !== "" && CRITICAL_BASH_PATTERNS.some(pattern => pattern.test(command))) { return { tier: "exec", override: true, reason: "Critical pattern detected" }; } + const safePatternRule = findBashApprovalPatternRule(command, patternRules); + if (safePatternRule?.approval === "allow") return { tier: "write", policy: "allow" }; + if (safePatternRule?.approval === "prompt") { + return { + tier: "exec", + override: true, + policy: "prompt", + reason: `Prompt required by bash pattern: ${safePatternRule.match}`, + }; + } return "exec"; }; readonly formatApprovalDetails = (args: unknown): string[] => { diff --git a/packages/coding-agent/test/tools/approval.test.ts b/packages/coding-agent/test/tools/approval.test.ts index ae6581e40..ab53dc8bf 100644 --- a/packages/coding-agent/test/tools/approval.test.ts +++ b/packages/coding-agent/test/tools/approval.test.ts @@ -21,9 +21,10 @@ function tool( return { name, approval, formatApprovalDetails }; } -function createBashTool(): BashTool { +function createBashTool(settingsOverrides: Record = {}): BashTool { const settings = { get(key: string): unknown { + if (Object.hasOwn(settingsOverrides, key)) return settingsOverrides[key]; switch (key) { case "async.enabled": case "bash.autoBackground.enabled": @@ -42,8 +43,8 @@ function createBashTool(): BashTool { return new BashTool({ settings } as unknown as ConstructorParameters[0]); } -function bashApproval(command: string) { - const approval = createBashTool().approval; +function bashApproval(command: string, settingsOverrides: Record = {}) { + const approval = createBashTool(settingsOverrides).approval; if (typeof approval !== "function") throw new Error("Bash approval must be dynamic"); return approval({ command }); } @@ -94,6 +95,22 @@ describe("resolveApproval override and user policy", () => { ); }); + it("tool-owned deny policy blocks before mode and user allow policies", () => { + const blocked = tool("bash", { + tier: "exec", + override: true, + policy: "deny", + reason: "Blocked by bash pattern: rm -rf *", + }); + expect(resolveApproval(blocked, {}, "yolo", { bash: "allow" })).toMatchObject({ + policy: "deny", + source: "tool", + }); + expect(() => requiresApproval(blocked, {}, "write", { bash: "allow" })).toThrow( + 'Tool "bash" is blocked by tool policy', + ); + }); + it("valid user policy overrides mode and tier when no tool override is active", () => { const writeTool = tool("write", "write"); expect(resolveApproval(writeTool, {}, "always-ask", { write: "allow" }).policy).toBe("allow"); @@ -178,6 +195,85 @@ describe("tool-owned dynamic approval declarations", () => { } }); + it("classifies configured bash approval patterns", () => { + const settingsOverrides = { + "bash.patterns": [ + { match: "git *", approval: "allow" }, + { match: "rm -rf *", approval: "deny" }, + { match: "*", approval: "prompt" }, + ], + }; + + for (const command of ["git diff packages/coding-agent/src/tools/bash.ts", "git status", "git log --oneline"]) { + expect(bashApproval(command, settingsOverrides)).toEqual({ tier: "write", policy: "allow" }); + } + + expect(bashApproval("rm -rf build", settingsOverrides)).toEqual({ + tier: "exec", + override: true, + policy: "deny", + reason: "Blocked by bash pattern: rm -rf *", + }); + expect( + bashApproval("git diff packages/coding-agent/src/tools/bash.ts && rm file.txt", settingsOverrides), + ).toEqual({ + tier: "exec", + override: true, + policy: "prompt", + reason: "Prompt required by bash pattern: *", + }); + expect(bashApproval("echo hello", settingsOverrides)).toEqual({ + tier: "exec", + override: true, + policy: "prompt", + reason: "Prompt required by bash pattern: *", + }); + }); + + it("keeps critical bash patterns prompt-gated unless explicitly denied", () => { + const settingsOverrides = { + "bash.patterns": [{ match: "*", approval: "allow" }], + }; + + expect(bashApproval("rm -rf /", settingsOverrides)).toEqual({ + tier: "exec", + override: true, + reason: "Critical pattern detected", + }); + expect(bashApproval("echo hello", settingsOverrides)).toEqual({ + tier: "write", + policy: "allow", + }); + expect(bashApproval("echo hello && rm file.txt", settingsOverrides)).toBe("exec"); + }); + + it("applies the first matching bash approval pattern", () => { + const settingsOverrides = { + "bash.patterns": [ + { match: "*", approval: "allow" }, + { match: "git *", approval: "deny" }, + ], + }; + + expect(bashApproval("git status", settingsOverrides)).toEqual({ + tier: "write", + policy: "allow", + }); + }); + + it("allows a specific deny pattern to block a critical bash command", () => { + const settingsOverrides = { + "bash.patterns": [{ match: "rm -rf *", approval: "deny" }], + }; + + expect(bashApproval("rm -rf /", settingsOverrides)).toEqual({ + tier: "exec", + override: true, + policy: "deny", + reason: "Blocked by bash pattern: rm -rf *", + }); + }); + it("exports LSP and debug read-only action sets from their owning tools", () => { expect(LSP_READONLY_ACTIONS.has("diagnostics")).toBe(true); expect(LSP_READONLY_ACTIONS.has("rename")).toBe(false);