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>
237 lines
5.9 KiB
TypeScript
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,
|
|
};
|
|
}
|