Files
IdeaSDK/frontend/src/features/permissions/usePermissions.ts
Blomios 27597eb64e 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>
2026-06-15 20:39:18 +02:00

237 lines
5.9 KiB
TypeScript

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,
};
}