From 47e723892547d09db2865ff0df5bc4bfb1a65014 Mon Sep 17 00:00:00 2001 From: Luke Boyle Date: Wed, 6 May 2026 09:11:19 +1000 Subject: [PATCH] feat: support destructive subcommand exclusions in npx safe policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npx safe whitelist now accepts per-package destructive subcommands. Packages mapped to `true` allow all subcommands (existing behaviour). Packages mapped to a `Set` allow everything except those subcommands — e.g. `npx cdk synth` is auto-allowed but `npx cdk deploy` falls through to ask. Adds CDK with deploy/destroy as destructive subcommands. Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- policies/allow-npx-safe.ts | 24 ++++++--- policies/tests/allow-npx-safe.test.ts | 71 +++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 policies/tests/allow-npx-safe.test.ts diff --git a/package.json b/package.json index 1e87c15..1832ba8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@brycehanscomb/toolgate", - "version": "0.4.0", + "version": "0.5.0", "devDependencies": { "bun-types": "latest" }, diff --git a/policies/allow-npx-safe.ts b/policies/allow-npx-safe.ts index f9507a4..5e86bd4 100644 --- a/policies/allow-npx-safe.ts +++ b/policies/allow-npx-safe.ts @@ -1,15 +1,25 @@ import { allow, next, type Policy } from "../src"; import { safeBashCommand, safeBashCommandOrPipeline, getAndChainSegments, getArgs, parseShell } from "./parse-bash-ast"; -/** Whitelisted npx packages — add entries as needed. */ -const SAFE_NPX_PACKAGES = new Set([ - "next", - "playwright", - "vitest", -]); +/** + * Whitelisted npx packages. + * - `true` means all subcommands are safe + * - A `Set` lists destructive subcommands that should NOT be auto-allowed + */ +const SAFE_NPX_PACKAGES: Record> = { + next: true, + playwright: true, + vitest: true, + cdk: new Set(["deploy", "destroy"]), +}; function isAllowedNpx(tokens: string[]): boolean { - return tokens[0] === "npx" && SAFE_NPX_PACKAGES.has(tokens[1]); + if (tokens[0] !== "npx") return false; + const rule = SAFE_NPX_PACKAGES[tokens[1]]; + if (!rule) return false; + if (rule === true) return true; + // Block if any argument matches a destructive subcommand + return !tokens.slice(2).some(t => rule.has(t)); } const allowNpxSafe: Policy = { diff --git a/policies/tests/allow-npx-safe.test.ts b/policies/tests/allow-npx-safe.test.ts new file mode 100644 index 0000000..f8d4bae --- /dev/null +++ b/policies/tests/allow-npx-safe.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import allowNpxSafe from "../allow-npx-safe"; + +const makeCall = (tool: string, args: Record = {}): ToolCall => ({ + tool, + args, + context: { cwd: "/tmp", env: {}, projectRoot: "/tmp" }, +}); + +describe("allow-npx-safe", () => { + it("allows npx playwright test", async () => { + const result = await allowNpxSafe.handler(makeCall("Bash", { command: "npx playwright test" })); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows npx vitest", async () => { + const result = await allowNpxSafe.handler(makeCall("Bash", { command: "npx vitest" })); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows npx next build", async () => { + const result = await allowNpxSafe.handler(makeCall("Bash", { command: "npx next build" })); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows npx cdk synth", async () => { + const result = await allowNpxSafe.handler(makeCall("Bash", { command: "npx cdk synth" })); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows npx cdk diff", async () => { + const result = await allowNpxSafe.handler(makeCall("Bash", { command: "npx cdk diff" })); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows npx cdk list", async () => { + const result = await allowNpxSafe.handler(makeCall("Bash", { command: "npx cdk list" })); + expect(result.verdict).toBe(ALLOW); + }); + + it("passes through npx cdk deploy", async () => { + const result = await allowNpxSafe.handler(makeCall("Bash", { command: "npx cdk deploy" })); + expect(result.verdict).toBe(NEXT); + }); + + it("passes through npx cdk deploy with stack name", async () => { + const result = await allowNpxSafe.handler(makeCall("Bash", { command: "npx cdk deploy MyStack" })); + expect(result.verdict).toBe(NEXT); + }); + + it("passes through npx cdk destroy", async () => { + const result = await allowNpxSafe.handler(makeCall("Bash", { command: "npx cdk destroy" })); + expect(result.verdict).toBe(NEXT); + }); + + it("passes through unknown npx packages", async () => { + const result = await allowNpxSafe.handler(makeCall("Bash", { command: "npx some-unknown-pkg" })); + expect(result.verdict).toBe(NEXT); + }); + + it("allows mcp__playwright__ tools", async () => { + const result = await allowNpxSafe.handler(makeCall("mcp__playwright__browser_snapshot")); + expect(result.verdict).toBe(ALLOW); + }); + + it("passes through non-Bash tools", async () => { + const result = await allowNpxSafe.handler(makeCall("Read", { file_path: "/tmp/test" })); + expect(result.verdict).toBe(NEXT); + }); +});