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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 07:16:26 +00:00
|
|
|
// ----- 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));
|
2026-10-12 07:16:26 +00:00
|
|
|
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));
|
|
|
|
|
}
|
2026-10-12 07:16:26 +00:00
|
|
|
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;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|