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:
381
frontend/src/features/permissions/PermissionsPanel.tsx
Normal file
381
frontend/src/features/permissions/PermissionsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user