MagicCode/packages/agent/src/permissions.ts

292 lines
12 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-21 23:54:32 +00:00
import { mcpSpecMatches } from "./mcp-rules";
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"]);
2026-10-21 23:54:32 +00:00
/** Substitutions de commandes et de processus : une règle allow ne peut pas les couvrir de façon sûre. */
const UNSAFE_SHELL = /\$\(|`|<\(|>\(/;
// ----- Redirections de sortie (>, >>, >|, &>, 2>…) : règles `Redirect(motif de chemin)`
export interface Redirections {
/** Commande sans ses redirections (chaque redirection est remplacée par un espace). */
stripped: string;
/** Cibles (fichiers) des redirections de sortie, sans guillemets. Les duplications `2>&1`, `>&2` n'en ont pas. */
targets: string[];
/** true si l'analyse n'est pas sûre (guillemet non fermé, cible absente ou `>(`) : rien n'est autorisé automatiquement. */
unknown: boolean;
}
/**
* Repère les redirections de sortie de la commande en respectant les guillemets simples et doubles et les `\`
* (un `>` entre guillemets n'est pas une redirection). `2>&1` / `>&2` (duplication d'un descripteur) ne désignent aucun fichier.
*/
export function extractRedirections(cmd: string): Redirections {
let stripped = "";
const targets: string[] = [];
let unknown = false;
const n = cmd.length;
let i = 0;
while (i < n) {
const ch = cmd[i]!;
if (ch === "\\") {
stripped += ch + (cmd[i + 1] ?? "");
i += 2;
} else if (ch === "'") {
const j = cmd.indexOf("'", i + 1);
if (j === -1) return { stripped: stripped + cmd.slice(i), targets, unknown: true };
stripped += cmd.slice(i, j + 1);
i = j + 1;
} else if (ch === '"') {
let j = i + 1;
while (j < n && cmd[j] !== '"') j += cmd[j] === "\\" ? 2 : 1;
if (j >= n) return { stripped: stripped + cmd.slice(i), targets, unknown: true };
stripped += cmd.slice(i, j + 1);
i = j + 1;
} else if (ch === ">") {
// Préfixe du descripteur : « 2 » ou « & » (isolés, précédés d'un espace)
const m = /(?:^|\s)(\d+|&)$/.exec(stripped);
if (m) stripped = stripped.slice(0, stripped.length - m[1]!.length);
i++;
if (cmd[i] === ">" || cmd[i] === "|") i++; // >> et >|
if (cmd[i] === "&") {
const dup = /^&(\d+|-)/.exec(cmd.slice(i));
if (dup) {
i += dup[0].length; // 2>&1, >&2, >&- : pas de fichier
stripped += " ";
continue;
}
i++; // >&fichier : sortie et erreur vers le fichier
}
while (i < n && (cmd[i] === " " || cmd[i] === "\t")) i++;
if (cmd[i] === "(") {
unknown = true;
break;
}
let target = "";
let quoted = false;
while (i < n && !/[\s;|&<>()]/.test(cmd[i]!)) {
const c = cmd[i]!;
if (c === "\\") {
target += cmd[i + 1] ?? "";
i += 2;
} else if (c === "'" || c === '"') {
const j = cmd.indexOf(c, i + 1);
if (j === -1) return { stripped, targets, unknown: true };
target += cmd.slice(i + 1, j);
quoted = true;
i = j + 1;
} else {
target += c;
i++;
}
}
if (target === "" && !quoted) unknown = true;
else targets.push(target);
stripped += " ";
} else {
stripped += ch;
i++;
}
}
return { stripped, targets, unknown };
}
/** Cible dont le chemin réel dépend du shell (variable, glob, accolades) : jamais couverte par un motif de chemin. */
const isDynamicTarget = (target: string): boolean => /[$`*?[\]{}]/.test(target);
const redirectRuleMatches = (rule: Rule, target: string, cwd: string): boolean =>
!rule.spec || matchPathSpec(rule.spec, targetPath({ path: target }, cwd));
2026-10-11 00:11:20 +00:00
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 {
2026-10-21 23:54:32 +00:00
// Outils MCP (`mcp__serveur__outil`) : règles `Mcp(serveur[,outil])`
if (rule.tool === "Mcp") return mcpSpecMatches(rule.spec, tool);
2026-10-11 00:11:20 +00:00
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
}
2026-10-21 23:54:32 +00:00
/** Une redirection vers ce fichier est autorisée par une règle `Redirect` (ou `Redirect(motif)`). */
function redirectAllowed(target: string, allow: Rule[], cwd: string): boolean {
return allow.some((r) => !r.spec || (!isDynamicTarget(target) && redirectRuleMatches(r, target, cwd)));
}
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). */
2026-10-21 23:54:32 +00:00
function allowApplies(
rules: Rule[],
tool: string,
input: Record<string, unknown>,
cwd: string,
redirectAllow: Rule[] = [],
): boolean {
if (rules.some((r) => r.tool === "Mcp" && mcpSpecMatches(r.spec, tool))) return true;
2026-10-11 00:11:20 +00:00
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 : "";
2026-10-21 23:54:32 +00:00
const redir = extractRedirections(cmd);
if (redir.unknown || UNSAFE_SHELL.test(cmd)) return false;
const subs = splitCommands(redir.stripped);
return (
subs.length > 0 &&
subs.every((sub) => mine.some((r) => matchBashSpec(r.spec!, sub))) &&
redir.targets.every((t) => redirectAllowed(t, redirectAllow, cwd))
);
2026-10-11 00:11:20 +00:00
}
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);
2026-10-21 23:54:32 +00:00
const redirectOf = (list: Rule[]): Rule[] => list.filter((r) => r.tool === "Redirect");
const redirectDeny = redirectOf(deny);
const redirectAsk = redirectOf(ask);
const redirectAllow = redirectOf(allow);
/** Règle Redirect (deny ou ask) qui vise un fichier de redirection de la commande Bash. */
const redirectHit = (list: Rule[], tool: string, input: Record<string, unknown>, cwd: string): Rule | undefined => {
if (tool !== "Bash" || list.length === 0) return undefined;
const { targets } = extractRedirections(typeof input.command === "string" ? input.command : "");
return list.find((r) => targets.some((t) => redirectRuleMatches(r, t, cwd)));
};
2026-10-11 00:11:20 +00:00
return {
check(tool, input, { cwd }) {
2026-10-21 23:54:32 +00:00
const d = deny.find((r) => r.tool !== "Redirect" && ruleApplies(r, tool, input, cwd)) ?? redirectHit(redirectDeny, tool, input, cwd);
2026-10-11 00:11:20 +00:00
if (d) return { decision: "deny", reason: `règle deny ${d.raw}` };
2026-10-21 23:54:32 +00:00
const a = ask.find((r) => r.tool !== "Redirect" && ruleApplies(r, tool, input, cwd)) ?? redirectHit(redirectAsk, tool, input, cwd);
2026-10-11 00:11:20 +00:00
if (a) return { decision: "ask", reason: `règle ask ${a.raw}` };
2026-10-21 23:54:32 +00:00
if (allowApplies(allow, tool, input, cwd, redirectAllow)) 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
},
};
}