import { isAbsolute, relative, resolve, sep } from "node:path"; import picomatch from "picomatch"; import type { PermissionPolicy } from "@maia/core"; import { mcpSpecMatches } from "./mcp-rules"; 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 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)); 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); } /** * 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. */ function matchBashSpec(spec: string, sub: string): boolean { if (spec.endsWith(":*")) return sub.startsWith(spec.slice(0, -2)); 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); } // ----- 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); } // ----- 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): boolean { const values = tool === "Skill" ? [input.skill] : tool === "Agent" ? [input.agent] : Object.values(input); const re = new RegExp(`^${spec.split("*").map(escapeRe).join(".*")}$`); return values.some((v) => typeof v === "string" && re.test(v)); } // ----- É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 { // Outils MCP (`mcp__serveur__outil`) : règles `Mcp(serveur[,outil])` if (rule.tool === "Mcp") return mcpSpecMatches(rule.spec, tool); if (rule.tool !== tool) return false; if (!rule.spec) return true; if (tool === "Bash") { const cmd = typeof input.command === "string" ? input.command : ""; return splitForRefusal(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 matchGenericSpec(tool, rule.spec, input); } /** 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))); } /** 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, redirectAllow: Rule[] = [], ): boolean { if (rules.some((r) => r.tool === "Mcp" && mcpSpecMatches(r.spec, tool))) return true; 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 : ""; 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)) ); } 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 mine.some((r) => matchGenericSpec(tool, r.spec!, input)); } /** * 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. */ export function createPermissionPolicy(rules: PermissionRules): PermissionPolicy { const deny = parseRules(rules.deny); const ask = parseRules(rules.ask); const allow = parseRules(rules.allow); 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, 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))); }; return { check(tool, input, { cwd }) { const d = deny.find((r) => r.tool !== "Redirect" && ruleApplies(r, tool, input, cwd)) ?? redirectHit(redirectDeny, tool, input, cwd); if (d) return { decision: "deny", reason: `règle deny ${d.raw}` }; const a = ask.find((r) => r.tool !== "Redirect" && ruleApplies(r, tool, input, cwd)) ?? redirectHit(redirectAsk, tool, input, cwd); if (a) return { decision: "ask", reason: `règle ask ${a.raw}` }; if (allowApplies(allow, tool, input, cwd, redirectAllow)) return { decision: "allow" }; return { decision: "ask", reason: "aucune règle : confirmation demandée par défaut" }; }, }; }