MagicCode/packages/ext-claudecode/src/permissions.ts

147 lines
5.3 KiB
TypeScript
Raw Normal View History

2026-10-11 00:11:20 +00:00
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<string, unknown>, 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, unknown>): 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);
}
2026-10-11 00:11:20 +00:00
// ----- É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<string, unknown>, 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);
}
2026-10-11 00:11:20 +00:00
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<string, unknown>, 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));
}
2026-10-11 00:11:20 +00:00
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;
},
};
}