feat(permissions): voie projection CLI (LP0→LP3) + checkpoint Codex/input

Jalon vert regroupant deux chantiers entrelacés dans le working tree,
indissociables au niveau fichier mais tous deux verts (cargo test
--workspace + tests frontend permissions au vert).

Permissions — voie « projection CLI » (advisory), complète :
- LP0 domaine pur : modèle PermissionSet/EffectivePermissions, resolve
  deny-wins + postures Allow<Ask<Deny (crates/domain/src/permission.rs).
- LP1 store : FsPermissionStore (.ideai/permissions.json).
- LP2 use cases : Get/Update project, Update agent override, Resolve.
- LP3 projecteurs Claude/Codex (settings.local.json / config.toml),
  câblage launch-path + PermissionProjectorRegistry, nettoyage des
  fichiers Replace orphelins au swap de profil (LP3-4), composition root
  + commandes Tauri, UI PermissionsPanel (projet + override agent).
- ports.rs : PermissionStore + FileSystem::remove_file (cleanup au swap).

Reste ouvert (hors scope, marqué dans le code) : LP4 enforcement OS
airtight (Landlock fichiers) + résumé de permissions injecté.

Inclut aussi le chantier Codex/input/sessions structurées en cours
(McpConfigStrategy, StructuredAdapter, gestion d'input) partageant les
mêmes fichiers (lifecycle.rs, commands.rs, dto.rs, state.rs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 20:39:18 +02:00
parent 46492506e1
commit 27597eb64e
41 changed files with 6513 additions and 269 deletions

View File

@ -0,0 +1,381 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Panel, Spinner, cn } from "@/shared";
import type { PermissionSet, PermissionPosture } from "@/domain";
import {
type CapabilityChoice,
type PolicyDraft,
draftFromSet,
isCustomPolicy,
usePermissions,
} from "./usePermissions";
export interface PermissionsPanelProps {
projectId: string;
}
type EditorTarget =
| { type: "project" }
| { type: "agent"; agentId: string; agentName: string };
const CAPABILITY_ROWS: {
key: keyof Omit<PolicyDraft, "fallback">;
label: string;
}[] = [
{ key: "read", label: "Read" },
{ key: "write", label: "Write" },
{ key: "delete", label: "Delete" },
{ key: "executeBash", label: "Bash" },
];
const POSTURE_LABELS: Record<PermissionPosture, string> = {
ask: "Ask",
allow: "Allow",
deny: "Deny",
};
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
const vm = usePermissions(projectId);
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
const selectedAgent = target.type === "agent"
? vm.rows.find((row) => row.agent.id === target.agentId) ?? null
: null;
const activePolicy = selectedAgent?.override ?? vm.document?.projectDefaults ?? null;
const activeDraft = target.type === "project"
? vm.projectDraft
: draftFromSet(selectedAgent?.override ?? vm.document?.projectDefaults ?? null);
const activeCustom = target.type === "project"
? isCustomPolicy(vm.document?.projectDefaults)
: isCustomPolicy(selectedAgent?.override);
const hasProjectDefaults = vm.document?.projectDefaults != null;
async function handleSave(draft: PolicyDraft) {
if (target.type === "project") {
await vm.saveProjectDefaults(draft);
} else {
await vm.saveAgentOverride(target.agentId, draft);
}
}
async function handleClear() {
if (target.type === "project") {
await vm.clearProjectDefaults();
} else {
await vm.clearAgentOverride(target.agentId);
}
}
return (
<Panel
title="Permissions"
actions={
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => void vm.refresh()}
>
Refresh
</Button>
}
className="flex flex-col"
flush
>
{vm.error && (
<p
role="alert"
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
<div className="flex flex-col gap-4 p-4">
<PolicyCard
title="Project defaults"
subtitle={hasProjectDefaults ? "Configured" : "Native CLI behavior"}
active={target.type === "project"}
custom={isCustomPolicy(vm.document?.projectDefaults)}
onSelect={() => setTarget({ type: "project" })}
policy={vm.document?.projectDefaults ?? null}
/>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
Agents
</h4>
{vm.busy && <Spinner size={14} />}
</div>
{vm.rows.length === 0 ? (
<p className="text-sm text-muted">No agents yet.</p>
) : (
<ul className="flex flex-col gap-2">
{vm.rows.map((row) => {
const active =
target.type === "agent" && target.agentId === row.agent.id;
return (
<li key={row.agent.id}>
<PolicyCard
title={row.agent.name}
subtitle={row.override ? "Override" : "Inherited"}
active={active}
custom={isCustomPolicy(row.override)}
onSelect={() =>
setTarget({
type: "agent",
agentId: row.agent.id,
agentName: row.agent.name,
})
}
policy={row.override}
/>
</li>
);
})}
</ul>
)}
</div>
<PermissionEditor
key={target.type === "project" ? "project" : target.agentId}
title={
target.type === "project"
? "Project defaults"
: `Override — ${target.agentName}`
}
draft={activeDraft}
sourcePolicy={activePolicy}
custom={activeCustom}
canClear={
target.type === "project"
? vm.document?.projectDefaults != null
: selectedAgent?.override != null
}
busy={vm.busy}
onSave={(draft) => void handleSave(draft)}
onClear={() => void handleClear()}
/>
</div>
</Panel>
);
}
interface PolicyCardProps {
title: string;
subtitle: string;
active: boolean;
custom: boolean;
policy: PermissionSet | null;
onSelect: () => void;
}
function PolicyCard({
title,
subtitle,
active,
custom,
policy,
onSelect,
}: PolicyCardProps) {
return (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
className={cn(
"flex w-full min-w-0 items-center justify-between gap-3 rounded-md border px-3 py-2 text-left",
"transition-colors hover:border-border-strong hover:bg-raised",
active ? "border-primary bg-raised" : "border-border bg-surface",
)}
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium text-content">{title}</span>
<span className="text-xs text-muted">{subtitle}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5">
{custom && (
<span className="rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
Custom
</span>
)}
{policy && (
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary">
{POSTURE_LABELS[policy.fallback]}
</span>
)}
</span>
</button>
);
}
interface PermissionEditorProps {
title: string;
draft: PolicyDraft;
sourcePolicy: PermissionSet | null;
custom: boolean;
canClear: boolean;
busy: boolean;
onSave: (draft: PolicyDraft) => void;
onClear: () => void;
}
function PermissionEditor({
title,
draft,
sourcePolicy,
custom,
canClear,
busy,
onSave,
onClear,
}: PermissionEditorProps) {
const [local, setLocal] = useState<PolicyDraft>(draft);
useEffect(() => {
setLocal(draft);
}, [draft]);
const changed = useMemo(
() => JSON.stringify(local) !== JSON.stringify(draft),
[draft, local],
);
function setCapability(
capability: keyof Omit<PolicyDraft, "fallback">,
choice: CapabilityChoice,
) {
setLocal((prev) => ({ ...prev, [capability]: choice }));
}
return (
<section className="rounded-md border border-border bg-surface">
<header className="flex items-start justify-between gap-3 border-b border-border px-3 py-2.5">
<div className="min-w-0">
<h4 className="truncate text-sm font-semibold text-content">{title}</h4>
<p className="text-xs text-muted">
{sourcePolicy ? `${sourcePolicy.rules.length} rules` : "Not configured"}
</p>
</div>
{custom && (
<span className="shrink-0 rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
Advanced
</span>
)}
</header>
<div className="flex flex-col gap-3 p-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Fallback</span>
<select
aria-label={`${title} fallback`}
value={local.fallback}
disabled={busy}
onChange={(e) =>
setLocal((prev) => ({
...prev,
fallback: e.target.value as PermissionPosture,
}))
}
className={cn(
"h-9 rounded-md border border-border bg-raised px-3 text-sm text-content",
"outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<option value="ask">Ask</option>
<option value="allow">Allow</option>
<option value="deny">Deny</option>
</select>
</label>
<div className="grid grid-cols-1 gap-2">
{CAPABILITY_ROWS.map((row) => (
<div
key={row.key}
className="grid grid-cols-[4.5rem_1fr] items-center gap-2"
>
<span className="text-xs font-medium text-muted">{row.label}</span>
<SegmentedChoice
value={local[row.key]}
disabled={busy}
label={row.label}
onChange={(choice) => setCapability(row.key, choice)}
/>
</div>
))}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
<Button
size="sm"
variant="ghost"
disabled={busy || !canClear}
onClick={onClear}
>
Clear
</Button>
<Button
size="sm"
variant="primary"
disabled={busy || !changed}
loading={busy && changed}
onClick={() => onSave(local)}
>
Save
</Button>
</div>
</div>
</section>
);
}
interface SegmentedChoiceProps {
value: CapabilityChoice;
disabled: boolean;
label: string;
onChange: (choice: CapabilityChoice) => void;
}
function SegmentedChoice({
value,
disabled,
label,
onChange,
}: SegmentedChoiceProps) {
const choices: { value: CapabilityChoice; label: string }[] = [
{ value: "none", label: "None" },
{ value: "allow", label: "Allow" },
{ value: "deny", label: "Deny" },
];
return (
<div
role="group"
aria-label={`${label} permission`}
className="grid grid-cols-3 overflow-hidden rounded-md border border-border"
>
{choices.map((choice) => (
<button
key={choice.value}
type="button"
disabled={disabled}
aria-pressed={value === choice.value}
onClick={() => onChange(choice.value)}
className={cn(
"h-8 min-w-0 border-r border-border px-2 text-xs font-medium last:border-r-0",
"transition-colors disabled:cursor-not-allowed disabled:opacity-50",
value === choice.value
? choice.value === "deny"
? "bg-danger/20 text-danger"
: choice.value === "allow"
? "bg-success/20 text-success"
: "bg-raised text-content"
: "bg-surface text-muted hover:bg-raised hover:text-content",
)}
>
{choice.label}
</button>
))}
</div>
);
}

View File

@ -0,0 +1 @@
export * from "./PermissionsPanel";

View File

@ -0,0 +1,89 @@
import { describe, expect, it } 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-permissions-test";
async function renderPanel() {
const agent = new MockAgentGateway();
const permission = new MockPermissionGateway();
await agent.createAgent(PROJECT_ID, { name: "Builder", profileId: "p1" });
const gateways = {
agent,
permission,
profile: new MockProfileGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<PermissionsPanel projectId={PROJECT_ID} />
</DIProvider>,
);
await waitFor(() => {
expect(screen.getByText("Builder")).toBeTruthy();
});
return { agent, permission };
}
function clickChoice(groupName: string, choice: string) {
const group = screen.getByRole("group", { name: groupName });
fireEvent.click(within(group).getByRole("button", { name: choice }));
}
describe("PermissionsPanel", () => {
it("saves project defaults from the compact policy editor", async () => {
const { permission } = await renderPanel();
fireEvent.change(screen.getByLabelText("Project defaults fallback"), {
target: { value: "allow" },
});
clickChoice("Read permission", "Allow");
clickChoice("Bash permission", "Deny");
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(async () => {
const doc = await permission.getProjectPermissions(PROJECT_ID);
expect(doc.projectDefaults?.fallback).toBe("allow");
expect(doc.projectDefaults?.rules).toEqual([
{ capability: "read", effect: "allow", paths: ["**"] },
{ capability: "executeBash", effect: "deny", commands: [] },
]);
});
});
it("saves and clears an agent override", async () => {
const { permission } = await renderPanel();
fireEvent.click(screen.getByText("Builder"));
fireEvent.change(screen.getByLabelText("Override — Builder fallback"), {
target: { value: "deny" },
});
clickChoice("Write permission", "Allow");
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(async () => {
const doc = await permission.getProjectPermissions(PROJECT_ID);
expect(doc.agents?.[0]?.permissions.fallback).toBe("deny");
expect(doc.agents?.[0]?.permissions.rules).toEqual([
{ capability: "write", effect: "allow", paths: ["**"] },
]);
});
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
await waitFor(async () => {
const doc = await permission.getProjectPermissions(PROJECT_ID);
expect(doc.agents).toEqual([]);
});
});
});

View File

@ -0,0 +1,236 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useGateways } from "@/app/di";
import type {
Agent,
Capability,
PermissionEffect,
PermissionPosture,
PermissionRule,
PermissionSet,
ProjectPermissions,
} from "@/domain";
export type CapabilityChoice = "none" | PermissionEffect;
export interface PolicyDraft {
fallback: PermissionPosture;
read: CapabilityChoice;
write: CapabilityChoice;
delete: CapabilityChoice;
executeBash: CapabilityChoice;
}
export interface AgentPermissionRow {
agent: Agent;
override: PermissionSet | null;
}
export interface PermissionsViewModel {
agents: Agent[];
rows: AgentPermissionRow[];
document: ProjectPermissions | null;
projectDraft: PolicyDraft;
busy: boolean;
error: string | null;
refresh: () => Promise<void>;
saveProjectDefaults: (draft: PolicyDraft) => Promise<void>;
clearProjectDefaults: () => Promise<void>;
saveAgentOverride: (agentId: string, draft: PolicyDraft) => Promise<void>;
clearAgentOverride: (agentId: string) => Promise<void>;
}
const DEFAULT_DRAFT: PolicyDraft = {
fallback: "ask",
read: "none",
write: "none",
delete: "none",
executeBash: "none",
};
const CAPABILITIES: Capability[] = ["read", "write", "delete", "executeBash"];
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}
export function draftFromSet(set?: PermissionSet | null): PolicyDraft {
if (!set) return { ...DEFAULT_DRAFT };
const draft: PolicyDraft = {
...DEFAULT_DRAFT,
fallback: set.fallback,
};
for (const capability of CAPABILITIES) {
draft[capability] = ruleChoice(set.rules, capability);
}
return draft;
}
function ruleChoice(
rules: PermissionRule[],
capability: Capability,
): CapabilityChoice {
const matching = rules.filter((rule) => rule.capability === capability);
if (matching.some((rule) => rule.effect === "deny")) return "deny";
if (matching.some((rule) => rule.effect === "allow")) return "allow";
return "none";
}
export function setFromDraft(draft: PolicyDraft): PermissionSet {
const rules: PermissionRule[] = [];
for (const capability of CAPABILITIES) {
const effect = draft[capability];
if (effect === "none") continue;
rules.push(ruleFromChoice(capability, effect));
}
return { fallback: draft.fallback, rules };
}
function ruleFromChoice(
capability: Capability,
effect: PermissionEffect,
): PermissionRule {
if (capability === "executeBash") {
return { capability, effect, commands: [] };
}
return { capability, effect, paths: ["**"] };
}
export function isCustomPolicy(set?: PermissionSet | null): boolean {
if (!set) return false;
return set.rules.some((rule) => {
if (rule.capability === "executeBash") {
return (rule.commands ?? []).length > 0;
}
const paths = rule.paths ?? [];
return paths.length !== 1 || paths[0] !== "**";
});
}
export function usePermissions(projectId: string): PermissionsViewModel {
const { agent, permission } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [document, setDocument] = useState<ProjectPermissions | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setBusy(true);
setError(null);
try {
const [agentList, permissionDoc] = await Promise.all([
agent.listAgents(projectId),
permission.getProjectPermissions(projectId),
]);
setAgents(agentList);
setDocument(permissionDoc);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [agent, permission, projectId]);
useEffect(() => {
void refresh();
}, [refresh]);
const rows = useMemo<AgentPermissionRow[]>(() => {
const overrides = new Map(
(document?.agents ?? []).map((entry) => [entry.agentId, entry.permissions]),
);
return agents.map((candidate) => ({
agent: candidate,
override: overrides.get(candidate.id) ?? null,
}));
}, [agents, document]);
const projectDraft = useMemo(
() => draftFromSet(document?.projectDefaults ?? null),
[document],
);
const saveProjectDefaults = useCallback(
async (draft: PolicyDraft) => {
setBusy(true);
setError(null);
try {
setDocument(
await permission.updateProjectPermissions(projectId, setFromDraft(draft)),
);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[permission, projectId],
);
const clearProjectDefaults = useCallback(async () => {
setBusy(true);
setError(null);
try {
setDocument(await permission.updateProjectPermissions(projectId, null));
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [permission, projectId]);
const saveAgentOverride = useCallback(
async (agentId: string, draft: PolicyDraft) => {
setBusy(true);
setError(null);
try {
setDocument(
await permission.updateAgentPermissions(
projectId,
agentId,
setFromDraft(draft),
),
);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[permission, projectId],
);
const clearAgentOverride = useCallback(
async (agentId: string) => {
setBusy(true);
setError(null);
try {
setDocument(
await permission.updateAgentPermissions(projectId, agentId, null),
);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[permission, projectId],
);
return {
agents,
rows,
document,
projectDraft,
busy,
error,
refresh,
saveProjectDefaults,
clearProjectDefaults,
saveAgentOverride,
clearAgentOverride,
};
}

View File

@ -37,6 +37,7 @@ import { TemplatesPanel } from "@/features/templates";
import { SkillsPanel } from "@/features/skills";
import { MemoryPanel } from "@/features/memory";
import { EmbedderSettings } from "@/features/embedder";
import { PermissionsPanel } from "@/features/permissions";
import { GitPanel, GitGraphView } from "@/features/git";
import { Button, Input, Panel, Tabs, cn } from "@/shared";
import { useGateways } from "@/app/di";
@ -49,6 +50,7 @@ type SidebarTab =
| "agents"
| "templates"
| "skills"
| "permissions"
| "memory"
| "git";
@ -58,6 +60,7 @@ const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
{ id: "agents", label: "Agents" },
{ id: "templates", label: "Templates" },
{ id: "skills", label: "Skills" },
{ id: "permissions", label: "Perms" },
{ id: "memory", label: "Memory" },
{ id: "git", label: "Git" },
];
@ -289,6 +292,14 @@ export function ProjectsView() {
<p className="text-sm text-muted">Open a project to manage skills.</p>
)}
{/* Permissions panel */}
{sidebarTab === "permissions" && active && (
<PermissionsPanel projectId={active.id} />
)}
{sidebarTab === "permissions" && !active && (
<p className="text-sm text-muted">Open a project to manage permissions.</p>
)}
{/* Memory panel + embedder settings */}
{sidebarTab === "memory" && active && (
<div className="flex flex-col gap-4">

View File

@ -225,11 +225,20 @@ export function useWritePortal(
},
bindHandle(handle: TerminalHandle) {
handleRef.current = handle;
// Tell the backend a frontend cell is now mounted for this agent, so the
// mediator routes its turns through `delegationReady` (this portal writes)
// rather than writing the PTY itself (the headless path for cell-less agents).
const agent = agentIdRef.current;
if (agent) void inputRef.current.setFrontAttached(agent, true).catch(() => {});
// The PTY just became available — a queued delegation may be injectable.
tryInject.current();
},
unbindHandle() {
handleRef.current = null;
// The cell is gone — let the backend fall back to headless delivery so a
// delegation arriving while this agent has no live cell is not lost.
const agent = agentIdRef.current;
if (agent) void inputRef.current.setFrontAttached(agent, false).catch(() => {});
},
}),
[],