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:
@ -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,
|
||||
};
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
43
frontend/src/adapters/permission.ts
Normal file
43
frontend/src/adapters/permission.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -84,6 +84,67 @@ export interface GatewayError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permissions (LP1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Agent capability governed by IdeA permissions. */
|
||||
export type Capability = "read" | "write" | "delete" | "executeBash";
|
||||
|
||||
/** Permission rule verdict. */
|
||||
export type PermissionEffect = "allow" | "deny";
|
||||
|
||||
/** Fallback stance when no permission rule matches. */
|
||||
export type PermissionPosture = "ask" | "allow" | "deny";
|
||||
|
||||
/** Glob path scope, relative to the project root. */
|
||||
export type PathScope = string[];
|
||||
|
||||
/** Shell command matcher. */
|
||||
export type CommandMatcher =
|
||||
| { kind: "exact"; value: string }
|
||||
| { kind: "prefix"; value: string }
|
||||
| { kind: "glob"; value: string };
|
||||
|
||||
/** Per-command bash verdict. */
|
||||
export interface CommandRule {
|
||||
matcher: CommandMatcher;
|
||||
effect: PermissionEffect;
|
||||
}
|
||||
|
||||
/** One permission rule. */
|
||||
export interface PermissionRule {
|
||||
capability: Capability;
|
||||
effect: PermissionEffect;
|
||||
paths?: PathScope;
|
||||
commands?: CommandRule[];
|
||||
}
|
||||
|
||||
/** Permission policy bundle. */
|
||||
export interface PermissionSet {
|
||||
rules: PermissionRule[];
|
||||
fallback: PermissionPosture;
|
||||
}
|
||||
|
||||
/** One sparse agent override in `.ideai/permissions.json`. */
|
||||
export interface AgentPermissionOverride {
|
||||
agentId: string;
|
||||
permissions: PermissionSet;
|
||||
}
|
||||
|
||||
/** Full project permission document. */
|
||||
export interface ProjectPermissions {
|
||||
version: number;
|
||||
projectDefaults?: PermissionSet;
|
||||
agents?: AgentPermissionOverride[];
|
||||
}
|
||||
|
||||
/** Effective project+agent policy. */
|
||||
export interface EffectivePermissions {
|
||||
rules: PermissionRule[];
|
||||
fallback: PermissionPosture;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout (L4) — mirror of the domain `LayoutTree` (ARCHITECTURE §3, §7).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/features/permissions/index.ts
Normal file
1
frontend/src/features/permissions/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./PermissionsPanel";
|
||||
89
frontend/src/features/permissions/permissions.test.tsx
Normal file
89
frontend/src/features/permissions/permissions.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
236
frontend/src/features/permissions/usePermissions.ts
Normal file
236
frontend/src/features/permissions/usePermissions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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(() => {});
|
||||
},
|
||||
}),
|
||||
[],
|
||||
|
||||
@ -29,7 +29,10 @@ import type {
|
||||
MemoryIndexEntry,
|
||||
MemoryLink,
|
||||
MemoryType,
|
||||
EffectivePermissions,
|
||||
PermissionSet,
|
||||
Project,
|
||||
ProjectPermissions,
|
||||
ProfileAvailability,
|
||||
ResumableAgent,
|
||||
Skill,
|
||||
@ -540,6 +543,15 @@ export interface InputGateway {
|
||||
agentId: string,
|
||||
ticket: string,
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Reports whether a **frontend terminal cell** is mounted for `agentId` (`true`
|
||||
* when the cell's write-portal binds its PTY handle, `false` on unmount). The
|
||||
* backend mediator needs this to deliver a turn to a **headless** agent — one
|
||||
* delegated in the background with no cell, where nobody would consume
|
||||
* `delegationReady`: it then writes the task into the PTY itself. An agent with a
|
||||
* mounted cell keeps the write-portal path. Best-effort; never throws into the UI.
|
||||
*/
|
||||
setFrontAttached(agentId: string, attached: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -587,6 +599,28 @@ export interface EmbedderGateway {
|
||||
describeEmbedderEngines(): Promise<EmbedderEngines>;
|
||||
}
|
||||
|
||||
/** Project and agent permission management (LP1). */
|
||||
export interface PermissionGateway {
|
||||
/** Reads the full project permission document. */
|
||||
getProjectPermissions(projectId: string): Promise<ProjectPermissions>;
|
||||
/** Replaces or removes project-level default permissions. */
|
||||
updateProjectPermissions(
|
||||
projectId: string,
|
||||
permissions: PermissionSet | null,
|
||||
): Promise<ProjectPermissions>;
|
||||
/** Replaces or removes one agent-specific override. */
|
||||
updateAgentPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
permissions: PermissionSet | null,
|
||||
): Promise<ProjectPermissions>;
|
||||
/** Resolves effective permissions for one agent. */
|
||||
resolveAgentPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<EffectivePermissions | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full set of gateways the app depends on, injected via the DI provider.
|
||||
* The composition (real vs mock) is chosen in `app/`.
|
||||
@ -605,4 +639,5 @@ export interface Gateways {
|
||||
skill: SkillGateway;
|
||||
memory: MemoryGateway;
|
||||
embedder: EmbedderGateway;
|
||||
permission: PermissionGateway;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user