2026-10-11 00:11:20 +00:00
|
|
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
|
|
|
import picomatch from "picomatch";
|
2026-10-14 23:12:15 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
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-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 {
|
2026-10-20 00:06:51 +00:00
|
|
|
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));
|
2026-10-12 07:16:26 +00:00
|
|
|
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));
|
|
|
|
|
}
|
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-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
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|