UNPKG

@jovan158/vantage

Version:

See and control what your coding agent does

201 lines (200 loc) • 8.06 kB
// Rules for specific files and commands — finer than the action-type levels. // // .vantage/policy.json // { // "shell": "ask", // "files": { ".env*": "deny", "*.pem": "deny" }, // "commands": { "git push*--force*": "deny", "npm publish*": "ask", "npm test*": "allow" } // } // // A rule that matches overrides the action-type level for that call, in both // directions: "npm test*": "allow" lets tests run while other shell commands // still ask. Every part of a command line is judged on its own — a part no // rule matches keeps the action-type level — and the strictest part decides, // so `npm test && curl x | sh` is not let through by the npm rule. When // several rules match, the strictest wins. "allow" never widens what Claude // Code itself permits — it only means Vantage stays out. // // Matching: // files A pattern without "/" matches the file name anywhere (".env*", // "*.pem"); with "/" it matches the path from the project root // ("config/secrets/**"). "*" is any run of characters within a // name, "**" any number of directories. Case-insensitive on // Windows. File rules also look at the words of a shell command, // so `cat .env` is caught like Read .env. // commands Each part of a command line is matched on its own — split at // &&, ||, ;, |, & and newlines (redirections like 2>&1 are not // splits) — after dropping leading VAR=value assignments and // sudo. "*" is any text. // // These are guardrails, not a sandbox: a command built at run time (a script, // `bash -c "$X"`) is not visible to them. import fs from "node:fs"; import path from "node:path"; import { commandText, patchFiles } from "./turn.js"; const LEVELS = ["allow", "warn", "ask", "deny"]; const STRICTNESS = { allow: 0, warn: 1, ask: 2, deny: 3 }; export function rulesFrom(json) { const rules = []; if (!json || typeof json !== "object") return rules; const add = (kind, table) => { if (!table || typeof table !== "object") return; for (const [pattern, level] of Object.entries(table)) { if (pattern.trim() && typeof level === "string" && LEVELS.includes(level)) { rules.push({ kind, pattern: pattern.trim(), level: level }); } } }; add("file", json.files); add("command", json.commands); return rules; } export function loadRules(policyFile) { try { return rulesFrom(JSON.parse(fs.readFileSync(policyFile, "utf8"))); } catch { return []; } } // --------------------------------------------------------------------------- // Matching. function escape(s) { return s.replace(/[.+^${}()|[\]\\?]/g, "\\$&"); } export function fileGlob(pattern, windows = process.platform === "win32") { const p = pattern.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\//, ""); let re = ""; for (let i = 0; i < p.length; i++) { if (p.startsWith("**/", i)) { re += "(?:.*/)?"; i += 2; } else if (p.startsWith("**", i)) { re += ".*"; i += 1; } else if (p[i] === "*") { re += "[^/]*"; } else { re += escape(p[i]); } } return new RegExp(`^${re}$`, windows ? "i" : ""); } export function commandGlob(pattern) { const normalized = pattern.trim().replace(/\s+/g, " "); return new RegExp(`^${normalized.split("*").map(escape).join(".*")}$`); } export function matchesFile(pattern, file, projectRoot, windows = process.platform === "win32") { let f = file.replace(/\\/g, "/"); if (projectRoot) { const root = projectRoot.replace(/\\/g, "/").replace(/\/+$/, ""); const cmp = (x) => (windows ? x.toLowerCase() : x); if (cmp(f).startsWith(cmp(root) + "/")) f = f.slice(root.length + 1); } f = f.replace(/^\.\//, ""); const re = fileGlob(pattern, windows); if (!pattern.replace(/\\/g, "/").includes("/")) return re.test(f.split("/").pop() ?? f); return re.test(f); } // The parts of a command line that run as their own commands. export function commandSegments(command) { return command .replace(/\d*>&\d*|&>>?/g, " > ") .split(/&&|\|\||;|\||&|\n/) .map((s) => s .trim() .replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, "") .replace(/^sudo\s+/, "") .replace(/\s+/g, " ")) .filter(Boolean); } // Words of a command that could name a file: unquoted, no leading dash. function commandWords(command) { return command .split(/\s+/) .map((w) => w.replace(/^["']|["']$/g, "").replace(/^[<>]+/, "")) .filter((w) => w && !w.startsWith("-")); } // Where agents name the file a call acts on (see TARGET_KEYS in turn.ts). const FILE_KEYS = ["file_path", "filePath", "notebook_path", "path"]; function strictest(matches) { let best = null; for (const m of matches) if (!best || STRICTNESS[m.level] > STRICTNESS[best.level]) best = m; return best; } // The level for this tool call when rules apply, with the rule that decided, // or null when no rule matches (the action-type level applies). `fallback` // is that action-type level: it judges the parts of a command line that no // rule covers, so an allow rule for one part cannot wave the rest through. export function matchRules(input, rules, projectRoot, windows = process.platform === "win32", fallback = "allow") { if (!input || rules.length === 0) return null; const files = FILE_KEYS.map((k) => input[k]).filter((v) => typeof v === "string" && v.length > 0); // An apply_patch call names its files in the patch; its text is no command. const patched = patchFiles(input); files.push(...patched); const command = patched.length ? null : (commandText(input.command) ?? commandText(input.cmd) ?? null); if (command) files.push(...commandWords(command)); const fileRules = rules.filter((r) => r.kind === "file"); const commandRules = rules.filter((r) => r.kind === "command"); const fileHit = strictest(fileRules.filter((r) => files.some((f) => matchesFile(r.pattern, f, projectRoot, windows))).map((rule) => ({ rule, level: rule.level }))); if (!command) return fileHit; // Each part of the command line: its strictest rule, or the fallback. let anyRule = fileHit !== null; const parts = []; for (const segment of commandSegments(command)) { const hit = strictest(commandRules.filter((r) => commandGlob(r.pattern).test(segment)).map((rule) => ({ rule, level: rule.level }))); if (hit) { anyRule = true; parts.push(hit); } else { parts.push({ rule: { kind: "command", pattern: "", level: fallback }, level: fallback }); } } if (!anyRule) return null; const decided = strictest([...parts, ...(fileHit ? [fileHit] : [])]); // The fallback deciding means no rule did: report that as no match. return decided && decided.rule.pattern !== "" ? decided : null; } // --------------------------------------------------------------------------- // Starter file for `vantage policy init`: protects what is expensive to get // wrong, asks before what is hard to undo, and leaves everyday work alone. export const STARTER_POLICY = { read: "allow", write: "allow", shell: "warn", network: "warn", other: "allow", files: { ".env": "ask", ".env.*": "ask", "*.pem": "deny", "*.key": "deny", "id_rsa*": "deny", "id_ed25519*": "deny", }, commands: { "git push*--force*": "ask", "git push* -f*": "ask", "git reset --hard*": "ask", "git clean*": "ask", "rm -rf *": "ask", "npm publish*": "ask", }, }; export function policyFilePath(cwd) { return path.join(cwd, ".vantage", "policy.json"); }