MagicCode/packages/agent/src/permissions.ts

168 lines
6.8 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 "@maia/core";
2026-10-11 00:11:20 +00:00
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);
}
2026-10-15 21:32:32 +00:00
/**
* Pour deny/ask : découpe plus fine (substitutions `$(…)`, backticks, parenthèses, redirections), afin qu'une
* commande interdite cachée dans `echo $(commande)` ou `(commande)` soit aussi reconnue.
*/
function splitForRefusal(cmd: string): string[] {
return [...splitCommands(cmd), ...cmd.split(/&&|\|\||;|\||\n|\$\(|`|<\(|\(|\)|>>?/).map((s) => s.trim()).filter(Boolean)];
}
const escapeRe = (text: string): string => text.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
/**
* Motif de commande :
* - `cmd arg` : commande exacte ;
* - `cmd *` : `cmd` avec ou sans arguments (`cmd`, `cmd x y`, mais pas `cmdx`) ;
* - `*` ailleurs : n'importe quelle suite de caractères ; `cmd:*` (ancienne syntaxe) : préfixe.
*/
2026-10-11 00:11:20 +00:00
function matchBashSpec(spec: string, sub: string): boolean {
if (spec.endsWith(":*")) return sub.startsWith(spec.slice(0, -2));
2026-10-15 21:32:32 +00:00
const withArgs = spec.endsWith(" *");
const base = withArgs ? spec.slice(0, -2) : spec;
const source = base.split("*").map(escapeRe).join(".*");
return new RegExp(`^${source}${withArgs ? "( .*)?" : ""}$`).test(sub);
2026-10-11 00:11:20 +00:00
}
// ----- 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-15 21:32:32 +00:00
// ----- Autres outils (Skill, outils ajoutés plus tard…) : la spécification est un motif (`*`) comparé à la valeur de l'entrée
function matchGenericSpec(tool: string, spec: string, input: Record<string, unknown>): boolean {
const values = tool === "Skill" ? [input.skill] : tool === "Agent" ? [input.agent] : Object.values(input);
2026-10-15 21:32:32 +00:00
const re = new RegExp(`^${spec.split("*").map(escapeRe).join(".*")}$`);
return values.some((v) => typeof v === "string" && re.test(v));
}
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 : "";
2026-10-15 21:32:32 +00:00
return splitForRefusal(cmd).some((sub) => matchBashSpec(rule.spec!, sub));
2026-10-11 00:11:20 +00:00
}
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-15 21:32:32 +00:00
return matchGenericSpec(tool, rule.spec, input);
2026-10-11 00:11:20 +00:00
}
/** 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-15 21:32:32 +00:00
return mine.some((r) => matchGenericSpec(tool, r.spec!, input));
2026-10-11 00:11:20 +00:00
}
/**
2026-10-15 21:32:32 +00:00
* Politique de permissions. Règles `Outil` (tous les appels) ou `Outil(motif)`, évaluées dans l'ordre
* deny, puis ask, puis allow : la première famille qui correspond décide, quelle que soit la précision des règles
* (un allow plus précis ne rend jamais une exception à un deny ou à un ask plus large).
* Aucune règle applicable → ask (confirmation de l'utilisateur), pour tous les outils.
2026-10-11 00:11:20 +00:00
*/
2026-10-15 21:32:32 +00:00
export function createPermissionPolicy(rules: PermissionRules): PermissionPolicy {
2026-10-11 00:11:20 +00:00
const deny = parseRules(rules.deny);
const ask = parseRules(rules.ask);
const allow = parseRules(rules.allow);
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" };
2026-10-15 21:32:32 +00:00
return { decision: "ask", reason: "aucune règle : confirmation demandée par défaut" };
2026-10-11 00:11:20 +00:00
},
};
}