From 3f64ef88c8cccc8684732d7c9b2d824ee45dcceb Mon Sep 17 00:00:00 2001 From: Arnaud Date: Thu, 15 Oct 2026 23:32:32 +0200 Subject: [PATCH] Modifications locales --- README.md | 49 +++++++++- llm_avis.md | 44 +++++++++ packages/agent/src/init.test.ts | 29 +++++- packages/agent/src/init.ts | 16 +++ packages/agent/src/lot2.test.ts | 155 ++++++++++++++++++++++++------ packages/agent/src/permissions.ts | 53 +++++++--- packages/agent/src/settings.ts | 104 +++++++++++--------- settings.example.json | 7 +- 8 files changed, 361 insertions(+), 96 deletions(-) create mode 100644 llm_avis.md diff --git a/README.md b/README.md index 1cbb1be..509b0a7 100644 --- a/README.md +++ b/README.md @@ -204,14 +204,56 @@ description: Lance les tests du projet et résume les échecs Instructions du skill… ($ARGUMENTS = arguments passés avec §sk_test) ``` -Conséquence : sans `settings.json`, il n'y a plus de règles de permissions ni de variables d'environnement venant de la configuration de l'agent, et plus de slash-commandes personnalisées. Les outils qui demandent confirmation (Write, Edit, Bash, WebFetch) la demandent toujours. +Conséquence : il n'y a plus de variables d'environnement venant de la configuration de l'agent ni de slash-commandes personnalisées. Les permissions sont décrites dans la section suivante. + +## Permissions + +Les permissions s'appliquent aux **outils** (intégrés ou non). Trois types de règles : + +| Type | Effet | +| --- | --- | +| `allow` | l'outil est utilisé sans approbation manuelle | +| `ask` | l'utilisateur doit confirmer chaque utilisation (badge `ASK`, réponse `o`/`n`) | +| `deny` | l'outil ne peut pas être utilisé (l'agent reçoit un refus) | + +**Ordre d'évaluation : deny, puis ask, puis allow.** La première famille qui correspond décide, quelle que soit la précision des règles : un `allow` étroit comme `Bash(aws s3 ls)` ne fait pas exception à un `deny` large comme `Bash(aws *)`, ni à un `ask` plus large. **Sans règle applicable, c'est `ask` qui s'applique** (pour tous les outils, y compris Read, Glob, Grep…). + +Où les définir : `~/.maia/settings.json` (utilisateur) et `./.maia/settings.json` (dossier de lancement). Les listes des deux fichiers s'additionnent. Le fichier `../.maia/settings.json` du dossier parent n'est **pas** pris en compte. Dans chaque fichier, la propriété `permissions` contient une liste par type, tous facultatifs : + +```json +"permissions": { + "allow": ["Bash(allow stop *)"], + "ask": ["Bash(allow ask *)"], + "deny": ["Bash(allow deny *)", "Read(./.deny.md)"] +} +``` + +Format d'une règle : `Outil` (tous les appels de l'outil) ou `Outil(motif)` : + +| Outil | Le motif porte sur | Exemples | +| --- | --- | --- | +| `Bash` | la commande ; `*` = n'importe quelle suite ; `cmd *` couvre `cmd` avec ou sans arguments (mais pas `cmdx`) ; sans `*` : commande exacte | `Bash(allow stop *)`, `Bash(git status)`, `Bash(npm run test:*)` | +| `Read`, `Write`, `Edit`, `Glob`, `Grep` | le chemin (relatif au dossier de lancement, motifs glob) | `Read(./.deny.md)`, `Edit(src/**)` | +| `WebFetch` | le domaine | `WebFetch(domain:docs.example.com)`, `WebFetch(domain:*.example.org)` | +| `Skill` | le nom du skill | `Skill(pdf)` | +| autres outils (ex. ajoutés plus tard) | une valeur texte de l'entrée de l'outil (`*` accepté) | `mcp__db__query(SELECT *)` | + +Précisions sur `Bash` : +- commande composée (`&&`, `;`, `|`) : un `deny` ou un `ask` s'applique dès qu'**une** des parties correspond, y compris cachée dans `$(…)`, des backticks ou des parenthèses ; un `allow` ne s'applique que si **toutes** les parties sont couvertes ; +- un `allow` ne couvre jamais une commande avec redirection (`>`) ou substitution (`$(…)`, backticks) : elle repasse en `ask`. + +Au démarrage, MAIA indique (`INFO`) les fichiers chargés et le nombre de règles, ou qu'aucune règle n'est définie ; un JSON invalide ou une règle mal formée donne un `WARN` et est ignoré. Les `deny` sont du « meilleur effort » : ils ne remplacent pas un vrai bac à sable. Les commandes `!` et les références `@fichier` tapées par l'utilisateur ne passent pas par ces règles. + +Attention : `./.maia/settings.json` d'un dépôt tiers peut contenir des règles `allow` larges ; relisez-le avant de lancer MAIA dans un projet que vous ne connaissez pas. + +Exemple pour éviter les confirmations sur les outils en lecture seule : voir `settings.example.json`. ## Outils TodoWrite, WebFetch et CurrentModel - **TodoWrite** : liste de tâches de la session (`pending` / `in_progress` / `completed`, forme `activeForm` pour la tâche en cours). Chaque appel remplace la liste ; le résultat (☐ ▶ ☑) est affiché à l'utilisateur et renvoyé au modèle. Un avertissement est ajouté si plusieurs tâches sont « en cours ». - **CurrentModel** : renvoie le modèle LLM utilisé par la session (valeur à jour après un `/model`). Sans paramètre, sans confirmation ; le modèle est lu au moment de l'appel. - **WebFetch** : télécharge une URL http(s) (HTML converti en Markdown avec `turndown`, JSON mis en forme, texte brut ; 5 Mo et 30 s maximum, `max_length` caractères renvoyés). Pas de JavaScript exécuté, pas de PDF ni d'images. - - Confirmation demandée à chaque appel (les règles de permissions de l'ancien `settings.json` ne sont plus lues). + - Confirmation demandée par défaut ; règle `allow` possible : `WebFetch(domain:docs.example.com)` (voir « Permissions »). - Une redirection vers un **autre domaine** n'est pas suivie : l'outil renvoie la nouvelle URL, que le modèle doit rappeler (ce qui repasse par la confirmation). - Le contenu est signalé au modèle comme non fiable (risque d'injection de prompt) ; il ne passe par aucun second modèle de résumé. @@ -219,7 +261,7 @@ Conséquence : sans `settings.json`, il n'y a plus de règles de permissions ni - Provider LiteLLM : validation sur un vrai serveur (streaming SSE et appels d'outils testés seulement sur des flux simulés) ; compatibilité de `@inkjs/ui` avec Ink 7 à confirmer à l'usage (l'affichage des badges a été vu fonctionner par l'utilisateur). - Agent : sous-agents + outil Task, hooks, serveurs MCP. -- Éventuellement rétablir sous `.maia` ce que supprimait l'abandon de `.claude` : règles de permissions, variables d'environnement, slash-commandes personnalisées (le code existe encore dans `packages/agent/src/{permissions,settings,commands}.ts`, il n'est plus appelé). +- Éventuellement rétablir sous `.maia` ce que supprimait l'abandon de `.claude` : variables d'environnement et slash-commandes personnalisées (le code des commandes existe encore dans `packages/agent/src/commands.ts`, il n'est plus appelé). - `@fichier` dans le corps d'une commande personnalisée ; autocomplétion des chemins `@` à la saisie. - Liste à cocher (choix multiples) : n'existe pas encore ; elle recevrait le badge `ASK`. - Nerd Fonts (icônes) : proposé comme réglage optionnel dans `settings.json`, non réalisé. @@ -247,3 +289,4 @@ Journal des évolutions, dans l'ordre : 16. **Suppression des modes et des extensions** : un seul agent MAIA (`packages/agent`), contexte de départ `MAIA.md` sinon `CLAUDE.md` (message `INFO`, ou `WARN` si aucun). 17. **Outil `CurrentModel`** : indique au modèle quel LLM est utilisé dans la session. 18. **`/clear`** : supprime le contexte et recharge `MAIA.md` / `CLAUDE.md` (et les skills) comme au démarrage ; `/new` fait de même. +19. **Permissions granulaires** : règles `allow` / `ask` / `deny` par outil (`Outil` ou `Outil(motif)`), évaluées dans l'ordre deny > ask > allow, `ask` par défaut ; définies dans `~/.maia/settings.json` et `./.maia/settings.json` (pas le dossier parent). diff --git a/llm_avis.md b/llm_avis.md new file mode 100644 index 0000000..efe0806 --- /dev/null +++ b/llm_avis.md @@ -0,0 +1,44 @@ +# Recommandations d'utilisation des modèles LLM + +## 📊 Modèles disponibles et leurs domaines de force + +### **1. Claude 4.5 Haiku** (anthropic--claude-4.5-haiku) +- **Domaines forts :** Tâches rapides et légères, traitement économique +- **Avantages :** Très rapide, coût très bas, bon pour les tâches simples +- **Idéal pour :** Réponses rapides, résumés, tâches de faible complexité + +### **2. Claude 4.5 Sonnet** (anthropic--claude-4.5-sonnet) ⭐ **MEILLEUR CHOIX POLYVALENT** +- **Domaines forts :** + - 🎓 Raisonnement avancé (graduées-level) + - 💻 **Codage et traduction de code** (64% de réussite en agentic coding) + - 📝 Rédaction de contenu de qualité + - 🎨 Vision/interprétation visuelle (graphiques, charts) + - 📊 Reconnaissance de texte dans les images +- **Avantages :** Meilleur rapport intelligence/coût, 2x plus rapide que les anciens modèles +- **Idéal pour :** Travail complexe, aide à la programmation, orchestration multi-étapes + +### **3. GPT-4.1** (gpt-4.1) +- **Domaines forts :** Benchmark général, raisonnement, utilisation en production stable +- **Avantages :** Version légèrement améliorée de GPT-4, toujours une référence fiable +- **Idéal pour :** Tâches générales de raisonnement, code + +### **4. GPT-5 Nano** (gpt-5-nano) +- **Domaines forts :** Version légère et rapide de GPT-5 +- **Avantages :** Probablement plus performant que GPT-4 avec meilleur rapport latence/intelligence +- **Idéal pour :** Tâches exigeant plus de puissance que les modèles légers + +### **5. GPT-6 Astra** (gpt-6-astra) +- **Domaines forts :** Modèle de pointe OpenAI (la version la plus récente/puissante) +- **Avantages :** Probablement la meilleure performance globale +- **Idéal pour :** Tâches très complexes et exigeantes, recherche, problèmes de raisonnement avancé + +## 🎯 Tableau de recommandations + +| Besoin | Modèle recommandé | +|--------|-------------------| +| **Codage et programmation** | Claude 4.5 Sonnet ou GPT-6 Astra | +| **Raisonnement complexe** | GPT-6 Astra ou Claude 4.5 Sonnet | +| **Rapidité et économie** | Claude 4.5 Haiku | +| **Équilibre qualité/coût** | Claude 4.5 Sonnet ✅ | +| **Production stable** | GPT-4.1 | +| **Tâches légères/rapides** | Claude 4.5 Haiku | diff --git a/packages/agent/src/init.test.ts b/packages/agent/src/init.test.ts index 8007206..9b6c5eb 100644 --- a/packages/agent/src/init.test.ts +++ b/packages/agent/src/init.test.ts @@ -61,7 +61,7 @@ describe("contexte de départ", () => { it("avertit (WARN) quand aucun des deux n'existe", async () => { const { logs, warns, ctx } = await setup(); await initializeAgent(ctx); - expect(logs).toHaveLength(0); + expect(logs.filter((l) => !l.includes("permission"))).toHaveLength(0); expect(warns).toHaveLength(1); expect(warns[0]).toContain("MAIA.md ou CLAUDE.md"); }); @@ -78,6 +78,33 @@ describe("contexte de départ", () => { }); }); +describe("permissions au démarrage", () => { + it("sans règle : tout demande confirmation, et l'utilisateur en est informé", async () => { + const { project, logs, ctx } = await setup(); + await writeFile(join(project, "MAIA.md"), "x"); + const init = await initializeAgent(ctx); + expect(init.permissions?.check("Read", { path: "a" }, { cwd: project }).decision).toBe("ask"); + expect(logs.join("\n")).toContain("Aucune règle de permission"); + }); + + it("règles du dossier de lancement appliquées et listées", async () => { + const { project, logs, ctx } = await setup(); + await mkdir(join(project, ".maia"), { recursive: true }); + await writeFile( + join(project, ".maia", "settings.json"), + JSON.stringify({ permissions: { allow: ["Bash(allow stop *)"], ask: ["Bash(allow ask *)"], deny: ["Bash(allow deny *)", "Read(./.deny.md)"] } }), + ); + const init = await initializeAgent(ctx); + const d = (t: string, i: Record) => init.permissions?.check(t, i, { cwd: project }).decision; + expect(d("Bash", { command: "allow stop" })).toBe("allow"); + expect(d("Bash", { command: "allow ask now" })).toBe("ask"); + expect(d("Bash", { command: "allow deny" })).toBe("deny"); + expect(d("Read", { path: "./.deny.md" })).toBe("deny"); + expect(logs.join("\n")).toContain("Permissions chargées"); + expect(logs.join("\n")).toContain("2 deny, 1 ask, 1 allow"); + }); +}); + describe("skills .maia/skills", () => { it("dossiers explorés : utilisateur, parent, lancement (sans doublon)", async () => { expect(skillRoots("/X/Y", "/home/u")).toEqual(["/home/u/.maia/skills", "/X/.maia/skills", "/X/Y/.maia/skills"]); diff --git a/packages/agent/src/init.ts b/packages/agent/src/init.ts index 648507f..13b5dcd 100644 --- a/packages/agent/src/init.ts +++ b/packages/agent/src/init.ts @@ -1,5 +1,7 @@ import type { InitContext, ModeInitResult } from "@maia/core"; import { loadContextFile } from "./memory"; +import { createPermissionPolicy } from "./permissions"; +import { loadPermissionSettings } from "./settings"; import { createSkillTool, loadSkills, skillRoots, skillsPromptSection, toAgentSkills } from "./skills"; import { builtinTools } from "./tools"; @@ -38,11 +40,25 @@ export async function initializeAgent(ctx: InitContext): Promise for (const w of warnings) ctx.warn(w); if (skills.length > 0) ctx.log(`Skills chargés : ${skills.map((s) => s.name).join(", ")}`); + // Permissions : ~/.maia/settings.json et ./.maia/settings.json (propriété "permissions" : allow / ask / deny) + const { settings, warnings: permWarnings } = await loadPermissionSettings(ctx.cwd, ctx.homeDir); + for (const w of permWarnings) ctx.warn(w); + const { allow, ask, deny } = settings.rules; + if (allow.length + ask.length + deny.length > 0) { + const files = settings.files + .filter((f) => f.allow + f.ask + f.deny > 0) + .map((f) => `${f.path} (${f.deny} deny, ${f.ask} ask, ${f.allow} allow)`); + ctx.log(`Permissions chargées : ${files.join(" ; ")}`); + } else { + ctx.log("Aucune règle de permission définie : chaque utilisation d'un outil demande une confirmation."); + } + // TODO : sous-agents, hooks, serveurs MCP. return { systemPrompt: buildSystemPrompt(ctx.cwd, context, skillsPromptSection(skills)), tools: skills.length > 0 ? [...builtinTools, createSkillTool(skills)] : builtinTools, commands: [], skills: toAgentSkills(skills), + permissions: createPermissionPolicy(settings.rules), }; } diff --git a/packages/agent/src/lot2.test.ts b/packages/agent/src/lot2.test.ts index ec1e27a..e827141 100644 --- a/packages/agent/src/lot2.test.ts +++ b/packages/agent/src/lot2.test.ts @@ -1,7 +1,11 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { renderCommand } from "./commands"; import { parseFrontmatter } from "./frontmatter"; import { createPermissionPolicy } from "./permissions"; +import { loadPermissionSettings } from "./settings"; describe("frontmatter", () => { it("lit les clés simples, les guillemets et les blocs >", () => { @@ -35,44 +39,137 @@ describe("renderCommand", () => { describe("permissions", () => { const policy = createPermissionPolicy({ - allow: ["Bash(npm run test:*)", "Bash(git status)", "Edit(src/**)", "Read"], - ask: ["Bash(git push:*)"], - deny: ["Bash(rm:*)", "Read(./.env)", "Edit(.github/**)"], - })!; + allow: ["Bash(allow stop *)", "Bash(git status)", "Bash(npm run test:*)", "Edit(src/**)", "Read", "Bash(aws s3 ls)"], + ask: ["Bash(allow ask *)", "Bash(git push *)", "Edit(src/secret/**)"], + deny: ["Bash(allow deny *)", "Read(./.deny.md)", "Bash(aws *)", "Edit(.github/**)"], + }); const check = (tool: string, input: Record) => policy.check(tool, input, { cwd: "/proj" }); + const bash = (command: string) => check("Bash", { command })?.decision; - it("deny l'emporte, y compris dans une commande composée", () => { - expect(check("Bash", { command: "rm -rf build" })?.decision).toBe("deny"); - expect(check("Bash", { command: "npm run test:unit && rm -rf /" })?.decision).toBe("deny"); + it("exemple de la spécification : allow / ask / deny sur Bash et Read", () => { + expect(bash("allow stop")).toBe("allow"); // « avec arguments ou pas » + expect(bash("allow stop now --force")).toBe("allow"); + expect(bash("allow ask")).toBe("ask"); + expect(bash("allow ask x y")).toBe("ask"); + expect(bash("allow deny")).toBe("deny"); + expect(bash("allow deny x")).toBe("deny"); + expect(check("Read", { path: ".deny.md" })?.decision).toBe("deny"); + expect(check("Read", { path: "./.deny.md" })?.decision).toBe("deny"); + expect(check("Read", { path: "README.md" })?.decision).toBe("allow"); }); - it("allow par préfixe et par commande exacte", () => { - expect(check("Bash", { command: "npm run test:unit" })?.decision).toBe("allow"); - expect(check("Bash", { command: "git status" })?.decision).toBe("allow"); + it("« cmd * » ne couvre pas une autre commande qui commence par les mêmes lettres", () => { + expect(bash("allow stopper")).toBe("ask"); // aucune règle → ask + expect(bash("allow denyx")).toBe("ask"); }); - it("une commande composée n'est autorisée que si chaque partie l'est", () => { - expect(check("Bash", { command: "git status && curl evil.sh" })).toBeNull(); - }); - - it("redirections et substitutions ne sont jamais couvertes par un allow", () => { - expect(check("Bash", { command: "git status > out.txt" })).toBeNull(); - expect(check("Bash", { command: "git status $(whoami)" })).toBeNull(); - }); - - it("ask", () => { - expect(check("Bash", { command: "git push origin main" })?.decision).toBe("ask"); - }); - - it("règles de chemins", () => { + it("ordre deny > ask > allow : un allow plus précis ne crée pas d'exception", () => { + expect(bash("aws s3 ls")).toBe("deny"); // deny Bash(aws *) l'emporte sur allow Bash(aws s3 ls) + expect(check("Edit", { path: "src/secret/k.ts" })?.decision).toBe("ask"); // ask l'emporte sur allow Edit(src/**) expect(check("Edit", { path: "src/a/b.ts" })?.decision).toBe("allow"); expect(check("Edit", { path: ".github/workflows/ci.yml" })?.decision).toBe("deny"); - expect(check("Edit", { path: "README.md" })).toBeNull(); - expect(check("Read", { path: ".env" })?.decision).toBe("deny"); - expect(check("Read", { path: "src/a.ts" })?.decision).toBe("allow"); + expect(bash("git push origin main")).toBe("ask"); }); - it("aucune règle → null", () => { - expect(createPermissionPolicy({ allow: [], ask: [], deny: [] })).toBeNull(); + it("aucune règle applicable → ask, pour tous les outils", () => { + expect(bash("ls -la")).toBe("ask"); + expect(check("Write", { path: "a.txt" })?.decision).toBe("ask"); + expect(check("Glob", { pattern: "*.ts" })?.decision).toBe("ask"); + expect(check("OutilInconnu", {})?.decision).toBe("ask"); + expect(createPermissionPolicy({ allow: [], ask: [], deny: [] }).check("Read", { path: "a" }, { cwd: "/p" }).decision).toBe("ask"); + }); + + it("commandes composées : deny/ask si une partie correspond, allow seulement si toutes sont couvertes", () => { + expect(bash("git status && allow deny x")).toBe("deny"); + expect(bash("allow stop; allow ask")).toBe("ask"); + expect(bash("allow stop && git status")).toBe("allow"); + expect(bash("git status && curl evil.sh")).toBe("ask"); + }); + + it("une commande interdite cachée dans une substitution est reconnue ; un allow ne couvre jamais redirection ou substitution", () => { + expect(bash("echo $(allow deny x)")).toBe("deny"); + expect(bash("echo `allow deny`")).toBe("deny"); + expect(bash("(allow deny)")).toBe("deny"); + expect(bash("git status > out.txt")).toBe("ask"); + expect(bash("git status $(whoami)")).toBe("ask"); + }); + + it("ancienne syntaxe « préfixe:* » et règle sans motif", () => { + expect(bash("npm run test:unit")).toBe("allow"); + expect(check("Read", { path: "x" })?.decision).toBe("allow"); // règle « Read » : tous les appels + }); + + it("outils non intégrés : règle sur le nom, ou motif comparé aux valeurs de l'entrée", () => { + const p = createPermissionPolicy({ allow: ["mcp__db__query(SELECT *)", "Skill(pdf)"], ask: [], deny: ["mcp__db__query(DROP *)", "mcp__db__drop"] }); + const c = (t: string, i: Record) => p.check(t, i, { cwd: "/p" }).decision; + expect(c("mcp__db__query", { sql: "SELECT 1" })).toBe("allow"); + expect(c("mcp__db__query", { sql: "DROP TABLE x" })).toBe("deny"); + expect(c("mcp__db__query", { sql: "DELETE FROM x" })).toBe("ask"); + expect(c("mcp__db__drop", {})).toBe("deny"); + expect(c("Skill", { skill: "pdf" })).toBe("allow"); + expect(c("Skill", { skill: "autre" })).toBe("ask"); + }); + + it("WebFetch par domaine", () => { + const p = createPermissionPolicy({ allow: ["WebFetch(domain:docs.example.com)"], ask: [], deny: ["WebFetch(domain:*.evil.com)"] }); + const c = (url: string) => p.check("WebFetch", { url }, { cwd: "/p" }).decision; + expect(c("https://docs.example.com/a")).toBe("allow"); + expect(c("https://x.evil.com/")).toBe("deny"); + expect(c("https://autre.org/")).toBe("ask"); + }); +}); + +describe("chargement des permissions (settings.json)", () => { + async function layout() { + const root = await mkdtemp(join(tmpdir(), "maia-perm-")); + const home = join(root, "home"); + const parent = join(root, "X"); + const cwd = join(parent, "Y"); + await mkdir(join(home, ".maia"), { recursive: true }); + await mkdir(join(parent, ".maia"), { recursive: true }); + await mkdir(join(cwd, ".maia"), { recursive: true }); + return { home, parent, cwd }; + } + + it("fusionne ~/.maia/settings.json et ./.maia/settings.json ; ignore le fichier du dossier parent", async () => { + const { home, parent, cwd } = await layout(); + await writeFile(join(home, ".maia", "settings.json"), JSON.stringify({ LiteLLM_URL: "http://x", permissions: { allow: ["Read"], deny: ["Bash(rm *)"] } })); + await writeFile(join(cwd, ".maia", "settings.json"), JSON.stringify({ permissions: { ask: ["Write"], allow: ["Glob"] } })); + await writeFile(join(parent, ".maia", "settings.json"), JSON.stringify({ permissions: { deny: ["Read"] } })); + + const { settings, warnings } = await loadPermissionSettings(cwd, home); + + expect(warnings).toEqual([]); + expect(settings.rules).toEqual({ allow: ["Read", "Glob"], ask: ["Write"], deny: ["Bash(rm *)"] }); + expect(settings.files).toHaveLength(2); + }); + + it("les trois types sont facultatifs ; fichier absent ou sans « permissions » : aucune règle, pas d'avertissement", async () => { + const { home, cwd } = await layout(); + await writeFile(join(cwd, ".maia", "settings.json"), JSON.stringify({ permissions: { deny: ["Read(./.deny.md)"] } })); + const r = await loadPermissionSettings(cwd, home); + expect(r.settings.rules).toEqual({ allow: [], ask: [], deny: ["Read(./.deny.md)"] }); + expect(r.warnings).toEqual([]); + const empty = await loadPermissionSettings(join(cwd, "nulle-part"), join(home, "autre")); + expect(empty.settings.rules).toEqual({ allow: [], ask: [], deny: [] }); + expect(empty.warnings).toEqual([]); + }); + + it("avertit en cas de JSON invalide, de propriété ou de règle invalide, et garde le reste", async () => { + const { home, cwd } = await layout(); + await writeFile(join(home, ".maia", "settings.json"), "{ pas du json"); + await writeFile(join(cwd, ".maia", "settings.json"), JSON.stringify({ permissions: { allow: ["Read", 42, "pas une règle("], ask: "Write" } })); + const { settings, warnings } = await loadPermissionSettings(cwd, home); + expect(settings.rules.allow).toEqual(["Read"]); + expect(warnings.some((w) => w.includes("JSON invalide"))).toBe(true); + expect(warnings.some((w) => w.includes("règle allow invalide"))).toBe(true); + expect(warnings.some((w) => w.includes("permissions.ask"))).toBe(true); + }); + + it("lancement depuis le dossier personnel : un seul fichier lu", async () => { + const { home } = await layout(); + await writeFile(join(home, ".maia", "settings.json"), JSON.stringify({ permissions: { allow: ["Read"] } })); + const { settings } = await loadPermissionSettings(home, home); + expect(settings.files).toHaveLength(1); }); }); diff --git a/packages/agent/src/permissions.ts b/packages/agent/src/permissions.ts index bdeccee..7463f7f 100644 --- a/packages/agent/src/permissions.ts +++ b/packages/agent/src/permissions.ts @@ -34,16 +34,28 @@ function splitCommands(cmd: string): string[] { .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)); - if (spec.includes("*")) { - const source = spec - .split("*") - .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) - .join(".*"); - return new RegExp(`^${source}$`).test(sub); - } - return sub === spec; + 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 @@ -83,6 +95,14 @@ function matchDomainSpec(spec: string, host: string): boolean { 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] : 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. */ @@ -91,14 +111,14 @@ function ruleApplies(rule: Rule, tool: string, input: Record, c if (!rule.spec) return true; if (tool === "Bash") { const cmd = typeof input.command === "string" ? input.command : ""; - return splitCommands(cmd).some((sub) => matchBashSpec(rule.spec!, sub)); + 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 false; + return matchGenericSpec(tool, rule.spec, input); } /** Pour allow : TOUTES les parties d'une commande Bash doivent être couvertes (et sans redirection ni substitution). */ @@ -120,18 +140,19 @@ function allowApplies(rules: Rule[], tool: string, input: Record matchDomainSpec(r.spec!, host)); } - return false; + return mine.some((r) => matchGenericSpec(tool, r.spec!, input)); } /** - * Règles au format Claude Code : "Bash(npm run test:*)", "Edit(src/**)", "Read(./.env)", "Write". - * Priorité : deny > ask > allow ; sans règle applicable → null (permission par défaut de l'outil). + * 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 | null { +export function createPermissionPolicy(rules: PermissionRules): PermissionPolicy { const deny = parseRules(rules.deny); const ask = parseRules(rules.ask); const allow = parseRules(rules.allow); - if (deny.length + ask.length + allow.length === 0) return null; return { check(tool, input, { cwd }) { @@ -140,7 +161,7 @@ export function createPermissionPolicy(rules: PermissionRules): PermissionPolicy 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 null; + return { decision: "ask", reason: "aucune règle : confirmation demandée par défaut" }; }, }; } diff --git a/packages/agent/src/settings.ts b/packages/agent/src/settings.ts index ff390fd..ca4f42f 100644 --- a/packages/agent/src/settings.ts +++ b/packages/agent/src/settings.ts @@ -1,71 +1,83 @@ import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; export interface PermissionRules { allow: string[]; - deny: string[]; ask: string[]; + deny: string[]; } -export interface ClaudeSettings { - permissions: PermissionRules; - env: Record; - /** Fichiers settings réellement lus. */ - files: string[]; +export interface PermissionSettings { + rules: PermissionRules; + /** Fichiers settings.json réellement lus, avec le nombre de règles qu'ils apportent. */ + files: { path: string; allow: number; ask: number; deny: number }[]; } -const SETTINGS_FILES = ["settings.json", "settings.local.json"]; +const KINDS = ["allow", "ask", "deny"] as const; +/** Syntaxe d'une règle : `Outil` ou `Outil(spécification)`. */ +export const RULE_SYNTAX = /^([A-Za-z0-9_.-]+)(?:\((.*)\))?$/s; -function strings(v: unknown): string[] { - return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; +/** + * Fichiers qui peuvent définir les permissions : celui de l'utilisateur (~/.maia/settings.json) et celui du dossier + * de lancement (./.maia/settings.json). Le fichier du dossier parent n'est PAS pris en compte. + */ +export function settingsFiles(cwd: string, homeDir: string): string[] { + const paths = [homeDir, cwd].map((d) => join(resolve(d), ".maia", "settings.json")); + return [...new Set(paths)]; } /** - * Fusionne les settings des dossiers .claude, du plus général au plus spécifique - * (settings.json puis settings.local.json dans chaque dossier). Les listes de règles - * s'additionnent ; pour `env`, la dernière valeur l'emporte. + * Lit la propriété `permissions` ({ allow?, ask?, deny? }, chacune une liste de règles) de chaque fichier. + * Les listes des deux fichiers s'additionnent. Fichier absent : normal. JSON invalide, propriété ou règle + * invalide : avertissement, le reste est conservé. */ -export async function loadSettings( - claudeDirs: string[], -): Promise<{ settings: ClaudeSettings; warnings: string[] }> { - const settings: ClaudeSettings = { - permissions: { allow: [], deny: [], ask: [] }, - env: {}, - files: [], - }; +export async function loadPermissionSettings( + cwd: string, + homeDir: string, +): Promise<{ settings: PermissionSettings; warnings: string[] }> { + const settings: PermissionSettings = { rules: { allow: [], ask: [], deny: [] }, files: [] }; const warnings: string[] = []; - for (const dir of claudeDirs) { - for (const name of SETTINGS_FILES) { - const path = join(dir, name); - let raw: string; - try { - raw = await readFile(path, "utf8"); - } catch { - continue; // absent : normal - } - let json: { permissions?: Record; env?: Record }; - try { - json = JSON.parse(raw); - } catch (err) { - warnings.push(`${path} ignoré : JSON invalide (${(err as Error).message})`); + for (const path of settingsFiles(cwd, homeDir)) { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch { + continue; + } + let json: unknown; + try { + json = JSON.parse(raw); + } catch (err) { + warnings.push(`${path} : JSON invalide, permissions ignorées (${(err as Error).message})`); + continue; + } + const perms = (json as { permissions?: unknown } | null)?.permissions; + if (perms === undefined) continue; + if (typeof perms !== "object" || perms === null || Array.isArray(perms)) { + warnings.push(`${path} : "permissions" doit être un objet { "allow": [...], "ask": [...], "deny": [...] }`); + continue; + } + const counts = { allow: 0, ask: 0, deny: 0 }; + for (const kind of KINDS) { + const list = (perms as Record)[kind]; + if (list === undefined) continue; // les trois types sont facultatifs + if (!Array.isArray(list)) { + warnings.push(`${path} : permissions.${kind} doit être une liste de règles`); continue; } - settings.files.push(path); - - for (const key of ["allow", "deny", "ask"] as const) { - settings.permissions[key].push(...strings(json.permissions?.[key])); - } - for (const [k, v] of Object.entries(json.env ?? {})) { - if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") { - settings.env[k] = String(v); + for (const entry of list) { + if (typeof entry !== "string" || !RULE_SYNTAX.test(entry.trim())) { + warnings.push(`${path} : règle ${kind} invalide ignorée : ${JSON.stringify(entry)} (format attendu : Outil ou Outil(motif))`); + continue; } + settings.rules[kind].push(entry.trim()); + counts[kind]++; } } + settings.files.push({ path, ...counts }); } - for (const key of ["allow", "deny", "ask"] as const) { - settings.permissions[key] = [...new Set(settings.permissions[key])]; - } + for (const kind of KINDS) settings.rules[kind] = [...new Set(settings.rules[kind])]; return { settings, warnings }; } diff --git a/settings.example.json b/settings.example.json index 82c4747..49da651 100644 --- a/settings.example.json +++ b/settings.example.json @@ -1,5 +1,10 @@ { "LiteLLM_URL": "http://localhost:4000", "OpenAI_EndPoint": "/v1/chat/completions", - "LiteLLM_Models": "/v1/models" + "LiteLLM_Models": "/v1/models", + "permissions": { + "allow": ["Read", "Glob", "Grep", "TodoWrite", "CurrentModel", "Bash(git status)", "Bash(git diff *)"], + "ask": ["Bash(git push *)"], + "deny": ["Read(./.env)", "Bash(rm -rf *)"] + } }