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

@ -24,6 +24,7 @@ import { TauriSkillGateway } from "./skill";
import { TauriMemoryGateway } from "./memory";
import { TauriEmbedderGateway } from "./embedder";
import { TauriGitGateway } from "./git";
import { TauriPermissionGateway } from "./permission";
function notImplemented(what: string): never {
const err: GatewayError = {
@ -55,6 +56,7 @@ export function createTauriGateways(): Gateways {
skill: new TauriSkillGateway(),
memory: new TauriMemoryGateway(),
embedder: new TauriEmbedderGateway(),
permission: new TauriPermissionGateway(),
};
}
@ -71,4 +73,5 @@ export {
TauriMemoryGateway,
TauriEmbedderGateway,
TauriGitGateway,
TauriPermissionGateway,
};

View File

@ -32,4 +32,10 @@ export class TauriInputGateway implements InputGateway {
request: { projectId, agentId, ticket },
});
}
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
await invoke("set_front_attached", {
request: { agentId, attached },
});
}
}

View File

@ -27,7 +27,10 @@ import type {
MemoryIndexEntry,
MemoryLink,
MemoryType,
EffectivePermissions,
PermissionSet,
Project,
ProjectPermissions,
ProfileAvailability,
ResumableAgent,
Skill,
@ -53,6 +56,7 @@ import type {
OpenTerminalOptions,
ProfileGateway,
ProjectGateway,
PermissionGateway,
ReattachResult,
RemoteGateway,
SkillGateway,
@ -1553,6 +1557,8 @@ export class MockInputGateway implements InputGateway {
readonly interrupts: MockInputCall[] = [];
/** All recorded delegation-delivered acks, in order. */
readonly delivered: MockInputCall[] = [];
/** All recorded front-attached reports, in order. */
readonly frontAttached: { agentId: string; attached: boolean }[] = [];
async interrupt(projectId: string, agentId: string): Promise<void> {
this.interrupts.push({ projectId, agentId });
@ -1565,6 +1571,72 @@ export class MockInputGateway implements InputGateway {
): Promise<void> {
this.delivered.push({ projectId, agentId, ticket });
}
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
this.frontAttached.push({ agentId, attached });
}
}
/** In-memory permissions gateway. */
export class MockPermissionGateway implements PermissionGateway {
private docs = new Map<string, ProjectPermissions>();
private doc(projectId: string): ProjectPermissions {
if (!this.docs.has(projectId)) {
this.docs.set(projectId, { version: 1, agents: [] });
}
return this.docs.get(projectId)!;
}
async getProjectPermissions(projectId: string): Promise<ProjectPermissions> {
return structuredClone(this.doc(projectId));
}
async updateProjectPermissions(
projectId: string,
permissions: PermissionSet | null,
): Promise<ProjectPermissions> {
const doc = this.doc(projectId);
if (permissions) doc.projectDefaults = structuredClone(permissions);
else delete doc.projectDefaults;
return structuredClone(doc);
}
async updateAgentPermissions(
projectId: string,
agentId: string,
permissions: PermissionSet | null,
): Promise<ProjectPermissions> {
const doc = this.doc(projectId);
const agents = (doc.agents ?? []).filter((entry) => entry.agentId !== agentId);
if (permissions) agents.push({ agentId, permissions: structuredClone(permissions) });
doc.agents = agents;
return structuredClone(doc);
}
async resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null> {
const doc = this.doc(projectId);
const project = doc.projectDefaults;
const agent = doc.agents?.find((entry) => entry.agentId === agentId)?.permissions;
if (!project && !agent) return null;
return {
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
fallback: mostRestrictive(project?.fallback, agent?.fallback),
};
}
}
function mostRestrictive(
project?: PermissionSet["fallback"],
agent?: PermissionSet["fallback"],
): PermissionSet["fallback"] {
const rank = { allow: 0, ask: 1, deny: 2 } as const;
const fallback = project ?? agent ?? "ask";
if (!agent) return fallback;
return rank[agent] >= rank[fallback] ? agent : fallback;
}
/** Builds the full set of mock gateways. */
@ -1584,6 +1656,7 @@ export function createMockGateways(): Gateways {
skill: new MockSkillGateway(agentGateway),
memory: new MockMemoryGateway(),
embedder: new MockEmbedderGateway(),
permission: new MockPermissionGateway(),
};
}

View File

@ -0,0 +1,43 @@
import { invoke } from "@tauri-apps/api/core";
import type {
EffectivePermissions,
PermissionSet,
ProjectPermissions,
} from "@/domain";
import type { PermissionGateway } from "@/ports";
/** Tauri-backed permissions gateway. */
export class TauriPermissionGateway implements PermissionGateway {
getProjectPermissions(projectId: string): Promise<ProjectPermissions> {
return invoke<ProjectPermissions>("get_project_permissions", { projectId });
}
updateProjectPermissions(
projectId: string,
permissions: PermissionSet | null,
): Promise<ProjectPermissions> {
return invoke<ProjectPermissions>("update_project_permissions", {
request: { projectId, permissions },
});
}
updateAgentPermissions(
projectId: string,
agentId: string,
permissions: PermissionSet | null,
): Promise<ProjectPermissions> {
return invoke<ProjectPermissions>("update_agent_permissions", {
request: { projectId, agentId, permissions },
});
}
resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null> {
return invoke<EffectivePermissions | null>("resolve_agent_permissions", {
request: { projectId, agentId },
});
}
}