import { isAbsolute, relative, resolve, sep } from "node:path"; import picomatch from "picomatch"; import type { PermissionPolicy } from "@maia/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); } /** * 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 { 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); } /** 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): 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)); } 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); 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 { decision: "ask", reason: "aucune règle : confirmation demandée par défaut" }; }, }; }