import { isAbsolute, relative, resolve, sep } from "node:path"; import picomatch from "picomatch"; import type { PermissionPolicy } from "@magiccode/core"; import type { PermissionRules } from "./settings"; interface Rule { tool: string; spec?: string; raw: string; } const RULE_RE = /^([A-Za-z0-9_.-]+)(?:\((.*)\))?$/s; const PATH_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "Grep"]); /** Substitutions de commandes, redirections : une règle allow ne peut pas les couvrir de façon sûre. */ const UNSAFE_SHELL = /\$\(|`|<\(|>/; function parseRules(raws: string[]): Rule[] { const rules: Rule[] = []; for (const raw of raws) { const m = RULE_RE.exec(raw.trim()); if (!m) continue; const spec = m[2]?.trim(); rules.push({ tool: m[1]!, spec: spec || undefined, raw }); } return rules; } // ----- Bash : la spécification est un préfixe ("npm run test:*"), un motif ("git * --help") ou une commande exacte function splitCommands(cmd: string): string[] { return cmd .split(/&&|\|\||;|\||\n/) .map((s) => s.trim()) .filter(Boolean); } function matchBashSpec(spec: string, sub: string): boolean { if (spec.endsWith(":*")) return sub.startsWith(spec.slice(0, -2)); if (spec.includes("*")) { const source = spec .split("*") .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) .join(".*"); return new RegExp(`^${source}$`).test(sub); } return sub === spec; } // ----- Read / Write / Edit / Glob / Grep : la spécification est un motif de chemin relatif au dossier courant function targetPath(input: Record, cwd: string): string { const raw = typeof input.path === "string" && input.path ? input.path : "."; const abs = resolve(cwd, raw); const rel = relative(cwd, abs); const out = rel === "" ? "." : rel.startsWith("..") || isAbsolute(rel) ? abs : rel; return out.split(sep).join("/"); } function matchPathSpec(spec: string, target: string): boolean { const s = spec.replace(/^\.\//, "").replace(/\/+$/, ""); return picomatch([s, `${s}/**`], { dot: true })(target); } // ----- WebFetch : la spécification est "domain:exemple.com" (nom d'hôte exact, ou motif avec *) function hostOf(input: Record): string | undefined { try { return new URL(String(input.url)).hostname.toLowerCase(); } catch { return undefined; } } function matchDomainSpec(spec: string, host: string): boolean { const m = /^domain:(.+)$/i.exec(spec.trim()); if (!m) return false; const pattern = m[1]!.trim().toLowerCase(); if (!pattern.includes("*")) return host === pattern; const source = pattern .split("*") .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) .join(".*"); return new RegExp(`^${source}$`).test(host); } // ----- Évaluation /** Pour deny/ask : la règle s'applique si une partie de la commande (Bash) ou le chemin correspond. */ function ruleApplies(rule: Rule, tool: string, input: Record, cwd: string): boolean { if (rule.tool !== tool) return false; if (!rule.spec) return true; if (tool === "Bash") { const cmd = typeof input.command === "string" ? input.command : ""; return splitCommands(cmd).some((sub) => matchBashSpec(rule.spec!, sub)); } if (PATH_TOOLS.has(tool)) return matchPathSpec(rule.spec, targetPath(input, cwd)); if (tool === "WebFetch") { const host = hostOf(input); return host !== undefined && matchDomainSpec(rule.spec, host); } return false; } /** Pour allow : TOUTES les parties d'une commande Bash doivent être couvertes (et sans redirection ni substitution). */ function allowApplies(rules: Rule[], tool: string, input: Record, cwd: string): boolean { const mine = rules.filter((r) => r.tool === tool); if (mine.length === 0) return false; if (mine.some((r) => !r.spec)) return true; // ex. "Edit" : tous les usages de l'outil if (tool === "Bash") { const cmd = typeof input.command === "string" ? input.command : ""; if (UNSAFE_SHELL.test(cmd)) return false; const subs = splitCommands(cmd); return subs.length > 0 && subs.every((sub) => mine.some((r) => matchBashSpec(r.spec!, sub))); } if (PATH_TOOLS.has(tool)) { const target = targetPath(input, cwd); return mine.some((r) => matchPathSpec(r.spec!, target)); } if (tool === "WebFetch") { const host = hostOf(input); return host !== undefined && mine.some((r) => matchDomainSpec(r.spec!, host)); } return false; } /** * Règles au format Claude Code : "Bash(npm run test:*)", "Edit(src/**)", "Read(./.env)", "Write". * Priorité : deny > ask > allow ; sans règle applicable → null (permission par défaut de l'outil). */ export function createPermissionPolicy(rules: PermissionRules): PermissionPolicy | null { const deny = parseRules(rules.deny); const ask = parseRules(rules.ask); const allow = parseRules(rules.allow); if (deny.length + ask.length + allow.length === 0) return null; return { check(tool, input, { cwd }) { const d = deny.find((r) => ruleApplies(r, tool, input, cwd)); if (d) return { decision: "deny", reason: `règle deny ${d.raw}` }; const a = ask.find((r) => ruleApplies(r, tool, input, cwd)); if (a) return { decision: "ask", reason: `règle ask ${a.raw}` }; if (allowApplies(allow, tool, input, cwd)) return { decision: "allow" }; return null; }, }; }