diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 3f41bf3..cb9f3d5 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -32,9 +32,11 @@ import type { MemoryIndexEntry, MemoryLink, MemoryType, + McpToolPolicy, ModelServerCommandPreview, PermissionSet, Project, + ProjectMcpToolPermissions, ProjectPermissions, ProjectWorkState, ProfileAvailability, @@ -344,6 +346,26 @@ export class HttpPermissionGateway implements PermissionGateway { request: { projectId, agentId }, }); } + getMcpToolPermissions(projectId: string): Promise { + return this.http.invoke("get_mcp_tool_permissions", { projectId }); + } + updateProjectMcpToolPermissions( + projectId: string, + policy: McpToolPolicy | null, + ): Promise { + return this.http.invoke("update_project_mcp_tool_permissions", { + request: { projectId, policy }, + }); + } + updateAgentMcpToolPermissions( + projectId: string, + agentId: string, + policy: McpToolPolicy | null, + ): Promise { + return this.http.invoke("update_agent_mcp_tool_permissions", { + request: { projectId, agentId, policy }, + }); + } } export class HttpWorkStateGateway implements WorkStateGateway { diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index f275d7c..b03a692 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -31,11 +31,14 @@ import type { MemoryIndexEntry, MemoryLink, MemoryType, + McpToolCatalogue, + McpToolPolicy, EffectivePermissions, PairedDevice, PairingCode, PermissionSet, Project, + ProjectMcpToolPermissions, ProjectPermissions, ProjectWorkState, ProfileAvailability, @@ -2151,6 +2154,79 @@ export class MockPermissionGateway implements PermissionGateway { fallback: mostRestrictive(project?.fallback, agent?.fallback), }; } + + // ── MCP tool permissions (ticket #82) — mirrors the backend catalogue in + // `crates/infrastructure/src/orchestrator/mcp/tools.rs`, a separate durable + // document from the file/command permissions above. ───────────────────── + private mcpCatalogue: McpToolCatalogue = { + readOnlyTools: [ + "idea_list_agents", + "idea_context_read", + "idea_memory_read", + "idea_skill_read", + "idea_workstate_read", + "idea_ticket_read", + "idea_ticket_list", + "idea_ticket_read_carnet", + "idea_sprint_list", + ], + writeActionTools: [ + "idea_ask_agent", + "idea_run_in_background", + "idea_launch_agent", + "idea_stop_agent", + "idea_update_context", + "idea_context_propose", + "idea_memory_write", + "idea_workstate_set", + "idea_create_skill", + "idea_ticket_create", + "idea_ticket_update", + "idea_ticket_update_status", + "idea_ticket_update_priority", + "idea_ticket_update_carnet", + "idea_ticket_link", + "idea_ticket_unlink", + ], + }; + + private mcpDocs = new Map(); + + private mcpDoc(projectId: string): ProjectMcpToolPermissions { + if (!this.mcpDocs.has(projectId)) { + this.mcpDocs.set(projectId, { + version: 1, + catalogue: this.mcpCatalogue, + projectDefault: null, + agents: [], + }); + } + return this.mcpDocs.get(projectId)!; + } + + async getMcpToolPermissions(projectId: string): Promise { + return structuredClone(this.mcpDoc(projectId)); + } + + async updateProjectMcpToolPermissions( + projectId: string, + policy: McpToolPolicy | null, + ): Promise { + const doc = this.mcpDoc(projectId); + doc.projectDefault = policy ? structuredClone(policy) : null; + return structuredClone(doc); + } + + async updateAgentMcpToolPermissions( + projectId: string, + agentId: string, + policy: McpToolPolicy | null, + ): Promise { + const doc = this.mcpDoc(projectId); + doc.agents = doc.agents.filter((entry) => entry.agentId !== agentId); + if (policy) doc.agents.push({ agentId, policy: structuredClone(policy) }); + return structuredClone(doc); + } } export class MockWorkStateGateway implements WorkStateGateway { diff --git a/frontend/src/adapters/permission.ts b/frontend/src/adapters/permission.ts index 369afab..4c7537c 100644 --- a/frontend/src/adapters/permission.ts +++ b/frontend/src/adapters/permission.ts @@ -2,7 +2,9 @@ import { invoke } from "@tauri-apps/api/core"; import type { EffectivePermissions, + McpToolPolicy, PermissionSet, + ProjectMcpToolPermissions, ProjectPermissions, } from "@/domain"; import type { PermissionGateway } from "@/ports"; @@ -40,4 +42,31 @@ export class TauriPermissionGateway implements PermissionGateway { request: { projectId, agentId }, }); } + + getMcpToolPermissions(projectId: string): Promise { + return invoke("get_mcp_tool_permissions", { + projectId, + }); + } + + updateProjectMcpToolPermissions( + projectId: string, + policy: McpToolPolicy | null, + ): Promise { + return invoke( + "update_project_mcp_tool_permissions", + { request: { projectId, policy } }, + ); + } + + updateAgentMcpToolPermissions( + projectId: string, + agentId: string, + policy: McpToolPolicy | null, + ): Promise { + return invoke( + "update_agent_mcp_tool_permissions", + { request: { projectId, agentId, policy } }, + ); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index e30aa82..cb2c15d 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -705,6 +705,41 @@ export interface EffectivePermissions { fallback: PermissionPosture; } +// --------------------------------------------------------------------------- +// MCP tool permissions (ticket #82) — distinct from the file/command +// permissions above: an allowlist of exact MCP tool names, applied by the MCP +// server/bridge before dispatch, not by Landlock/sandbox. +// --------------------------------------------------------------------------- + +/** Allowlist-based MCP tool policy: exact tool names permitted. */ +export interface McpToolPolicy { + allowedTools: string[]; +} + +/** One agent's MCP tool policy override, replacing the project default entirely. */ +export interface AgentMcpToolPolicyOverride { + agentId: string; + policy: McpToolPolicy; +} + +/** + * Backend-canonical classification of the MCP tool catalogue. The frontend + * must treat this as the source of truth for read vs write/action — never + * hardcode the split locally (ticket #82 acceptance criteria). + */ +export interface McpToolCatalogue { + readOnlyTools: string[]; + writeActionTools: string[]; +} + +/** Full per-project MCP tool permission document, mirroring the backend DTO. */ +export interface ProjectMcpToolPermissions { + version: number; + catalogue: McpToolCatalogue; + projectDefault: McpToolPolicy | null; + agents: AgentMcpToolPolicyOverride[]; +} + // --------------------------------------------------------------------------- // Layout (L4) — mirror of the domain `LayoutTree` (ARCHITECTURE §3, §7). // --------------------------------------------------------------------------- diff --git a/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx b/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx new file mode 100644 index 0000000..9b0e3d5 --- /dev/null +++ b/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx @@ -0,0 +1,259 @@ +/** + * Ticket #82 — the "Tools MCP IdeA" tab of the project `Permissions` panel. + * + * Pins the UX contract from carnet #82 (Conception UX/F): an agent without an + * override shows it inherits the project default with non-editable controls + * until `Créer un override`; creating one prefills the effective allowlist; + * toggling a tool updates the visible summary; saving calls the backend + * command with the exact resulting allowlist; and an unsaved draft is + * protected when the user switches target. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; + +import { + MockAgentGateway, + MockPermissionGateway, + MockProfileGateway, +} from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; +import type { Gateways } from "@/ports"; +import { PermissionsPanel } from "./PermissionsPanel"; + +const PROJECT_ID = "proj-mcp-permissions-test"; + +async function renderPanel() { + const agent = new MockAgentGateway(); + const permission = new MockPermissionGateway(); + const created = await agent.createAgent(PROJECT_ID, { + name: "DevFrontend", + profileId: "p1", + }); + const gateways = { + agent, + permission, + profile: new MockProfileGateway(), + } as unknown as Gateways; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("DevFrontend")).toBeTruthy(); + }); + + return { agent, permission, agentId: created.id }; +} + +/** Switches to the "Tools MCP IdeA" tab and waits past the doc load. */ +async function openMcpToolsTab() { + fireEvent.click(screen.getByRole("tab", { name: "Tools MCP IdeA" })); + await screen.findByRole("navigation", { name: "cibles des permissions MCP" }); +} + +async function selectAgentTarget(name: string) { + const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); + fireEvent.click(within(nav).getByRole("button", { name: new RegExp(name) })); + await waitFor(() => { + expect(screen.getByRole("heading", { name: `Tools MCP IdeA — ${name}` })).toBeTruthy(); + }); +} + +describe("PermissionsPanel — Tools MCP IdeA tab", () => { + it("has an accessible tablist and defaults to Système", async () => { + await renderPanel(); + + const tablist = screen.getByRole("tablist", { name: "Permissions" }); + const tabs = within(tablist).getAllByRole("tab"); + expect(tabs.map((t) => t.textContent)).toEqual(["Système", "Tools MCP IdeA"]); + expect(screen.getByRole("tab", { name: "Système" }).getAttribute("aria-selected")).toBe( + "true", + ); + }); + + it("an agent without an override shows it inherits the project default, read-only", async () => { + await renderPanel(); + await openMcpToolsTab(); + + const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); + const agentButton = within(nav).getByRole("button", { name: /DevFrontend/ }); + expect(within(agentButton).getByText("Hérité")).toBeTruthy(); + + await selectAgentTarget("DevFrontend"); + + expect(screen.getByText("Hérite du défaut projet")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Créer un override" })).toBeTruthy(); + + // Read groups are collapsed by default in agent editing (carnet #82) — expand it. + fireEvent.click(screen.getByRole("button", { name: /Lecture tickets/ })); + + // A canonical read-only tool is shown checked but disabled (inherited display). + const readCheckbox = screen.getByRole("checkbox", { + name: /idea_ticket_read$/, + }) as HTMLInputElement; + expect(readCheckbox.checked).toBe(true); + expect(readCheckbox.disabled).toBe(true); + + // A write tool is shown unchecked (canonical read-only fallback denies it) and disabled. + const writeCheckbox = screen.getByRole("checkbox", { + name: /idea_ticket_update_carnet$/, + }) as HTMLInputElement; + expect(writeCheckbox.checked).toBe(false); + expect(writeCheckbox.disabled).toBe(true); + + // No Save action is meaningfully available before creating an override. + expect(screen.getByRole("button", { name: "Enregistrer" })).toHaveProperty("disabled", true); + }); + + it("creating an override prefills the draft with the current effective allowlist", async () => { + await renderPanel(); + await openMcpToolsTab(); + await selectAgentTarget("DevFrontend"); + + fireEvent.click(screen.getByRole("button", { name: "Créer un override" })); + + expect(screen.getByText("Override personnalisé")).toBeTruthy(); + + // Read groups are collapsed by default in agent editing (carnet #82) — expand it. + fireEvent.click(screen.getByRole("button", { name: /Lecture tickets/ })); + + // Prefilled from the effective (inherited) allowlist: canonical read-only + // tools checked and now editable. + const readCheckbox = screen.getByRole("checkbox", { + name: /idea_ticket_read$/, + }) as HTMLInputElement; + expect(readCheckbox.checked).toBe(true); + expect(readCheckbox.disabled).toBe(false); + + const writeCheckbox = screen.getByRole("checkbox", { + name: /idea_ticket_update_carnet$/, + }) as HTMLInputElement; + expect(writeCheckbox.checked).toBe(false); + + // No edit yet: nothing to save. + expect(screen.getByRole("button", { name: "Enregistrer" })).toHaveProperty("disabled", true); + }); + + it("checking a write tool updates the effective summary and unsaved-changes flag", async () => { + await renderPanel(); + await openMcpToolsTab(); + await selectAgentTarget("DevFrontend"); + fireEvent.click(screen.getByRole("button", { name: "Créer un override" })); + + expect(screen.getByText(/9 lecture autorisés · 0 écriture autorisé/)).toBeTruthy(); + + const writeCheckbox = screen.getByRole("checkbox", { + name: /idea_ticket_update_carnet$/, + }); + fireEvent.click(writeCheckbox); + + expect(screen.getByText(/9 lecture autorisés · 1 écriture autorisé/)).toBeTruthy(); + expect(screen.getByText("Modifications non enregistrées")).toBeTruthy(); + expect(screen.getByText("Ajouté")).toBeTruthy(); + }); + + it("saving calls updateAgentMcpToolPermissions with the exact resulting allowlist", async () => { + const { permission, agentId } = await renderPanel(); + const spy = vi.spyOn(permission, "updateAgentMcpToolPermissions"); + await openMcpToolsTab(); + await selectAgentTarget("DevFrontend"); + fireEvent.click(screen.getByRole("button", { name: "Créer un override" })); + + fireEvent.click(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ })); + fireEvent.click(screen.getByRole("button", { name: "Enregistrer" })); + + await waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); + const [calledProjectId, calledAgentId, calledPolicy] = spy.mock.calls[0]!; + expect(calledProjectId).toBe(PROJECT_ID); + expect(calledAgentId).toBe(agentId); + expect(calledPolicy?.allowedTools).toEqual( + expect.arrayContaining([ + "idea_list_agents", + "idea_context_read", + "idea_memory_read", + "idea_skill_read", + "idea_workstate_read", + "idea_ticket_read", + "idea_ticket_list", + "idea_ticket_read_carnet", + "idea_sprint_list", + "idea_ticket_update_carnet", + ]), + ); + expect(calledPolicy?.allowedTools).toHaveLength(10); + + // Persisted: the left-column badge flips to Override, and Réinitialiser appears. + const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); + const agentButton = within(nav).getByRole("button", { name: /DevFrontend/ }); + expect(within(agentButton).getByText("Override")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Réinitialiser l'override" })).toBeTruthy(); + }); + + describe("draft protection on target change", () => { + const originalConfirm = window.confirm; + beforeEach(() => { + window.confirm = vi.fn(); + }); + afterEach(() => { + window.confirm = originalConfirm; + }); + + it("asks for confirmation, and keeps the draft when the user cancels", async () => { + await renderPanel(); + await openMcpToolsTab(); + await selectAgentTarget("DevFrontend"); + fireEvent.click(screen.getByRole("button", { name: "Créer un override" })); + fireEvent.click(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ })); + + (window.confirm as ReturnType).mockReturnValue(false); + + const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); + fireEvent.click(within(nav).getByRole("button", { name: /^Défaut projet/ })); + + expect(window.confirm).toHaveBeenCalled(); + // Still on the agent editor, with the unsaved edit intact. + expect( + screen.getByRole("heading", { name: "Tools MCP IdeA — DevFrontend" }), + ).toBeTruthy(); + expect( + (screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }) as HTMLInputElement) + .checked, + ).toBe(true); + }); + + it("discards the draft and switches target when the user confirms", async () => { + await renderPanel(); + await openMcpToolsTab(); + await selectAgentTarget("DevFrontend"); + fireEvent.click(screen.getByRole("button", { name: "Créer un override" })); + fireEvent.click(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ })); + + (window.confirm as ReturnType).mockReturnValue(true); + + const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); + fireEvent.click(within(nav).getByRole("button", { name: /^Défaut projet/ })); + + expect(window.confirm).toHaveBeenCalled(); + expect( + screen.getByRole("heading", { name: "Tools MCP IdeA — Défaut projet" }), + ).toBeTruthy(); + }); + + it("does not prompt when switching target with no unsaved changes", async () => { + await renderPanel(); + await openMcpToolsTab(); + await selectAgentTarget("DevFrontend"); + + const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); + fireEvent.click(within(nav).getByRole("button", { name: /^Défaut projet/ })); + + expect(window.confirm).not.toHaveBeenCalled(); + expect( + screen.getByRole("heading", { name: "Tools MCP IdeA — Défaut projet" }), + ).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/features/permissions/McpToolPermissionsPanel.tsx b/frontend/src/features/permissions/McpToolPermissionsPanel.tsx new file mode 100644 index 0000000..772d7ef --- /dev/null +++ b/frontend/src/features/permissions/McpToolPermissionsPanel.tsx @@ -0,0 +1,630 @@ +/** + * `McpToolPermissionsPanel` — the "Tools MCP IdeA" tab of the project + * `Permissions` panel (ticket #82). Lets the user grant/revoke MCP tool + * capabilities per agent (or project-wide default), grouped by domain rather + * than as a flat 25-checkbox list (carnet #82 — Conception UX/F). + * + * Two-column layout: a target selector (`Défaut projet` + agents, each + * showing `Hérité`/`Override`) on the left, and the grouped tool editor for + * the selected target on the right. An agent without an override shows its + * inherited effective state read-only until `Créer un override`; the draft + * then starts prefilled with that exact effective allowlist. Unsaved edits + * are tracked per mounted editor instance (remounted via `key` on target + * change) and protected by a confirmation when switching target. + */ + +import { useEffect, useMemo, useRef, useState } from "react"; + +import type { Agent, McpToolCatalogue } from "@/domain"; +import { Button, Panel, cn } from "@/shared"; +import { MCP_TOOL_GROUPS, mcpToolLabel } from "./mcpToolGroups"; +import { useMcpToolPermissions } from "./useMcpToolPermissions"; + +export interface McpToolPermissionsPanelProps { + projectId: string; + agents: Agent[]; +} + +type McpTarget = + | { type: "project" } + | { type: "agent"; agentId: string; agentName: string }; + +const READ_GROUP_IDS = new Set(["readProject", "readTickets"]); + +function toggleTool(list: string[], tool: string): string[] { + return list.includes(tool) ? list.filter((t) => t !== tool) : [...list, tool]; +} + +function sameToolSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const bs = new Set(b); + return a.every((t) => bs.has(t)); +} + +export function McpToolPermissionsPanel({ + projectId, + agents, +}: McpToolPermissionsPanelProps) { + const vm = useMcpToolPermissions(projectId); + const [target, setTarget] = useState({ type: "project" }); + // Read imperatively (not via re-render) by `selectTarget` so an unsaved + // draft in the currently-mounted editor blocks a target switch without + // needing the editor's dirty state to live in this component. + const dirtyRef = useRef(false); + + function selectTarget(next: McpTarget) { + if (dirtyRef.current) { + const proceed = window.confirm( + "Des modifications non enregistrées seront perdues. Continuer ?", + ); + if (!proceed) return; + } + dirtyRef.current = false; + setTarget(next); + } + + if (vm.loading && !vm.doc) { + return ( +
+
+
+
+
+
+ ); + } + + if (vm.error && !vm.doc) { + return ( +
+

+ {vm.error} +

+ +
+ ); + } + + if (!vm.doc) return null; + const doc = vm.doc; + + const selectedAgentOverride = + target.type === "agent" + ? doc.agents.find((entry) => entry.agentId === target.agentId) ?? null + : null; + + return ( +
+ + +
+ {target.type === "project" ? ( + { + dirtyRef.current = dirty; + }} + onSaveProjectDefault={async (allowed) => { + await vm.saveProjectDefault({ allowedTools: allowed }); + }} + /> + ) : ( + { + dirtyRef.current = dirty; + }} + onSaveAgentOverride={async (allowed) => { + await vm.saveAgentOverride(target.agentId, { allowedTools: allowed }); + }} + onResetOverride={async () => { + await vm.resetAgentOverride(target.agentId); + }} + /> + )} +
+
+ ); +} + +interface McpToolEditorProps { + targetKind: "project" | "agent"; + title: string; + catalogue: McpToolCatalogue; + /** Stored policy allowlist for this exact target, or `null` if unset/inherited. */ + storedAllowed: string[] | null; + /** + * For `project`: the canonical read-only fallback (used as the starting + * draft when no default is stored yet). For `agent`: the effective + * inherited allowlist (project default, or read-only fallback) — used both + * as the read-only display when there is no override, and to prefill the + * draft the moment `Créer un override` is clicked, and to compute the + * per-row `Ajouté`/`Retiré` diff once editing. + */ + comparisonBaseline: string[]; + busy: boolean; + onDirtyChange: (dirty: boolean) => void; + onSaveProjectDefault?: (allowed: string[]) => Promise; + onSaveAgentOverride?: (allowed: string[]) => Promise; + onResetOverride?: () => Promise; +} + +function McpToolEditor({ + targetKind, + title, + catalogue, + storedAllowed, + comparisonBaseline, + busy, + onDirtyChange, + onSaveProjectDefault, + onSaveAgentOverride, + onResetOverride, +}: McpToolEditorProps) { + const initialAllowed = storedAllowed ?? comparisonBaseline; + const [overrideActive, setOverrideActive] = useState( + targetKind === "project" || storedAllowed != null, + ); + const [hasPersistedOverride, setHasPersistedOverride] = useState(storedAllowed != null); + const [draft, setDraft] = useState(initialAllowed); + const [baseline, setBaseline] = useState(initialAllowed); + const [pendingConfirm, setPendingConfirm] = useState(false); + const [savedFlash, setSavedFlash] = useState(false); + const [saveError, setSaveError] = useState(null); + const [expandedGroups, setExpandedGroups] = useState>(() => + Object.fromEntries( + MCP_TOOL_GROUPS.map((group) => [ + group.id, + READ_GROUP_IDS.has(group.id) ? targetKind === "project" : true, + ]), + ), + ); + + const editable = targetKind === "project" || overrideActive; + const displaySet = editable ? draft : comparisonBaseline; + const dirty = editable && !sameToolSet(draft, baseline); + + useEffect(() => { + onDirtyChange(dirty); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dirty]); + + useEffect(() => { + if (!savedFlash) return; + const id = window.setTimeout(() => setSavedFlash(false), 2000); + return () => window.clearTimeout(id); + }, [savedFlash]); + + const readAllowedCount = catalogue.readOnlyTools.filter((t) => displaySet.includes(t)).length; + const writeAllowedCount = catalogue.writeActionTools.filter((t) => + displaySet.includes(t), + ).length; + + const knownTools = useMemo( + () => new Set([...catalogue.readOnlyTools, ...catalogue.writeActionTools]), + [catalogue], + ); + const unknownTools = useMemo( + () => Array.from(new Set([...displaySet, ...comparisonBaseline])).filter((t) => !knownTools.has(t)), + [displaySet, comparisonBaseline, knownTools], + ); + + function toggle(tool: string) { + setSaveError(null); + setDraft((prev) => toggleTool(prev, tool)); + } + + function toggleGroup(id: string) { + setExpandedGroups((prev) => ({ ...prev, [id]: !prev[id] })); + } + + function createOverride() { + setOverrideActive(true); + setDraft(comparisonBaseline); + setBaseline(comparisonBaseline); + } + + function cancelDraft() { + setDraft(baseline); + setPendingConfirm(false); + setSaveError(null); + } + + async function handleSave() { + setSaveError(null); + if (targetKind === "project") { + const introducesNewWrite = draft.some( + (t) => catalogue.writeActionTools.includes(t) && !baseline.includes(t), + ); + if (introducesNewWrite && !pendingConfirm) { + setPendingConfirm(true); + return; + } + setPendingConfirm(false); + try { + await onSaveProjectDefault?.(draft); + setBaseline(draft); + setSavedFlash(true); + } catch (e) { + setSaveError(describeError(e)); + } + return; + } + try { + await onSaveAgentOverride?.(draft); + setBaseline(draft); + setHasPersistedOverride(true); + setSavedFlash(true); + } catch (e) { + setSaveError(describeError(e)); + } + } + + async function handleReset() { + setSaveError(null); + try { + await onResetOverride?.(); + setOverrideActive(false); + setHasPersistedOverride(false); + setDraft(comparisonBaseline); + setBaseline(comparisonBaseline); + setSavedFlash(true); + } catch (e) { + setSaveError(describeError(e)); + } + } + + return ( + +
+ {targetKind === "agent" && ( +

+ {overrideActive ? "Override personnalisé" : "Hérite du défaut projet"} +

+ )} + + {targetKind === "agent" && !overrideActive && ( + + )} + +
+
+ Résumé effectif +
+

+ {readAllowedCount} lecture autorisés · {writeAllowedCount} écriture autorisé + {writeAllowedCount > 1 ? "s" : ""} +

+
+ +
+
+ Détail des tools +
+ {MCP_TOOL_GROUPS.map((group) => ( + toggleGroup(group.id)} + catalogue={catalogue} + editable={editable} + targetKind={targetKind} + draftSet={draft} + displaySet={displaySet} + comparisonBaseline={comparisonBaseline} + onToggleTool={toggle} + /> + ))} + + {unknownTools.length > 0 && ( + toggleGroup("unknown")} + catalogue={catalogue} + editable={false} + targetKind={targetKind} + draftSet={draft} + displaySet={displaySet} + comparisonBaseline={comparisonBaseline} + onToggleTool={toggle} + unknown + /> + )} +
+ + {pendingConfirm && ( +
+

+ Ce tool sera autorisé pour tous les agents sans override. Confirmer cette + modification ? +

+
+ + +
+
+ )} + + {saveError && ( +

+ {saveError} +

+ )} + + {savedFlash && ( +

+ Permissions enregistrées +

+ )} + +
+
+ {dirty && ( + Modifications non enregistrées + )} +
+
+ {targetKind === "agent" && hasPersistedOverride && ( + + )} + + +
+
+
+
+ ); +} + +interface ToolGroupSectionProps { + group: { id: string; label: string; tools: string[] }; + expanded: boolean; + onToggle: () => void; + catalogue: McpToolCatalogue; + editable: boolean; + targetKind: "project" | "agent"; + draftSet: string[]; + displaySet: string[]; + comparisonBaseline: string[]; + onToggleTool: (tool: string) => void; + unknown?: boolean; +} + +function ToolGroupSection({ + group, + expanded, + onToggle, + catalogue, + editable, + targetKind, + displaySet, + comparisonBaseline, + onToggleTool, + unknown, +}: ToolGroupSectionProps) { + const allowedCount = group.tools.filter((t) => displaySet.includes(t)).length; + const total = group.tools.length; + + return ( +
+ + {expanded && ( +
    + {group.tools.map((tool) => ( + onToggleTool(tool)} + /> + ))} +
+ )} +
+ ); +} + +type RowStatus = "allowed" | "denied" | "inherited" | "added" | "removed" | "unknown"; + +function rowStatus( + tool: string, + editable: boolean, + targetKind: "project" | "agent", + displaySet: string[], + comparisonBaseline: string[], + unknown?: boolean, +): RowStatus { + if (unknown) return "unknown"; + if (targetKind === "agent" && !editable) return "inherited"; + if (targetKind === "agent" && editable) { + const inDraft = displaySet.includes(tool); + const inBaseline = comparisonBaseline.includes(tool); + if (inDraft && !inBaseline) return "added"; + if (!inDraft && inBaseline) return "removed"; + return inDraft ? "allowed" : "denied"; + } + return displaySet.includes(tool) ? "allowed" : "denied"; +} + +const STATUS_LABEL: Record = { + allowed: "Autorisé", + denied: "Refusé", + inherited: "Hérité", + added: "Ajouté", + removed: "Retiré", + unknown: "Inconnu", +}; + +interface ToolRowProps { + tool: string; + isRead: boolean; + unknown?: boolean; + editable: boolean; + checked: boolean; + status: RowStatus; + onToggle: () => void; +} + +function ToolRow({ tool, isRead, unknown, editable, checked, status, onToggle }: ToolRowProps) { + const label = mcpToolLabel(tool); + const inputId = `mcp-tool-${tool}`; + return ( +
  • + + + + {STATUS_LABEL[status]} + +
  • + ); +} + +function describeError(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as { message: unknown }).message); + } + return String(e); +} diff --git a/frontend/src/features/permissions/PermissionsPanel.tsx b/frontend/src/features/permissions/PermissionsPanel.tsx index 887133c..e3015ba 100644 --- a/frontend/src/features/permissions/PermissionsPanel.tsx +++ b/frontend/src/features/permissions/PermissionsPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Button, Panel, Spinner, cn } from "@/shared"; import type { PermissionSet, PermissionPosture } from "@/domain"; +import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel"; import { type CapabilityChoice, type PolicyDraft, @@ -14,6 +15,13 @@ export interface PermissionsPanelProps { projectId: string; } +type PermissionsTab = "system" | "mcpTools"; + +const TABS: { id: PermissionsTab; label: string }[] = [ + { id: "system", label: "Système" }, + { id: "mcpTools", label: "Tools MCP IdeA" }, +]; + type EditorTarget = | { type: "project" } | { type: "agent"; agentId: string; agentName: string }; @@ -37,6 +45,7 @@ const POSTURE_LABELS: Record = { export function PermissionsPanel({ projectId }: PermissionsPanelProps) { const vm = usePermissions(projectId); const [target, setTarget] = useState({ type: "project" }); + const [tab, setTab] = useState("system"); const selectedAgent = target.type === "agent" ? vm.rows.find((row) => row.agent.id === target.agentId) ?? null @@ -79,9 +88,39 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) { Refresh } - className="flex flex-col" + className="flex min-h-0 flex-1 flex-col" flush > +
    + {TABS.map((t) => ( + + ))} +
    + {vm.error && (

    )} -

    + + + ); } diff --git a/frontend/src/features/permissions/mcpToolGroups.ts b/frontend/src/features/permissions/mcpToolGroups.ts new file mode 100644 index 0000000..82c3606 --- /dev/null +++ b/frontend/src/features/permissions/mcpToolGroups.ts @@ -0,0 +1,111 @@ +/** + * Presentational grouping of the MCP tool catalogue (ticket #82). + * + * Purely a frontend display concern: which domain a tool "belongs to" for the + * grouped/collapsible editor, and its short French label. The read/write + * classification stays backend-canonical — always read from the + * {@link McpToolCatalogue} the API returns, never re-derived here. + */ + +export interface McpToolGroupDef { + id: string; + label: string; + tools: string[]; +} + +/** Domain groups, in display order (carnet #82 — Conception UX/F). */ +export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [ + { + id: "readProject", + label: "Lecture projet", + tools: [ + "idea_list_agents", + "idea_context_read", + "idea_memory_read", + "idea_skill_read", + "idea_workstate_read", + ], + }, + { + id: "readTickets", + label: "Lecture tickets", + tools: [ + "idea_ticket_read", + "idea_ticket_list", + "idea_ticket_read_carnet", + "idea_sprint_list", + ], + }, + { + id: "delegation", + label: "Délégation agents", + tools: ["idea_ask_agent", "idea_launch_agent", "idea_stop_agent"], + }, + { + id: "contextMemory", + label: "Contexte et mémoire", + tools: ["idea_update_context", "idea_context_propose", "idea_memory_write"], + }, + { + id: "tickets", + label: "Tickets", + tools: [ + "idea_ticket_create", + "idea_ticket_update", + "idea_ticket_update_status", + "idea_ticket_update_priority", + "idea_ticket_update_carnet", + "idea_ticket_link", + "idea_ticket_unlink", + ], + }, + { + id: "work", + label: "Travail et exécution", + tools: ["idea_run_in_background", "idea_workstate_set"], + }, + { + id: "skills", + label: "Skills", + tools: ["idea_create_skill"], + }, +]; + +/** Short, human French label per exact MCP tool name. */ +export const MCP_TOOL_LABELS: Record = { + idea_list_agents: "Lister les agents", + idea_context_read: "Lire le contexte d'un agent", + idea_memory_read: "Lire la mémoire projet", + idea_skill_read: "Lire un skill", + idea_workstate_read: "Lire l'état de travail", + idea_ticket_read: "Lire un ticket", + idea_ticket_list: "Lister les tickets", + idea_ticket_read_carnet: "Lire le carnet d'un ticket", + idea_sprint_list: "Lister les sprints", + idea_ask_agent: "Déléguer à un agent", + idea_launch_agent: "Lancer un agent", + idea_stop_agent: "Arrêter un agent", + idea_update_context: "Modifier le contexte d'un agent", + idea_context_propose: "Proposer un contexte", + idea_memory_write: "Écrire la mémoire projet", + idea_workstate_set: "Écrire l'état de travail", + idea_create_skill: "Créer un skill", + idea_ticket_create: "Créer un ticket", + idea_ticket_update: "Modifier un ticket", + idea_ticket_update_status: "Modifier le statut d'un ticket", + idea_ticket_update_priority: "Modifier la priorité d'un ticket", + idea_ticket_update_carnet: "Modifier le carnet d'un ticket", + idea_ticket_link: "Lier des tickets", + idea_ticket_unlink: "Délier des tickets", + idea_run_in_background: "Exécuter une commande en arrière-plan", +}; + +/** Short human label for `tool`, falling back to the exact name if unmapped. */ +export function mcpToolLabel(tool: string): string { + return MCP_TOOL_LABELS[tool] ?? tool; +} + +/** The group a tool belongs to, or `undefined` if not locally mapped. */ +export function mcpToolGroupId(tool: string): string | undefined { + return MCP_TOOL_GROUPS.find((group) => group.tools.includes(tool))?.id; +} diff --git a/frontend/src/features/permissions/useMcpToolPermissions.ts b/frontend/src/features/permissions/useMcpToolPermissions.ts new file mode 100644 index 0000000..b0352d5 --- /dev/null +++ b/frontend/src/features/permissions/useMcpToolPermissions.ts @@ -0,0 +1,141 @@ +/** + * `useMcpToolPermissions` — view-model hook for the "Tools MCP IdeA" tab + * (ticket #82). Owns the durable per-project MCP tool permission document + * (distinct from the file/command permissions in `usePermissions`) and + * exposes read/save/reset operations. No effective-policy resolution logic + * beyond mirroring the backend's own precedence (agent override > project + * default > canonical read-only fallback) using ONLY data the API returns — + * the read/write classification itself always comes from `catalogue`, never + * a local constant (ticket #82 acceptance criteria). + */ + +import { useCallback, useEffect, useState } from "react"; + +import type { GatewayError, McpToolPolicy, ProjectMcpToolPermissions } from "@/domain"; +import { useGateways } from "@/app/di"; + +export interface McpToolPermissionsViewModel { + doc: ProjectMcpToolPermissions | null; + loading: boolean; + busy: boolean; + error: string | null; + refresh: () => Promise; + saveProjectDefault: (policy: McpToolPolicy) => Promise; + saveAgentOverride: (agentId: string, policy: McpToolPolicy) => Promise; + resetAgentOverride: (agentId: string) => Promise; +} + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +/** + * Effective allowlist for `agentId`: its stored override if any, else the + * project default, else the catalogue's read-only fallback. Pure function of + * backend-provided data (no hardcoded tool names). + */ +export function effectiveMcpAllowedTools( + doc: ProjectMcpToolPermissions, + agentId: string, +): string[] { + const override = doc.agents.find((entry) => entry.agentId === agentId); + if (override) return override.policy.allowedTools; + if (doc.projectDefault) return doc.projectDefault.allowedTools; + return doc.catalogue.readOnlyTools; +} + +export function useMcpToolPermissions( + projectId: string, +): McpToolPermissionsViewModel { + const { permission } = useGateways(); + const [doc, setDoc] = useState(null); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (!permission) return; + setLoading(true); + setError(null); + try { + setDoc(await permission.getMcpToolPermissions(projectId)); + } catch (e) { + setError(describe(e)); + } finally { + setLoading(false); + } + }, [permission, projectId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const saveProjectDefault = useCallback( + async (policy: McpToolPolicy) => { + if (!permission) return; + setBusy(true); + setError(null); + try { + setDoc(await permission.updateProjectMcpToolPermissions(projectId, policy)); + } catch (e) { + setError(describe(e)); + throw e; + } finally { + setBusy(false); + } + }, + [permission, projectId], + ); + + const saveAgentOverride = useCallback( + async (agentId: string, policy: McpToolPolicy) => { + if (!permission) return; + setBusy(true); + setError(null); + try { + setDoc( + await permission.updateAgentMcpToolPermissions(projectId, agentId, policy), + ); + } catch (e) { + setError(describe(e)); + throw e; + } finally { + setBusy(false); + } + }, + [permission, projectId], + ); + + const resetAgentOverride = useCallback( + async (agentId: string) => { + if (!permission) return; + setBusy(true); + setError(null); + try { + setDoc( + await permission.updateAgentMcpToolPermissions(projectId, agentId, null), + ); + } catch (e) { + setError(describe(e)); + throw e; + } finally { + setBusy(false); + } + }, + [permission, projectId], + ); + + return { + doc, + loading, + busy, + error, + refresh, + saveProjectDefault, + saveAgentOverride, + resetAgentOverride, + }; +} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 31caca6..6c617bd 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -32,11 +32,13 @@ import type { MemoryIndexEntry, MemoryLink, MemoryType, + McpToolPolicy, OpenCodeConfig, EffectivePermissions, PairedDevice, PairingCode, PermissionSet, + ProjectMcpToolPermissions, PageDirection, Project, ProjectPermissions, @@ -797,6 +799,23 @@ export interface PermissionGateway { projectId: string, agentId: string, ): Promise; + /** + * Reads the project's durable MCP tool permission document plus the + * backend-canonical catalogue classification (ticket #82). Distinct + * document from the file/command permissions above. + */ + getMcpToolPermissions(projectId: string): Promise; + /** Replaces or removes the project-wide default MCP tool policy. */ + updateProjectMcpToolPermissions( + projectId: string, + policy: McpToolPolicy | null, + ): Promise; + /** Replaces or removes one agent's MCP tool policy override. */ + updateAgentMcpToolPermissions( + projectId: string, + agentId: string, + policy: McpToolPolicy | null, + ): Promise; } /** Read-only live work-state read-model for conversations/delegations. */