feat(permissions): expose network permission state (#103)
This commit is contained in:
@ -40,7 +40,10 @@ import type {
|
||||
ProjectMcpToolPermissions,
|
||||
ProjectPermissions,
|
||||
ProjectWorkState,
|
||||
ProjectSystemPermissions,
|
||||
ProfileAvailability,
|
||||
ResolvedAgentSystemPermissions,
|
||||
SystemPermissionSet,
|
||||
Skill,
|
||||
SkillScope,
|
||||
Template,
|
||||
@ -364,6 +367,37 @@ export class HttpPermissionGateway implements PermissionGateway {
|
||||
request: { projectId, agentId },
|
||||
});
|
||||
}
|
||||
getProjectSystemPermissions(projectId: string): Promise<ProjectSystemPermissions> {
|
||||
return this.http.invoke<ProjectSystemPermissions>("get_project_system_permissions", {
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
updateProjectSystemPermissions(
|
||||
projectId: string,
|
||||
permissions: SystemPermissionSet | null,
|
||||
): Promise<ProjectSystemPermissions> {
|
||||
return this.http.invoke<ProjectSystemPermissions>("update_project_system_permissions", {
|
||||
request: { projectId, permissions },
|
||||
});
|
||||
}
|
||||
updateAgentSystemPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
permissions: SystemPermissionSet | null,
|
||||
): Promise<ProjectSystemPermissions> {
|
||||
return this.http.invoke<ProjectSystemPermissions>("update_agent_system_permissions", {
|
||||
request: { projectId, agentId, permissions },
|
||||
});
|
||||
}
|
||||
resolveAgentSystemPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<ResolvedAgentSystemPermissions> {
|
||||
return this.http.invoke<ResolvedAgentSystemPermissions>(
|
||||
"resolve_agent_system_permissions",
|
||||
{ request: { projectId, agentId } },
|
||||
);
|
||||
}
|
||||
getMcpToolPermissions(projectId: string): Promise<ProjectMcpToolPermissions> {
|
||||
return this.http.invoke<ProjectMcpToolPermissions>("get_mcp_tool_permissions", { projectId });
|
||||
}
|
||||
|
||||
@ -50,8 +50,10 @@ import type {
|
||||
ProjectMcpToolPermissions,
|
||||
ProjectPermissions,
|
||||
ProjectWorkState,
|
||||
ProjectSystemPermissions,
|
||||
ProfileAvailability,
|
||||
ResumableAgent,
|
||||
ResolvedAgentSystemPermissions,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
Skill,
|
||||
@ -59,6 +61,7 @@ import type {
|
||||
SkillScope,
|
||||
Sprint,
|
||||
Template,
|
||||
SystemPermissionSet,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketCarnet,
|
||||
@ -2186,6 +2189,8 @@ export class MockDeviceGateway implements DeviceGateway {
|
||||
/** In-memory permissions gateway. */
|
||||
export class MockPermissionGateway implements PermissionGateway {
|
||||
private docs = new Map<string, ProjectPermissions>();
|
||||
private systemDocs = new Map<string, ProjectSystemPermissions>();
|
||||
private systemRuntime = new Map<string, ResolvedAgentSystemPermissions>();
|
||||
|
||||
private doc(projectId: string): ProjectPermissions {
|
||||
if (!this.docs.has(projectId)) {
|
||||
@ -2234,6 +2239,72 @@ export class MockPermissionGateway implements PermissionGateway {
|
||||
};
|
||||
}
|
||||
|
||||
private systemDoc(projectId: string): ProjectSystemPermissions {
|
||||
if (!this.systemDocs.has(projectId)) {
|
||||
this.systemDocs.set(projectId, { version: 1, agents: [] });
|
||||
}
|
||||
return this.systemDocs.get(projectId)!;
|
||||
}
|
||||
|
||||
async getProjectSystemPermissions(
|
||||
projectId: string,
|
||||
): Promise<ProjectSystemPermissions> {
|
||||
return structuredClone(this.systemDoc(projectId));
|
||||
}
|
||||
|
||||
async updateProjectSystemPermissions(
|
||||
projectId: string,
|
||||
permissions: SystemPermissionSet | null,
|
||||
): Promise<ProjectSystemPermissions> {
|
||||
const doc = this.systemDoc(projectId);
|
||||
if (permissions && Object.keys(permissions).length > 0) {
|
||||
doc.projectDefault = structuredClone(permissions);
|
||||
} else {
|
||||
delete doc.projectDefault;
|
||||
}
|
||||
return structuredClone(doc);
|
||||
}
|
||||
|
||||
async updateAgentSystemPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
permissions: SystemPermissionSet | null,
|
||||
): Promise<ProjectSystemPermissions> {
|
||||
const doc = this.systemDoc(projectId);
|
||||
const agents = (doc.agents ?? []).filter((entry) => entry.agentId !== agentId);
|
||||
if (permissions && Object.keys(permissions).length > 0) {
|
||||
agents.push({ agentId, permissions: structuredClone(permissions) });
|
||||
}
|
||||
doc.agents = agents;
|
||||
return structuredClone(doc);
|
||||
}
|
||||
|
||||
async resolveAgentSystemPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<ResolvedAgentSystemPermissions> {
|
||||
const runtime = this.systemRuntime.get(agentId);
|
||||
if (runtime) return structuredClone(runtime);
|
||||
const doc = this.systemDoc(projectId);
|
||||
const wanted =
|
||||
doc.agents?.find((entry) => entry.agentId === agentId)?.permissions.network ??
|
||||
doc.projectDefault?.network ??
|
||||
null;
|
||||
return {
|
||||
wanted,
|
||||
effective: wanted ?? "ask",
|
||||
runtimeLock: { state: "none" },
|
||||
control: { mode: "editable" },
|
||||
};
|
||||
}
|
||||
|
||||
setResolvedAgentSystemPermissions(
|
||||
agentId: string,
|
||||
resolved: ResolvedAgentSystemPermissions,
|
||||
): void {
|
||||
this.systemRuntime.set(agentId, structuredClone(resolved));
|
||||
}
|
||||
|
||||
// ── MCP tool permissions (ticket #82) — mirrors the backend catalogue in
|
||||
// `crates/infrastructure/src/orchestrator/mcp/tools.rs`, a separate durable
|
||||
// document from the file/command permissions above. ─────────────────────
|
||||
|
||||
@ -6,6 +6,9 @@ import type {
|
||||
PermissionSet,
|
||||
ProjectMcpToolPermissions,
|
||||
ProjectPermissions,
|
||||
ProjectSystemPermissions,
|
||||
ResolvedAgentSystemPermissions,
|
||||
SystemPermissionSet,
|
||||
} from "@/domain";
|
||||
import type { PermissionGateway } from "@/ports";
|
||||
|
||||
@ -43,6 +46,41 @@ export class TauriPermissionGateway implements PermissionGateway {
|
||||
});
|
||||
}
|
||||
|
||||
getProjectSystemPermissions(projectId: string): Promise<ProjectSystemPermissions> {
|
||||
return invoke<ProjectSystemPermissions>("get_project_system_permissions", {
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
updateProjectSystemPermissions(
|
||||
projectId: string,
|
||||
permissions: SystemPermissionSet | null,
|
||||
): Promise<ProjectSystemPermissions> {
|
||||
return invoke<ProjectSystemPermissions>("update_project_system_permissions", {
|
||||
request: { projectId, permissions },
|
||||
});
|
||||
}
|
||||
|
||||
updateAgentSystemPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
permissions: SystemPermissionSet | null,
|
||||
): Promise<ProjectSystemPermissions> {
|
||||
return invoke<ProjectSystemPermissions>("update_agent_system_permissions", {
|
||||
request: { projectId, agentId, permissions },
|
||||
});
|
||||
}
|
||||
|
||||
resolveAgentSystemPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<ResolvedAgentSystemPermissions> {
|
||||
return invoke<ResolvedAgentSystemPermissions>(
|
||||
"resolve_agent_system_permissions",
|
||||
{ request: { projectId, agentId } },
|
||||
);
|
||||
}
|
||||
|
||||
getMcpToolPermissions(projectId: string): Promise<ProjectMcpToolPermissions> {
|
||||
return invoke<ProjectMcpToolPermissions>("get_mcp_tool_permissions", {
|
||||
projectId,
|
||||
|
||||
@ -748,6 +748,48 @@ export interface EffectivePermissions {
|
||||
fallback: PermissionPosture;
|
||||
}
|
||||
|
||||
/** Wanted/effective network policy for system permissions. */
|
||||
export type NetworkPolicy = "allow" | "deny" | "ask";
|
||||
|
||||
/** Optional system permission bundle, distinct from file/bash permissions. */
|
||||
export interface SystemPermissionSet {
|
||||
network?: NetworkPolicy;
|
||||
}
|
||||
|
||||
/** One sparse agent system-permission override. */
|
||||
export interface AgentSystemPermissionOverride {
|
||||
agentId: string;
|
||||
permissions: SystemPermissionSet;
|
||||
}
|
||||
|
||||
/** Full project system-permission document. */
|
||||
export interface ProjectSystemPermissions {
|
||||
version: number;
|
||||
projectDefault?: SystemPermissionSet;
|
||||
agents?: AgentSystemPermissionOverride[];
|
||||
}
|
||||
|
||||
/** Runtime lock details for resolved system permissions. */
|
||||
export interface RuntimeLock {
|
||||
state: "none" | "locked";
|
||||
source?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Whether the UI can edit the wanted system policy. */
|
||||
export interface SystemPermissionControl {
|
||||
mode: "editable" | "readOnly";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Resolved wanted/effective system permissions for one agent. */
|
||||
export interface ResolvedAgentSystemPermissions {
|
||||
wanted: NetworkPolicy | null;
|
||||
effective: NetworkPolicy;
|
||||
runtimeLock: RuntimeLock;
|
||||
control: SystemPermissionControl;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP tool permissions (ticket #82) — distinct from the file/command
|
||||
// permissions above: an allowlist of exact MCP tool names, applied by the MCP
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
* the existing `createAgent` path with the selected profile.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
||||
import { TerminalView } from "@/features/terminals/TerminalView";
|
||||
@ -25,6 +25,7 @@ import { useAgents } from "./useAgents";
|
||||
import { correlateModelServerStatus } from "./modelServerLaunch";
|
||||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||||
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||
import type { ResolvedAgentSystemPermissions } from "@/domain";
|
||||
|
||||
export interface AgentsPanelProps {
|
||||
/** The project whose agents to manage. */
|
||||
@ -41,6 +42,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
const drift = useDrift(projectId);
|
||||
const gateways = useGateways();
|
||||
const templateGw = gateways.template ?? null;
|
||||
const permissionGw = gateways.permission ?? null;
|
||||
|
||||
// Create form state
|
||||
const [newName, setNewName] = useState("");
|
||||
@ -130,6 +132,45 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
const [agentSessions, setAgentSessions] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [networkByAgent, setNetworkByAgent] = useState<
|
||||
Record<string, ResolvedAgentSystemPermissions>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!permissionGw || vm.agents.length === 0) {
|
||||
setNetworkByAgent({});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void Promise.all(
|
||||
vm.agents.map(async (candidate) => {
|
||||
try {
|
||||
return [
|
||||
candidate.id,
|
||||
await permissionGw.resolveAgentSystemPermissions(
|
||||
projectId,
|
||||
candidate.id,
|
||||
),
|
||||
] as const;
|
||||
} catch {
|
||||
return [candidate.id, null] as const;
|
||||
}
|
||||
}),
|
||||
).then((pairs) => {
|
||||
if (cancelled) return;
|
||||
setNetworkByAgent(
|
||||
Object.fromEntries(
|
||||
pairs.filter(
|
||||
(pair): pair is readonly [string, ResolvedAgentSystemPermissions] =>
|
||||
pair[1] !== null,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [permissionGw, projectId, vm.agents]);
|
||||
|
||||
/**
|
||||
* Pending profile change awaiting confirmation: the target agent + the chosen
|
||||
@ -342,6 +383,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
vm.modelServerStatusByServer,
|
||||
);
|
||||
const launchFailure = vm.launchFailureByAgent[a.id];
|
||||
const network = networkByAgent[a.id] ?? null;
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
@ -379,6 +421,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
{delegationSource}
|
||||
</span>
|
||||
)}
|
||||
{network && <NetworkPermissionBadge state={network} />}
|
||||
</span>
|
||||
<span className="text-xs text-muted">{profileName}</span>
|
||||
{live && (
|
||||
@ -513,6 +556,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
onSessionId={(sid) =>
|
||||
setAgentSessions((prev) => ({ ...prev, [activeAgentId]: sid }))
|
||||
}
|
||||
systemPermissions={networkByAgent[activeAgentId] ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -667,3 +711,40 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkPermissionBadge({
|
||||
state,
|
||||
}: {
|
||||
state: ResolvedAgentSystemPermissions;
|
||||
}) {
|
||||
const locked = state.runtimeLock.state === "locked";
|
||||
const label = locked
|
||||
? "Verrouillé"
|
||||
: state.effective === "allow"
|
||||
? "Autorisé"
|
||||
: state.effective === "deny"
|
||||
? "Interdit"
|
||||
: "Demande";
|
||||
return (
|
||||
<span
|
||||
aria-label={`network ${label}`}
|
||||
title={
|
||||
locked
|
||||
? (state.runtimeLock.reason ?? "Réseau verrouillé par le runtime")
|
||||
: `État effectif : ${label}`
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
locked
|
||||
? "bg-warning/15 text-warning"
|
||||
: state.effective === "allow"
|
||||
? "bg-success/15 text-success"
|
||||
: state.effective === "deny"
|
||||
? "bg-danger/15 text-danger"
|
||||
: "bg-primary/15 text-primary",
|
||||
)}
|
||||
>
|
||||
Réseau: {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockPermissionGateway,
|
||||
MockProfileGateway,
|
||||
MockSystemGateway,
|
||||
MockTemplateGateway,
|
||||
@ -44,13 +45,15 @@ function renderPanel(
|
||||
profile: MockProfileGateway = new MockProfileGateway(),
|
||||
projectRoot = "/home/me/proj",
|
||||
template?: MockTemplateGateway,
|
||||
permission: MockPermissionGateway = new MockPermissionGateway(),
|
||||
) {
|
||||
const tmpl = template ?? new MockTemplateGateway(agent);
|
||||
const gateways = { agent, profile, template: tmpl } as unknown as Gateways;
|
||||
const gateways = { agent, profile, template: tmpl, permission } as unknown as Gateways;
|
||||
return {
|
||||
agent,
|
||||
profile,
|
||||
template: tmpl,
|
||||
permission,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AgentsPanel projectId={PROJECT_ID} projectRoot={projectRoot} />
|
||||
@ -108,6 +111,27 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
expect(agent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the resolved network permission badge for an agent", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const permission = new MockPermissionGateway();
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Builder",
|
||||
profileId: "p1",
|
||||
});
|
||||
permission.setResolvedAgentSystemPermissions(created.id, {
|
||||
wanted: "allow",
|
||||
effective: "deny",
|
||||
runtimeLock: { state: "locked", reason: "Runtime locked." },
|
||||
control: { mode: "readOnly", reason: "Runtime locked." },
|
||||
});
|
||||
|
||||
renderPanel(agent, new MockProfileGateway(), "/home/me/proj", undefined, permission);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Réseau: Verrouillé")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("the Create button is disabled when the name is empty", async () => {
|
||||
renderPanel();
|
||||
await waitForIdle();
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Button, Panel, Spinner, cn } from "@/shared";
|
||||
import type { PermissionSet, PermissionPosture } from "@/domain";
|
||||
import type {
|
||||
NetworkPolicy,
|
||||
PermissionSet,
|
||||
PermissionPosture,
|
||||
ResolvedAgentSystemPermissions,
|
||||
SystemPermissionSet,
|
||||
} from "@/domain";
|
||||
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
|
||||
import {
|
||||
type CapabilityChoice,
|
||||
@ -42,6 +48,12 @@ const POSTURE_LABELS: Record<PermissionPosture, string> = {
|
||||
deny: "Deny",
|
||||
};
|
||||
|
||||
const NETWORK_LABELS: Record<NetworkPolicy, string> = {
|
||||
allow: "Autorisé",
|
||||
deny: "Interdit",
|
||||
ask: "Demande",
|
||||
};
|
||||
|
||||
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
const vm = usePermissions(projectId);
|
||||
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
|
||||
@ -51,6 +63,16 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
? vm.rows.find((row) => row.agent.id === target.agentId) ?? null
|
||||
: null;
|
||||
const activePolicy = selectedAgent?.override ?? vm.document?.projectDefaults ?? null;
|
||||
const activeSystemSet =
|
||||
target.type === "project"
|
||||
? vm.systemDocument?.projectDefault ?? null
|
||||
: selectedAgent?.systemOverride ??
|
||||
vm.systemDocument?.projectDefault ??
|
||||
null;
|
||||
const activeResolvedSystem =
|
||||
target.type === "project"
|
||||
? vm.rows[0]?.resolvedSystem ?? null
|
||||
: selectedAgent?.resolvedSystem ?? null;
|
||||
const activeDraft = target.type === "project"
|
||||
? vm.projectDraft
|
||||
: draftFromSet(selectedAgent?.override ?? vm.document?.projectDefaults ?? null);
|
||||
@ -75,6 +97,14 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveNetwork(permissions: SystemPermissionSet | null) {
|
||||
if (target.type === "project") {
|
||||
await vm.saveProjectSystemPermissions(permissions);
|
||||
} else {
|
||||
await vm.saveAgentSystemPermissions(target.agentId, permissions);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Permissions"
|
||||
@ -202,6 +232,24 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
onSave={(draft) => void handleSave(draft)}
|
||||
onClear={() => void handleClear()}
|
||||
/>
|
||||
|
||||
<NetworkPermissionEditor
|
||||
key={`network-${target.type === "project" ? "project" : target.agentId}`}
|
||||
title={
|
||||
target.type === "project"
|
||||
? "Réseau — defaults projet"
|
||||
: `Réseau — ${target.agentName}`
|
||||
}
|
||||
source={activeSystemSet}
|
||||
resolved={activeResolvedSystem}
|
||||
inherited={
|
||||
target.type === "agent" && selectedAgent?.systemOverride == null
|
||||
? vm.systemDocument?.projectDefault ?? null
|
||||
: null
|
||||
}
|
||||
busy={vm.busy}
|
||||
onSave={(permissions) => void handleSaveNetwork(permissions)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@ -267,6 +315,138 @@ function PolicyCard({
|
||||
);
|
||||
}
|
||||
|
||||
interface NetworkPermissionEditorProps {
|
||||
title: string;
|
||||
source: SystemPermissionSet | null;
|
||||
inherited: SystemPermissionSet | null;
|
||||
resolved: ResolvedAgentSystemPermissions | null;
|
||||
busy: boolean;
|
||||
onSave: (permissions: SystemPermissionSet | null) => void;
|
||||
}
|
||||
|
||||
function NetworkPermissionEditor({
|
||||
title,
|
||||
source,
|
||||
inherited,
|
||||
resolved,
|
||||
busy,
|
||||
onSave,
|
||||
}: NetworkPermissionEditorProps) {
|
||||
const current = source?.network ?? "";
|
||||
const [local, setLocal] = useState<NetworkPolicy | "">(current);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(current);
|
||||
}, [current]);
|
||||
|
||||
const readOnly = resolved?.control.mode === "readOnly";
|
||||
const changed = local !== current;
|
||||
const wanted = resolved?.wanted ?? source?.network ?? inherited?.network ?? null;
|
||||
const effective = resolved?.effective ?? null;
|
||||
const runtimeLocked = resolved?.runtimeLock.state === "locked";
|
||||
const lockReason = resolved?.runtimeLock.reason ?? resolved?.control.reason ?? null;
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-border bg-surface">
|
||||
<header className="border-b border-border px-3 py-2.5">
|
||||
<h4 className="text-sm font-semibold text-content">{title}</h4>
|
||||
<p className="text-xs text-muted">
|
||||
Politique voulue, état effectif et verrou runtime.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
|
||||
<StatusLine
|
||||
label="Wanted"
|
||||
value={wanted ? `Réseau ${NETWORK_LABELS[wanted].toLowerCase()}` : "Non configuré"}
|
||||
/>
|
||||
<StatusLine
|
||||
label="Effective"
|
||||
value={
|
||||
effective
|
||||
? `Réseau ${NETWORK_LABELS[effective].toLowerCase()}`
|
||||
: "Indisponible"
|
||||
}
|
||||
/>
|
||||
<StatusLine
|
||||
label="Runtime"
|
||||
value={runtimeLocked ? "Verrouillé par le runtime" : "Aucun verrou connu"}
|
||||
/>
|
||||
<StatusLine
|
||||
label="Control"
|
||||
value={readOnly ? "Lecture seule" : "Modifiable"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{readOnly && (
|
||||
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
{lockReason ?? "Le runtime actif ne permet pas de modifier cette permission."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Réseau</span>
|
||||
<select
|
||||
aria-label={`${title} network`}
|
||||
value={local}
|
||||
disabled={busy || readOnly}
|
||||
onChange={(e) => setLocal(e.target.value as NetworkPolicy | "")}
|
||||
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="">Non configuré</option>
|
||||
<option value="allow">Réseau autorisé</option>
|
||||
<option value="deny">Réseau interdit</option>
|
||||
<option value="ask">Demande d'autorisation</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{inherited?.network && !source?.network && (
|
||||
<p className="text-xs text-muted">
|
||||
Hérite du projet : Réseau {NETWORK_LABELS[inherited.network].toLowerCase()}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="Clear network"
|
||||
disabled={busy || readOnly || !source?.network}
|
||||
onClick={() => onSave(null)}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
aria-label="Save network"
|
||||
disabled={busy || readOnly || !changed}
|
||||
loading={busy && changed}
|
||||
onClick={() => onSave(local ? { network: local } : null)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusLine({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/70 bg-raised px-3 py-2">
|
||||
<span className="block text-[11px] font-medium uppercase text-faint">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-content">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PermissionEditorProps {
|
||||
title: string;
|
||||
draft: PolicyDraft;
|
||||
|
||||
@ -86,4 +86,58 @@ describe("PermissionsPanel", () => {
|
||||
expect(doc.agents).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("saves project network defaults in the separate system permissions document", async () => {
|
||||
const { permission } = await renderPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Réseau — defaults projet network"), {
|
||||
target: { value: "allow" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save network" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const doc = await permission.getProjectSystemPermissions(PROJECT_ID);
|
||||
expect(doc.projectDefault?.network).toBe("allow");
|
||||
});
|
||||
expect(screen.getAllByText("Réseau autorisé").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders the network control read-only when the resolved runtime says so", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const permission = new MockPermissionGateway();
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Builder",
|
||||
profileId: "p1",
|
||||
});
|
||||
permission.setResolvedAgentSystemPermissions(created.id, {
|
||||
wanted: "allow",
|
||||
effective: "deny",
|
||||
runtimeLock: {
|
||||
state: "locked",
|
||||
source: "external-runtime",
|
||||
reason: "Runtime network is locked.",
|
||||
},
|
||||
control: { mode: "readOnly", reason: "Runtime network is locked." },
|
||||
});
|
||||
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("Verrouillé par le runtime")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("Runtime network is locked.")).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByLabelText("Réseau — defaults projet network") as HTMLSelectElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,6 +9,9 @@ import type {
|
||||
PermissionRule,
|
||||
PermissionSet,
|
||||
ProjectPermissions,
|
||||
ProjectSystemPermissions,
|
||||
ResolvedAgentSystemPermissions,
|
||||
SystemPermissionSet,
|
||||
} from "@/domain";
|
||||
|
||||
export type CapabilityChoice = "none" | PermissionEffect;
|
||||
@ -24,12 +27,15 @@ export interface PolicyDraft {
|
||||
export interface AgentPermissionRow {
|
||||
agent: Agent;
|
||||
override: PermissionSet | null;
|
||||
systemOverride: SystemPermissionSet | null;
|
||||
resolvedSystem: ResolvedAgentSystemPermissions | null;
|
||||
}
|
||||
|
||||
export interface PermissionsViewModel {
|
||||
agents: Agent[];
|
||||
rows: AgentPermissionRow[];
|
||||
document: ProjectPermissions | null;
|
||||
systemDocument: ProjectSystemPermissions | null;
|
||||
projectDraft: PolicyDraft;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
@ -38,6 +44,13 @@ export interface PermissionsViewModel {
|
||||
clearProjectDefaults: () => Promise<void>;
|
||||
saveAgentOverride: (agentId: string, draft: PolicyDraft) => Promise<void>;
|
||||
clearAgentOverride: (agentId: string) => Promise<void>;
|
||||
saveProjectSystemPermissions: (
|
||||
permissions: SystemPermissionSet | null,
|
||||
) => Promise<void>;
|
||||
saveAgentSystemPermissions: (
|
||||
agentId: string,
|
||||
permissions: SystemPermissionSet | null,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_DRAFT: PolicyDraft = {
|
||||
@ -114,6 +127,11 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
const { agent, permission } = useGateways();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [document, setDocument] = useState<ProjectPermissions | null>(null);
|
||||
const [systemDocument, setSystemDocument] =
|
||||
useState<ProjectSystemPermissions | null>(null);
|
||||
const [resolvedSystemByAgent, setResolvedSystemByAgent] = useState<
|
||||
Record<string, ResolvedAgentSystemPermissions>
|
||||
>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -121,12 +139,34 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [agentList, permissionDoc] = await Promise.all([
|
||||
const [agentList, permissionDoc, systemPermissionDoc] = await Promise.all([
|
||||
agent.listAgents(projectId),
|
||||
permission.getProjectPermissions(projectId),
|
||||
permission.getProjectSystemPermissions(projectId),
|
||||
]);
|
||||
const resolvedPairs = await Promise.all(
|
||||
agentList.map(async (candidate) => {
|
||||
try {
|
||||
return [
|
||||
candidate.id,
|
||||
await permission.resolveAgentSystemPermissions(projectId, candidate.id),
|
||||
] as const;
|
||||
} catch {
|
||||
return [candidate.id, null] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
setAgents(agentList);
|
||||
setDocument(permissionDoc);
|
||||
setSystemDocument(systemPermissionDoc);
|
||||
setResolvedSystemByAgent(
|
||||
Object.fromEntries(
|
||||
resolvedPairs.filter(
|
||||
(pair): pair is readonly [string, ResolvedAgentSystemPermissions] =>
|
||||
pair[1] !== null,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
@ -142,11 +182,19 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
const overrides = new Map(
|
||||
(document?.agents ?? []).map((entry) => [entry.agentId, entry.permissions]),
|
||||
);
|
||||
const systemOverrides = new Map(
|
||||
(systemDocument?.agents ?? []).map((entry) => [
|
||||
entry.agentId,
|
||||
entry.permissions,
|
||||
]),
|
||||
);
|
||||
return agents.map((candidate) => ({
|
||||
agent: candidate,
|
||||
override: overrides.get(candidate.id) ?? null,
|
||||
systemOverride: systemOverrides.get(candidate.id) ?? null,
|
||||
resolvedSystem: resolvedSystemByAgent[candidate.id] ?? null,
|
||||
}));
|
||||
}, [agents, document]);
|
||||
}, [agents, document, systemDocument, resolvedSystemByAgent]);
|
||||
|
||||
const projectDraft = useMemo(
|
||||
() => draftFromSet(document?.projectDefaults ?? null),
|
||||
@ -220,10 +268,52 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
[permission, projectId],
|
||||
);
|
||||
|
||||
const saveProjectSystemPermissions = useCallback(
|
||||
async (permissionsToSave: SystemPermissionSet | null) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await permission.updateProjectSystemPermissions(
|
||||
projectId,
|
||||
permissionsToSave,
|
||||
);
|
||||
setSystemDocument(next);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[permission, projectId, refresh],
|
||||
);
|
||||
|
||||
const saveAgentSystemPermissions = useCallback(
|
||||
async (agentId: string, permissionsToSave: SystemPermissionSet | null) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await permission.updateAgentSystemPermissions(
|
||||
projectId,
|
||||
agentId,
|
||||
permissionsToSave,
|
||||
);
|
||||
setSystemDocument(next);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[permission, projectId, refresh],
|
||||
);
|
||||
|
||||
return {
|
||||
agents,
|
||||
rows,
|
||||
document,
|
||||
systemDocument,
|
||||
projectDraft,
|
||||
busy,
|
||||
error,
|
||||
@ -232,5 +322,7 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
clearProjectDefaults,
|
||||
saveAgentOverride,
|
||||
clearAgentOverride,
|
||||
saveProjectSystemPermissions,
|
||||
saveAgentSystemPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
@ -55,6 +55,22 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a non-blocking network banner when system permissions are locked", () => {
|
||||
renderView(new MockTerminalGateway(), "/home/me/proj", {
|
||||
systemPermissions: {
|
||||
wanted: "allow",
|
||||
effective: "deny",
|
||||
runtimeLock: { state: "locked", reason: "Runtime network locked." },
|
||||
control: { mode: "readOnly", reason: "Runtime network locked." },
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("terminal-network-banner").textContent).toContain(
|
||||
"Réseau verrouillé par le runtime.",
|
||||
);
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a terminal through the gateway with the given cwd", async () => {
|
||||
const gw = new MockTerminalGateway();
|
||||
const openSpy = vi.spyOn(gw, "openTerminal");
|
||||
|
||||
@ -39,6 +39,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { useGateways } from "@/app/di";
|
||||
import type { ResolvedAgentSystemPermissions } from "@/domain";
|
||||
import type {
|
||||
OpenTerminalOptions,
|
||||
ReattachResult,
|
||||
@ -113,6 +114,8 @@ interface TerminalViewProps {
|
||||
* it never remounts/reopens the terminal.
|
||||
*/
|
||||
refitSignal?: number;
|
||||
/** Optional resolved system permissions for this agent/cell. */
|
||||
systemPermissions?: ResolvedAgentSystemPermissions | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -144,6 +147,7 @@ export function TerminalView({
|
||||
portal,
|
||||
onReady,
|
||||
refitSignal,
|
||||
systemPermissions,
|
||||
}: TerminalViewProps) {
|
||||
const { terminal } = useGateways();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -414,6 +418,15 @@ export function TerminalView({
|
||||
refitRef.current?.();
|
||||
}, [refitSignal]);
|
||||
|
||||
const showNetworkBanner =
|
||||
systemPermissions != null &&
|
||||
(systemPermissions.runtimeLock.state === "locked" ||
|
||||
systemPermissions.effective === "deny");
|
||||
const networkReason =
|
||||
systemPermissions?.runtimeLock.reason ??
|
||||
systemPermissions?.control.reason ??
|
||||
"Le réseau est interdit pour cette cellule.";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="terminal-view"
|
||||
@ -427,6 +440,34 @@ export function TerminalView({
|
||||
{/* xterm mounts into this inner node; the error banner is a sibling so
|
||||
React never fights xterm over the same subtree. */}
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||
{showNetworkBanner && (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="terminal-network-banner"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
padding: "0.5rem 0.75rem",
|
||||
border: "1px solid rgba(245, 158, 11, 0.45)",
|
||||
borderRadius: 6,
|
||||
background: "rgba(24, 24, 27, 0.94)",
|
||||
color: "var(--color-warning, #f59e0b)",
|
||||
fontSize: 12,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{systemPermissions.runtimeLock.state === "locked"
|
||||
? "Réseau verrouillé par le runtime."
|
||||
: "Réseau interdit pour cet agent."}{" "}
|
||||
{networkReason}
|
||||
</div>
|
||||
)}
|
||||
{openError && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@ -52,13 +52,16 @@ import type {
|
||||
Project,
|
||||
ProjectPermissions,
|
||||
ProjectWorkState,
|
||||
ProjectSystemPermissions,
|
||||
ProfileAvailability,
|
||||
ResumableAgent,
|
||||
ResolvedAgentSystemPermissions,
|
||||
ReplyChunk,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
Skill,
|
||||
SkillScope,
|
||||
SystemPermissionSet,
|
||||
Sprint,
|
||||
Template,
|
||||
TerminalSession,
|
||||
@ -861,6 +864,24 @@ export interface PermissionGateway {
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<EffectivePermissions | null>;
|
||||
/** Reads the full project system-permission document. */
|
||||
getProjectSystemPermissions(projectId: string): Promise<ProjectSystemPermissions>;
|
||||
/** Replaces or removes project-level default system permissions. */
|
||||
updateProjectSystemPermissions(
|
||||
projectId: string,
|
||||
permissions: SystemPermissionSet | null,
|
||||
): Promise<ProjectSystemPermissions>;
|
||||
/** Replaces or removes one agent-specific system-permission override. */
|
||||
updateAgentSystemPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
permissions: SystemPermissionSet | null,
|
||||
): Promise<ProjectSystemPermissions>;
|
||||
/** Resolves wanted/effective runtime-constrained system permissions. */
|
||||
resolveAgentSystemPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<ResolvedAgentSystemPermissions>;
|
||||
/**
|
||||
* Reads the project's durable MCP tool permission document plus the
|
||||
* backend-canonical catalogue classification (ticket #82). Distinct
|
||||
|
||||
Reference in New Issue
Block a user