diff --git a/package.json b/package.json index 1e87c15..12b3af6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@brycehanscomb/toolgate", - "version": "0.4.0", + "version": "0.10.0", "devDependencies": { "bun-types": "latest" }, diff --git a/policies/_test-runners.ts b/policies/_test-runners.ts new file mode 100644 index 0000000..b6e25ac --- /dev/null +++ b/policies/_test-runners.ts @@ -0,0 +1,47 @@ +/** + * Shared allowlist of test-runner command prefixes. Used by policies that + * permit running a test runner in various contexts (docker compose exec, + * subshell-wrapped cd, etc.). + * + * Each entry is an ordered token prefix. A command matches if its first + * N tokens equal the prefix exactly; the rest are treated as opaque args. + */ +export const TEST_RUNNER_PREFIXES: readonly (readonly string[])[] = [ + // PHP / Laravel + ["php", "artisan", "test"], + ["php", "vendor/bin/phpunit"], + ["php", "vendor/bin/pest"], + ["php", "vendor/bin/phpstan"], + ["vendor/bin/phpunit"], + ["vendor/bin/pest"], + ["vendor/bin/phpstan"], + ["./vendor/bin/phpunit"], + ["./vendor/bin/pest"], + ["./vendor/bin/phpstan"], + // JS / TS + ["bun", "test"], + ["npm", "test"], + ["pnpm", "test"], + ["yarn", "test"], + // Python + ["pytest"], + ["python", "-m", "pytest"], + ["python", "-m", "unittest"], + ["python3", "-m", "pytest"], + ["python3", "-m", "unittest"], +]; + +export function matchesTestRunner(tokens: string[], start = 0): boolean { + for (const prefix of TEST_RUNNER_PREFIXES) { + if (tokens.length - start < prefix.length) continue; + let ok = true; + for (let i = 0; i < prefix.length; i++) { + if (tokens[start + i] !== prefix[i]) { + ok = false; + break; + } + } + if (ok) return true; + } + return false; +} diff --git a/policies/allow-aws-cli.ts b/policies/allow-aws-cli.ts index 389cb2e..1b5f73b 100644 --- a/policies/allow-aws-cli.ts +++ b/policies/allow-aws-cli.ts @@ -128,12 +128,13 @@ export function createAwsCliPolicy(config: AwsCliPolicyConfig = {}): Policy { return { name: "Allow AWS CLI", description: - "Auto-allows non-destructive AWS CLI commands with ReadOnly profiles; requires approval for Admin profiles; denies destructive commands", + "Auto-allows non-destructive AWS CLI commands with ReadOnly profiles (via --profile or AWS_PROFILE env); requires approval for Admin profiles; denies destructive commands", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); if (!tokens || tokens[0] !== "aws") return next(); - const profile = extractProfile(tokens); + // --profile flag wins, then AWS_PROFILE env, matching AWS CLI's own precedence + const profile = extractProfile(tokens) ?? call.context.env.AWS_PROFILE ?? null; // Destructive commands — always require approval if (isDestructiveCommand(tokens, extraDestructive)) return next(); diff --git a/policies/allow-cdk.ts b/policies/allow-cdk.ts new file mode 100644 index 0000000..16c3363 --- /dev/null +++ b/policies/allow-cdk.ts @@ -0,0 +1,49 @@ +import { allow, next, type Policy } from "../src"; +import { safeBashCommandOrPipeline } from "./parse-bash-ast"; + +/** CDK subcommands that are read-only / informational */ +const SAFE_SUBCOMMANDS = new Set([ + "ls", + "list", + "diff", + "synth", + "synthesize", + "doctor", + "context", + "metadata", + "notices", +]); + +/** CDK flags that act as read-only subcommands */ +const SAFE_FLAGS = new Set([ + "--version", + "-v", + "--help", + "-h", +]); + +const allowCdk: Policy = { + name: "Allow cdk read-only", + description: + "Auto-allows read-only cdk commands (ls, diff, synth, doctor, etc.); requires approval for deploy, destroy, bootstrap, import, migrate, rollback, watch", + handler: async (call) => { + const tokens = await safeBashCommandOrPipeline(call); + if (!tokens || tokens[0] !== "cdk") return next(); + + if (tokens.length >= 2 && SAFE_FLAGS.has(tokens[1])) return allow(); + + let subcommand: string | undefined; + for (let i = 1; i < tokens.length; i++) { + if (!tokens[i].startsWith("-")) { + subcommand = tokens[i]; + break; + } + } + if (!subcommand) return next(); + + if (SAFE_SUBCOMMANDS.has(subcommand)) return allow(); + + return next(); + }, +}; +export default allowCdk; diff --git a/policies/allow-docker-compose-exec-mysql-readonly.ts b/policies/allow-docker-compose-exec-mysql-readonly.ts new file mode 100644 index 0000000..199ae28 --- /dev/null +++ b/policies/allow-docker-compose-exec-mysql-readonly.ts @@ -0,0 +1,341 @@ +import type { ToolCall } from "../src"; +import { allow, next, type Policy } from "../src"; +import { + Op, + type BinaryCmd, + type DblQuoted, + type Lit, + type SglQuoted, + type Stmt, + type Word, + getArgs, + hasUnsafeNodes, + isSafeFilter, + parseShell, + wordToString, +} from "./parse-bash-ast"; + +/** + * Permit read-only mysql queries through `docker compose exec`. Supports two + * forms: + * + * docker compose [flags] exec [flags] mysql [flags...] -e '' + * docker compose [flags] exec [flags] sh -c "mysql [flags...] -e ''" + * + * The whole thing may optionally be piped to safe filters (head/tail/grep/etc). + * `` must be one or more read-only statements (SELECT/SHOW/DESC/EXPLAIN/...). + */ + +const COMPOSE_FLAGS_WITH_VALUE = new Set([ + "-f", "--file", + "--env-file", + "-p", "--project-name", + "--project-directory", + "--profile", + "--ansi", + "--progress", + "--parallel", +]); + +const EXEC_FLAGS_WITH_VALUE = new Set([ + "-u", "--user", + "-w", "--workdir", + "-e", "--env", + "--index", +]); + +const EXEC_BOOLEAN_FLAGS = new Set([ + "-T", + "--no-TTY", + "-i", "--interactive", + "-t", "--tty", +]); + +const EXEC_FORBIDDEN_FLAGS = new Set([ + "-d", "--detach", + "--privileged", +]); + +const MYSQL_FLAGS_WITH_VALUE = new Set([ + "-h", "--host", + "-u", "--user", + "-P", "--port", + "-S", "--socket", + "-D", "--database", + "--default-character-set", + "--protocol", + "--connect-timeout", +]); + +// Flags like `-p` and `-pPASSWORD` (mysql lets you concat the password) — the +// value form `-p` followed by separate token *prompts* interactively, which +// blocks. The concatenated form `-pPASSWORD` is a single token. We treat any +// lone `-p` as boolean (no value consumed) since requiring a value tends to +// hang anyway. +const MYSQL_BOOLEAN_OR_CONCAT_FLAGS = new Set([ + "-p", + "--password", + "-N", "--skip-column-names", + "-s", "--silent", + "-v", "--verbose", + "-t", "--table", + "-B", "--batch", + "-X", "--xml", + "--html", + "-r", "--raw", + "--vertical", + "-E", + "--show-warnings", + "--ssl", + "--ssl-mode=DISABLED", +]); + +const READ_ONLY_SQL_VERBS = new Set([ + "SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "USE", "HELP", "WITH", "VALUES", +]); + +function isReadOnlySql(sql: string): boolean { + // Reject SQL comments — they can hide payload. + if (sql.includes("--") || sql.includes("/*") || sql.includes("#")) return false; + const stmts = sql.split(";").map((s) => s.trim()).filter((s) => s.length > 0); + if (stmts.length === 0) return false; + for (const stmt of stmts) { + const firstWord = stmt.split(/\s+/)[0]?.toUpperCase(); + if (!firstWord || !READ_ONLY_SQL_VERBS.has(firstWord)) return false; + } + return true; +} + +/** + * Resolve a Word to a static string, allowing mixed Lit + single/double-quoted + * parts (including DblQuoted that wraps further Lit/SglQuoted). Returns null + * if any part involves expansion (ParamExp, CmdSubst, ArithmExp, ProcSubst). + */ +function staticString(word: Word): string | null { + const out: string[] = []; + for (const part of word.Parts) { + if (part.Type === "Lit") { + out.push((part as Lit).Value); + } else if (part.Type === "SglQuoted") { + out.push((part as SglQuoted).Value); + } else if (part.Type === "DblQuoted") { + const dbl = part as DblQuoted; + for (const inner of dbl.Parts ?? []) { + if (inner.Type === "Lit") { + out.push((inner as Lit).Value); + } else if (inner.Type === "SglQuoted") { + out.push((inner as SglQuoted).Value); + } else { + return null; + } + } + } else { + return null; + } + } + return out.join(""); +} + +/** Re-parse a string and return its single CallExpr's args, or null. */ +async function reparseToCallExpr(s: string): Promise { + const file = await parseShell(s); + if (!file) return null; + if (file.Stmts.length !== 1) return null; + if (hasUnsafeNodes(file)) return null; + const stmt = file.Stmts[0]; + if (stmt.Background || stmt.Negated) return null; + if ((stmt as any).Comments?.length > 0) return null; + if (stmt.Redirs && stmt.Redirs.length > 0) return null; + if (stmt.Cmd?.Type !== "CallExpr") return null; + return getArgs(stmt); +} + +/** Walk past flags, returning index of the next positional arg or null. */ +function skipFlags( + tokens: string[], + start: number, + flagsWithValue: Set, + booleanFlags: Set | null, + forbiddenFlags: Set | null, +): number | null { + let i = start; + while (i < tokens.length) { + const t = tokens[i]; + if (!t.startsWith("-")) return i; + if (forbiddenFlags && forbiddenFlags.has(t)) return null; + if (t.includes("=")) { + const flagName = t.slice(0, t.indexOf("=")); + if (forbiddenFlags && forbiddenFlags.has(flagName)) return null; + i += 1; + continue; + } + if (flagsWithValue.has(t)) { + i += 2; + continue; + } + if (!booleanFlags || booleanFlags.has(t)) { + i += 1; + continue; + } + return null; + } + return null; +} + +/** + * Validate that `mysql [flags...] -e [...]` is read-only. Every -e + * value must be read-only SQL. Returns true if the command is a safe mysql + * invocation. + */ +function isReadOnlyMysqlCall(tokens: string[]): boolean { + if (tokens[0] !== "mysql") return false; + let sawDashE = false; + let i = 1; + while (i < tokens.length) { + const t = tokens[i]; + if (t === "-e" || t === "--execute") { + if (i + 1 >= tokens.length) return false; + const sql = tokens[i + 1]; + if (!isReadOnlySql(sql)) return false; + sawDashE = true; + i += 2; + continue; + } + // --execute=SQL or --execute='SQL' + if (t.startsWith("--execute=")) { + const sql = t.slice("--execute=".length); + if (!isReadOnlySql(sql)) return false; + sawDashE = true; + i += 1; + continue; + } + if (MYSQL_FLAGS_WITH_VALUE.has(t)) { + i += 2; + continue; + } + if (t.includes("=") && t.startsWith("--")) { + i += 1; + continue; + } + if (MYSQL_BOOLEAN_OR_CONCAT_FLAGS.has(t)) { + i += 1; + continue; + } + // Concatenated flag forms (-pPASS, -hHOST, -uUSER, etc.) — single token. + if (t.startsWith("-") && t.length > 2 && !t.startsWith("--")) { + i += 1; + continue; + } + // --long=value already handled above; any other --long flag we don't + // recognise → bail (could change behavior unexpectedly). + if (t.startsWith("--")) { + i += 1; + continue; + } + // Positional arg (e.g. database name without -D) — accept. + i += 1; + } + return sawDashE; +} + +/** + * Walk the outermost stmt as: 0+ pipes to safe filters, with our target + * docker-compose call at the leftmost leaf. + */ +function unwrapTopLevelPipes(stmt: Stmt): { leaf: Stmt } | null { + let cur: Stmt = stmt; + if (cur.Background || cur.Negated) return null; + if ((cur as any).Comments?.length > 0) return null; + while (cur.Cmd?.Type === "BinaryCmd") { + const bin = cur.Cmd as BinaryCmd; + if (bin.Op !== Op.Pipe) return null; + if (bin.Y.Background || bin.Y.Negated) return null; + const rightArgs = getArgs(bin.Y); + if (!rightArgs || !isSafeFilter(rightArgs)) return null; + cur = bin.X; + } + return { leaf: cur }; +} + +async function check(call: ToolCall): Promise { + if (call.tool !== "Bash") return false; + const command = call.args?.command; + if (typeof command !== "string") return false; + + const file = await parseShell(command); + if (!file) return false; + if (file.Stmts.length !== 1) return false; + if (hasUnsafeNodes(file)) return false; + + const unwrapped = unwrapTopLevelPipes(file.Stmts[0]); + if (!unwrapped) return false; + const leaf = unwrapped.leaf; + if (leaf.Cmd?.Type !== "CallExpr") return false; + if (leaf.Background || leaf.Negated) return false; + + // Extract docker compose [flags] exec [flags] + // We need the raw Word array because the inner sh -c arg may be a multi-part + // quoted string that wordToString rejects. Walk args manually. + const argWords = (leaf.Cmd as any).Args as Word[]; + if (!argWords || argWords.length < 4) return false; + + // Resolve each Word to a string using staticString (allows multi-part static). + const tokens: string[] = []; + for (const w of argWords) { + // Prefer wordToString for the common case; fall back to staticString. + const simple = wordToString(w); + if (simple !== null) { + tokens.push(simple); + } else { + const sx = staticString(w); + if (sx === null) return false; + tokens.push(sx); + } + } + + if (tokens[0] !== "docker" || tokens[1] !== "compose") return false; + + const execIdx = skipFlags(tokens, 2, COMPOSE_FLAGS_WITH_VALUE, null, null); + if (execIdx === null || tokens[execIdx] !== "exec") return false; + + const serviceIdx = skipFlags( + tokens, + execIdx + 1, + EXEC_FLAGS_WITH_VALUE, + EXEC_BOOLEAN_FLAGS, + EXEC_FORBIDDEN_FLAGS, + ); + if (serviceIdx === null) return false; + + const innerStart = serviceIdx + 1; + if (innerStart >= tokens.length) return false; + + // Direct form: docker compose exec mysql ... + if (tokens[innerStart] === "mysql") { + return isReadOnlyMysqlCall(tokens.slice(innerStart)); + } + + // sh -c form: docker compose exec sh -c "" + if (tokens[innerStart] === "sh" && tokens[innerStart + 1] === "-c") { + const inner = tokens[innerStart + 2]; + if (inner === undefined) return false; + // Reject if there are extra tokens after the sh -c arg (positional $0 etc.) + if (innerStart + 3 !== tokens.length) return false; + const innerArgs = await reparseToCallExpr(inner); + if (!innerArgs) return false; + return isReadOnlyMysqlCall(innerArgs); + } + + return false; +} + +const allowDockerComposeExecMysqlReadOnly: Policy = { + name: "Allow docker compose exec mysql (read-only)", + description: + "Permits `docker compose exec mysql -e ''` (and `sh -c \"mysql -e ''\"`) when every SQL statement is read-only (SELECT/SHOW/DESC/EXPLAIN/USE/HELP/WITH/VALUES)", + handler: async (call) => { + return (await check(call)) ? allow() : next(); + }, +}; + +export default allowDockerComposeExecMysqlReadOnly; diff --git a/policies/allow-docker-compose-exec-tests.ts b/policies/allow-docker-compose-exec-tests.ts new file mode 100644 index 0000000..e42399f --- /dev/null +++ b/policies/allow-docker-compose-exec-tests.ts @@ -0,0 +1,105 @@ +import { allow, next, type Policy } from "../src"; +import { safeBashCommandOrPipeline } from "./parse-bash-ast"; +import { matchesTestRunner } from "./_test-runners"; + +const COMPOSE_FLAGS_WITH_VALUE = new Set([ + "-f", "--file", + "--env-file", + "-p", "--project-name", + "--project-directory", + "--profile", + "--ansi", + "--progress", + "--parallel", +]); + +const EXEC_FLAGS_WITH_VALUE = new Set([ + "-u", "--user", + "-w", "--workdir", + "-e", "--env", + "--index", +]); + +// Boolean exec flags we permit (no value, just skip). +const EXEC_BOOLEAN_FLAGS = new Set([ + "-T", + "--no-TTY", + "-i", "--interactive", + "-t", "--tty", +]); + +// Exec flags whose presence should reject the command — they signal +// something other than a foreground test invocation. +const EXEC_FORBIDDEN_FLAGS = new Set([ + "-d", "--detach", + "--privileged", +]); + +/** Skip flags and return the index of the next positional, or null. */ +function skipFlags( + tokens: string[], + start: number, + flagsWithValue: Set, + booleanFlags: Set | null, + forbiddenFlags: Set | null, +): number | null { + let i = start; + while (i < tokens.length) { + const t = tokens[i]; + if (!t.startsWith("-")) return i; + if (forbiddenFlags && forbiddenFlags.has(t)) return null; + if (t.includes("=")) { + // --flag=value — must not be a forbidden flag + const eqIdx = t.indexOf("="); + const flagName = t.slice(0, eqIdx); + if (forbiddenFlags && forbiddenFlags.has(flagName)) return null; + i += 1; + continue; + } + if (flagsWithValue.has(t)) { + i += 2; + continue; + } + if (!booleanFlags || booleanFlags.has(t)) { + i += 1; + continue; + } + // Unknown flag — bail out conservatively. + return null; + } + return null; +} + +const allowDockerComposeExecTests: Policy = { + name: "Allow docker compose exec test runners", + description: + "Permits `docker compose exec ` when the inner command is a known test runner (php artisan test, phpunit, pest, bun test, pytest, etc.)", + handler: async (call) => { + const tokens = await safeBashCommandOrPipeline(call); + if (!tokens) return next(); + if (tokens[0] !== "docker" || tokens[1] !== "compose") return next(); + + // Skip compose-level flags. + const execIdx = skipFlags(tokens, 2, COMPOSE_FLAGS_WITH_VALUE, null, null); + if (execIdx === null) return next(); + if (tokens[execIdx] !== "exec") return next(); + + // Skip exec-level flags (rejecting forbidden ones). + const serviceIdx = skipFlags( + tokens, + execIdx + 1, + EXEC_FLAGS_WITH_VALUE, + EXEC_BOOLEAN_FLAGS, + EXEC_FORBIDDEN_FLAGS, + ); + if (serviceIdx === null) return next(); + + // Service name is a single positional, then the inner command begins. + const innerStart = serviceIdx + 1; + if (innerStart >= tokens.length) return next(); + + return matchesTestRunner(tokens, innerStart) ? allow() : next(); + }, +}; + +export default allowDockerComposeExecTests; diff --git a/policies/allow-docker-read-only.ts b/policies/allow-docker-read-only.ts new file mode 100644 index 0000000..671fa16 --- /dev/null +++ b/policies/allow-docker-read-only.ts @@ -0,0 +1,97 @@ +import { allow, next, type Policy } from "../src"; +import { safeBashCommandOrPipeline } from "./parse-bash-ast"; + +// Compose CLI flags that consume the next token as a value. +const COMPOSE_FLAGS_WITH_VALUE = new Set([ + "-f", "--file", + "--env-file", + "-p", "--project-name", + "--project-directory", + "--profile", + "--ansi", + "--progress", + "--parallel", +]); + +// docker — read-only top-level commands. +const DOCKER_READ_ONLY = new Set([ + "ps", "images", "logs", "inspect", "version", "info", + "stats", "top", "port", "events", "history", "diff", +]); + +// docker — read-only namespaced commands. +const DOCKER_NAMESPACED_READ_ONLY: Record> = { + image: new Set(["ls", "inspect", "history"]), + container: new Set(["ls", "inspect", "logs", "top", "port", "diff", "stats"]), + network: new Set(["ls", "inspect"]), + volume: new Set(["ls", "inspect"]), + system: new Set(["info", "df", "events"]), + node: new Set(["ls", "inspect"]), + service: new Set(["ls", "inspect", "logs", "ps"]), + stack: new Set(["ls", "ps", "services"]), + context: new Set(["ls", "inspect", "show"]), + plugin: new Set(["ls", "inspect"]), + config: new Set(["ls", "inspect"]), + secret: new Set(["ls", "inspect"]), +}; + +// docker compose — read-only. +const COMPOSE_READ_ONLY = new Set([ + "ps", "logs", "top", "images", "config", "port", "ls", + "version", "events", "convert", +]); + +/** + * Walk past flags (including `--flag value` pairs) and return the index of + * the first positional argument, or null if none. + */ +function findNextPositional( + tokens: string[], + start: number, + flagsWithValue: Set, +): number | null { + let i = start; + while (i < tokens.length) { + const t = tokens[i]; + if (!t.startsWith("-")) return i; + if (t.includes("=")) { + i += 1; + continue; + } + if (flagsWithValue.has(t)) { + i += 2; + continue; + } + i += 1; + } + return null; +} + +const allowDockerReadOnly: Policy = { + name: "Allow docker read-only", + description: + "Permits read-only docker / docker compose subcommands (ps, logs, inspect, config, etc.)", + handler: async (call) => { + const tokens = await safeBashCommandOrPipeline(call); + if (!tokens || tokens[0] !== "docker") return next(); + + // docker compose [compose-flags] + if (tokens[1] === "compose") { + const subIdx = findNextPositional(tokens, 2, COMPOSE_FLAGS_WITH_VALUE); + if (subIdx === null) return next(); + return COMPOSE_READ_ONLY.has(tokens[subIdx]) ? allow() : next(); + } + + const sub = tokens[1]; + if (!sub) return next(); + + if (DOCKER_READ_ONLY.has(sub)) return allow(); + + const namespaced = DOCKER_NAMESPACED_READ_ONLY[sub]; + if (namespaced && tokens[2] && namespaced.has(tokens[2])) return allow(); + + return next(); + }, +}; + +export default allowDockerReadOnly; diff --git a/policies/allow-go.ts b/policies/allow-go.ts new file mode 100644 index 0000000..3fe8984 --- /dev/null +++ b/policies/allow-go.ts @@ -0,0 +1,32 @@ +import { allow, next, type Policy } from "../src"; +import { safeBashCommandOrPipeline } from "./parse-bash-ast"; + +const SAFE_SUBCOMMANDS = new Set([ + "build", + "test", + "vet", + "version", + "env", + "list", + "doc", + "fmt", + "mod", + "help", + "tool", + "work", +]); + +const allowGo: Policy = { + name: "Allow go commands", + description: + "Permits non-destructive Go commands (build, test, vet, env, list, doc, fmt, mod, etc.)", + handler: async (call) => { + const tokens = await safeBashCommandOrPipeline(call); + if (!tokens) return next(); + if (tokens[0] !== "go") return next(); + if (tokens.length < 2) return next(); + if (!SAFE_SUBCOMMANDS.has(tokens[1])) return next(); + return allow(); + }, +}; +export default allowGo; diff --git a/policies/allow-lsof.ts b/policies/allow-lsof.ts new file mode 100644 index 0000000..858a0e5 --- /dev/null +++ b/policies/allow-lsof.ts @@ -0,0 +1,13 @@ +import { allow, next, type Policy } from "../src"; +import { safeBashCommandOrPipeline } from "./parse-bash-ast"; + +const allowLsof: Policy = { + name: "Allow lsof", + description: "Permits lsof for inspecting open files, sockets, and ports, optionally piped through safe filters", + handler: async (call) => { + const tokens = await safeBashCommandOrPipeline(call); + if (!tokens || tokens[0] !== "lsof") return next(); + return allow(); + }, +}; +export default allowLsof; diff --git a/policies/allow-mcp-atlassian.ts b/policies/allow-mcp-atlassian.ts new file mode 100644 index 0000000..0794534 --- /dev/null +++ b/policies/allow-mcp-atlassian.ts @@ -0,0 +1,16 @@ +import { allow, deny, next, type Policy } from "../src"; + +/** + * Allow all mcp__atlassian__* tool calls except delete operations. + */ +const allowMcpAtlassian: Policy = { + name: "Allow MCP Atlassian", + description: + "Permits all Atlassian MCP tool calls except deleting Confluence pages", + handler: async (call) => { + if (!call.tool.startsWith("mcp__atlassian__")) return next(); + if (call.tool.includes("delete")) return deny("Atlassian delete operations require manual approval"); + return allow(); + }, +}; +export default allowMcpAtlassian; diff --git a/policies/allow-npm-install.ts b/policies/allow-npm-install.ts index 16701c4..c19ce55 100644 --- a/policies/allow-npm-install.ts +++ b/policies/allow-npm-install.ts @@ -1,11 +1,11 @@ import { allow, next, type Policy } from "../src"; -import { safeBashCommand } from "./parse-bash-ast"; +import { safeBashCommandOrPipeline } from "./parse-bash-ast"; const allowNpmInstall: Policy = { name: "Allow npm/pnpm/yarn install", - description: "Permits npm install, pnpm install, and yarn install commands", + description: "Permits npm install, pnpm install, and yarn install commands, optionally piped through safe filters", handler: async (call) => { - const tokens = await safeBashCommand(call); + const tokens = await safeBashCommandOrPipeline(call); if (!tokens) return next(); const cmd = tokens[0]; diff --git a/policies/allow-npx-safe.ts b/policies/allow-npx-safe.ts index f9507a4..949f4f3 100644 --- a/policies/allow-npx-safe.ts +++ b/policies/allow-npx-safe.ts @@ -5,6 +5,8 @@ import { safeBashCommand, safeBashCommandOrPipeline, getAndChainSegments, getArg const SAFE_NPX_PACKAGES = new Set([ "next", "playwright", + "tsc", + "typescript", "vitest", ]); diff --git a/policies/allow-pnpm-package-script.ts b/policies/allow-pnpm-package-script.ts new file mode 100644 index 0000000..a3a8c86 --- /dev/null +++ b/policies/allow-pnpm-package-script.ts @@ -0,0 +1,40 @@ +import { dirname } from "node:path"; +import { allow, next, type Policy } from "../src"; +import { safeBashCommandOrPipeline } from "./parse-bash-ast"; + +async function findPackageScripts(startDir: string): Promise | null> { + let dir = startDir; + while (true) { + const pkgPath = `${dir}/package.json`; + const file = Bun.file(pkgPath); + if (await file.exists()) { + try { + const pkg = await file.json(); + return new Set(Object.keys(pkg.scripts ?? {})); + } catch { + return null; + } + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +const allowPnpmPackageScript: Policy = { + name: "Allow pnpm run for package.json scripts", + description: "Permits pnpm run