Update modifications

This commit is contained in:
Arnaud 2026-10-15 23:49:58 +02:00
parent d0853db245
commit a021bd4fbc
13 changed files with 103 additions and 10 deletions

View file

@ -52,6 +52,7 @@ Il n'y a plus de notion de mode ni d'extensions : un seul agent, MAIA.
| Saisie | Effet |
| --- | --- |
| `/help` | aide (commandes, skills) |
| `/permissions` | affiche les règles de permissions chargées, par fichier : badges `ALLOW`, `ASK`, `DENY` suivis des règles |
| `/model` | recharge la liste des modèles LiteLLM et change de modèle (conversation conservée) |
| `/clear` | supprime le contexte de la session et recharge `MAIA.md` / `CLAUDE.md` et les skills, comme au démarrage (la session garde son nom) |
| `/rename <nom>` · `/load [nom]` · `/new` | sessions (voir plus bas) |
@ -246,6 +247,8 @@ Au démarrage, MAIA indique (`INFO`) les fichiers chargés et le nombre de règl
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.
La commande `/permissions` affiche les règles actives, fichier par fichier, avec un badge `ALLOW` (vert), `ASK` (jaune) et `DENY` (rouge) suivi des règles du type, une par ligne ; seuls les types non vides sont montrés. Après avoir modifié un `settings.json`, `/clear` recharge la configuration.
Exemple pour éviter les confirmations sur les outils en lecture seule : voir `settings.example.json`.
## Outils TodoWrite, WebFetch et CurrentModel
@ -290,3 +293,4 @@ Journal des évolutions, dans l'ordre :
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).
20. **`/permissions`** : affiche les permissions chargées, par fichier et par type (badges ALLOW / ASK / DENY) ; les fichiers sont listés l'un sous l'autre au démarrage.

View file

@ -4,7 +4,7 @@ import type { SlashCommand } from "@maia/core";
import { parseFrontmatter } from "./frontmatter";
/** Noms réservés aux commandes internes de MAIA (gérées par la CLI). */
export const RESERVED_COMMANDS = new Set(["help", "clear", "model", "exit", "rename", "load", "new"]);
export const RESERVED_COMMANDS = new Set(["help", "clear", "model", "exit", "rename", "load", "new", "permissions"]);
async function listMarkdown(dir: string, prefix = ""): Promise<{ name: string; file: string }[]> {
let entries;

View file

@ -103,6 +103,14 @@ describe("permissions au démarrage", () => {
expect(logs.join("\n")).toContain("Permissions chargées");
expect(logs.join("\n")).toContain("2 deny, 1 ask, 1 allow");
expect(logs.some((l) => l.startsWith("Permissions chargées :\n"))).toBe(true); // un fichier par ligne
expect(init.permissionSources).toEqual([
{
path: join(project, ".maia", "settings.json"),
allow: ["Bash(allow stop *)"],
ask: ["Bash(allow ask *)"],
deny: ["Bash(allow deny *)", "Read(./.deny.md)"],
},
]);
});
});

View file

@ -46,8 +46,8 @@ export async function initializeAgent(ctx: InitContext): Promise<ModeInitResult>
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)`);
.filter((f) => f.allow.length + f.ask.length + f.deny.length > 0)
.map((f) => `${f.path} (${f.deny.length} deny, ${f.ask.length} ask, ${f.allow.length} allow)`);
ctx.log(["Permissions chargées :", ...files].join("\n"));
} else {
ctx.log("Aucune règle de permission définie : chaque utilisation d'un outil demande une confirmation.");
@ -60,5 +60,6 @@ export async function initializeAgent(ctx: InitContext): Promise<ModeInitResult>
commands: [],
skills: toAgentSkills(skills),
permissions: createPermissionPolicy(settings.rules),
permissionSources: settings.files.filter((f) => f.allow.length + f.ask.length + f.deny.length > 0),
};
}

View file

@ -10,7 +10,7 @@ export interface PermissionRules {
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 }[];
files: { path: string; allow: string[]; ask: string[]; deny: string[] }[];
}
const KINDS = ["allow", "ask", "deny"] as const;
@ -58,7 +58,7 @@ export async function loadPermissionSettings(
warnings.push(`${path} : "permissions" doit être un objet { "allow": [...], "ask": [...], "deny": [...] }`);
continue;
}
const counts = { allow: 0, ask: 0, deny: 0 };
const own: { allow: string[]; ask: string[]; deny: string[] } = { allow: [], ask: [], deny: [] };
for (const kind of KINDS) {
const list = (perms as Record<string, unknown>)[kind];
if (list === undefined) continue; // les trois types sont facultatifs
@ -72,10 +72,10 @@ export async function loadPermissionSettings(
continue;
}
settings.rules[kind].push(entry.trim());
counts[kind]++;
own[kind].push(entry.trim());
}
}
settings.files.push({ path, ...counts });
settings.files.push({ path, ...own });
}
for (const kind of KINDS) settings.rules[kind] = [...new Set(settings.rules[kind])];

View file

@ -65,7 +65,7 @@ Usage : maia [options]
Sans cette option, la liste des modèles disponibles est proposée au démarrage.
-h, --help Affiche cette aide
Commandes en session : /help /model /clear /exit
Commandes en session : /help /model /permissions /clear /exit
Sessions : /rename <nom> /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions)
Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills)
Commande shell : !commande ou !'commande avec paramètres' (exécutée directement ; résultat ajouté au contexte)
@ -273,6 +273,10 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
ui.info([HELP, ...custom, ...skills].join("\n"));
continue;
}
if (name === "permissions") {
ui.showPermissions(session.permissionSources);
continue;
}
if (name === "rename") {
if (!sessions) ui.error("Sessions indisponibles (dossier ./.maia non accessible).");
else if (!args.trim()) ui.info(`Session courante : ${sessions.name} (usage : /rename <nouveau nom>)`);

View file

@ -6,6 +6,7 @@ import type {
Message,
ModeInitResult,
PermissionPolicy,
PermissionSource,
SlashCommand,
Tool,
} from "./types";
@ -17,6 +18,8 @@ export interface AgentSession {
commands: SlashCommand[];
skills: AgentSkill[];
permissions?: PermissionPolicy;
/** Fichiers dont viennent les règles de permissions (affichage avec /permissions). */
permissionSources: PermissionSource[];
env?: Record<string, string>;
messages: Message[];
/** Contexte ajouté par l'utilisateur hors conversation (ex. résultat d'une commande "!"), joint à sa prochaine demande. */
@ -38,6 +41,7 @@ export function createSession(opts: {
commands: opts.init.commands ?? [],
skills: opts.init.skills ?? [],
permissions: opts.init.permissions,
permissionSources: opts.init.permissionSources ?? [],
env: opts.init.env,
messages: [],
pendingContext: [],

View file

@ -122,6 +122,14 @@ export interface InitContext {
warn(message: string): void;
}
/** Règles de permissions apportées par un fichier (pour l'affichage avec /permissions). */
export interface PermissionSource {
path: string;
allow: string[];
ask: string[];
deny: string[];
}
/** Résultat de l'initialisation de l'agent : ce dont l'agent a besoin pour démarrer. */
export interface ModeInitResult {
systemPrompt: string;
@ -132,6 +140,8 @@ export interface ModeInitResult {
skills?: AgentSkill[];
/** Règles allow/ask/deny de l'agent (remplacent la permission par défaut des outils quand elles s'appliquent). */
permissions?: PermissionPolicy;
/** Fichiers dont viennent les règles de `permissions`, pour l'affichage. */
permissionSources?: PermissionSource[];
/** Variables d'environnement transmises aux outils (ex. Bash). */
env?: Record<string, string>;
}

View file

@ -9,6 +9,7 @@ import type {
ConfirmPromptState,
InputPromptState,
LogItem,
PermissionFile,
PromptState,
SelectPromptState,
UIStore,
@ -16,6 +17,36 @@ import type {
// ───────────── Journal ─────────────
const PERMISSION_KINDS = [
{ key: "allow", label: "ALLOW", color: "green" },
{ key: "ask", label: "ASK", color: "yellow" },
{ key: "deny", label: "DENY", color: "red" },
] as const;
/** Règles par fichier : chemin, puis un badge ALLOW / ASK / DENY suivi des règles du type (une par ligne). */
function PermissionsView({ files }: { files: PermissionFile[] }) {
if (files.length === 0) {
return <Text dimColor>Aucune règle de permission définie : chaque utilisation d'un outil demande une confirmation.</Text>;
}
return (
<Box flexDirection="column">
{files.map((file, i) => (
<Box key={file.path} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
<Text bold>{`${file.path} :`}</Text>
{PERMISSION_KINDS.filter((k) => file[k.key].length > 0).map((k) => (
<Box key={k.key} flexDirection="column">
<Badge color={k.color}>{k.label}</Badge>
{file[k.key].map((rule) => (
<Text key={rule}>{rule}</Text>
))}
</Box>
))}
</Box>
))}
</Box>
);
}
function LogLine({ item }: { item: LogItem }) {
switch (item.kind) {
case "intro":
@ -78,6 +109,8 @@ function LogLine({ item }: { item: LogItem }) {
);
case "assistant":
return <Text>{item.text}</Text>;
case "permissions":
return <PermissionsView files={item.permissions ?? []} />;
case "shell":
return (
<Box flexDirection="column">

View file

@ -1,2 +1,2 @@
export { InkUI } from "./ink-ui";
export { UIStore, type Choice, type LogItem, type LogKind, type UIState } from "./store";
export { UIStore, type Choice, type LogItem, type LogKind, type PermissionFile, type UIState } from "./store";

View file

@ -86,6 +86,11 @@ export class InkUI implements AgentUI {
this.start().log("user", text);
}
/** Affiche les règles de permissions par fichier (allow / ask / deny). */
showPermissions(files: PermissionFile[]): void {
this.start().showPermissions(files);
}
/** Commande "!" de l'utilisateur et son résultat. */
onShell(command: string, output: string, status: string | null): void {
this.start().shell(command, output, status);

View file

@ -14,6 +14,15 @@ describe("UIStore", () => {
expect(kinds(s)).toEqual(["assistant:Bonjour"]);
});
it("showPermissions : un élément avec les règles par fichier", () => {
const s = new UIStore();
const files = [{ path: "/h/.maia/settings.json", allow: ["Read"], ask: [], deny: ["Bash(rm *)"] }];
s.showPermissions(files);
const item = s.getSnapshot().items[0]!;
expect(item.kind).toBe("permissions");
expect(item.permissions).toEqual(files);
});
it("notifie les abonnés et fournit un instantané stable entre deux changements", () => {
const s = new UIStore();
let calls = 0;

View file

@ -13,7 +13,8 @@ export type LogKind =
| "choice"
| "user"
| "assistant"
| "shell";
| "shell"
| "permissions";
export interface LogItem {
id: number;
@ -23,6 +24,15 @@ export interface LogItem {
detail?: string;
/** Commande shell en échec (affichée en rouge). */
failed?: boolean;
/** Règles de permissions par fichier (kind "permissions"). */
permissions?: PermissionFile[];
}
export interface PermissionFile {
path: string;
allow: string[];
ask: string[];
deny: string[];
}
export interface Choice<T = unknown> {
@ -121,6 +131,11 @@ export class UIStore {
this.commit({ items: this.withItem("shell", command, { detail, failed: status !== null }) });
}
/** Règles de permissions chargées, par fichier (commande /permissions). */
showPermissions(files: PermissionFile[]): void {
this.commit({ items: this.withItem("permissions", "Permissions", { permissions: files }) });
}
setBusy(label: string | null): void {
this.commit({ busy: label });
}