diff --git a/frontend/src/adapters/http/httpInvoker.test.ts b/frontend/src/adapters/http/httpInvoker.test.ts
index c58fe6b7..298c5e9f 100644
--- a/frontend/src/adapters/http/httpInvoker.test.ts
+++ b/frontend/src/adapters/http/httpInvoker.test.ts
@@ -264,7 +264,7 @@ describe("request/response gateways preserve the Tauri command contract", () =>
contentMd: "# Body",
version: 1,
defaultProfileId: "p1",
- defaultSystemPermissions: { network: "allow" },
+ defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
defaultMcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
};
const spy = vi
@@ -276,7 +276,7 @@ describe("request/response gateways preserve the Tauri command contract", () =>
await expect(gw.listTemplates()).resolves.toMatchObject([
{
- systemPermissions: { network: "allow" },
+ systemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
},
]);
@@ -285,11 +285,11 @@ describe("request/response gateways preserve the Tauri command contract", () =>
name: "Permissioned",
content: "# Body",
defaultProfileId: "p1",
- systemPermissions: { network: "allow" },
+ systemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}),
).resolves.toMatchObject({
- systemPermissions: { network: "allow" },
+ systemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
});
await expect(
@@ -297,11 +297,11 @@ describe("request/response gateways preserve the Tauri command contract", () =>
name: "Permissioned",
content: "# Body",
defaultProfileId: "p1",
- systemPermissions: { network: "allow" },
+ systemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}),
).resolves.toMatchObject({
- systemPermissions: { network: "allow" },
+ systemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
});
@@ -311,7 +311,7 @@ describe("request/response gateways preserve the Tauri command contract", () =>
content: "# Body",
defaultProfileId: "p1",
defaultAgentName: null,
- defaultSystemPermissions: { network: "allow" },
+ defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
},
});
@@ -322,7 +322,7 @@ describe("request/response gateways preserve the Tauri command contract", () =>
content: "# Body",
defaultProfileId: "p1",
defaultAgentName: null,
- defaultSystemPermissions: { network: "allow" },
+ defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
},
});
diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts
index 5057612e..146104c6 100644
--- a/frontend/src/domain/index.ts
+++ b/frontend/src/domain/index.ts
@@ -820,6 +820,7 @@ export type NetworkPolicy = "allow" | "deny" | "ask";
/** Optional system permission bundle, distinct from file/bash permissions. */
export interface SystemPermissionSet {
network?: NetworkPolicy;
+ crossProjectContactable?: boolean;
}
/** One sparse agent system-permission override. */
diff --git a/frontend/src/features/permissions/PermissionsPanel.tsx b/frontend/src/features/permissions/PermissionsPanel.tsx
index b8e8f79d..ee06eef7 100644
--- a/frontend/src/features/permissions/PermissionsPanel.tsx
+++ b/frontend/src/features/permissions/PermissionsPanel.tsx
@@ -54,6 +54,19 @@ const NETWORK_LABELS: Record = {
ask: "Demande",
};
+const CROSS_PROJECT_HELP =
+ "Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte.";
+
+function systemPermissionsFromDraft(
+ network: NetworkPolicy | "",
+ crossProjectContactable: boolean,
+): SystemPermissionSet | null {
+ const permissions: SystemPermissionSet = {};
+ if (network) permissions.network = network;
+ if (crossProjectContactable) permissions.crossProjectContactable = true;
+ return Object.keys(permissions).length > 0 ? permissions : null;
+}
+
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
const vm = usePermissions(projectId);
const [target, setTarget] = useState({ type: "project" });
@@ -247,6 +260,7 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
? vm.systemDocument?.projectDefault ?? null
: null
}
+ showCrossProjectContactable={target.type === "agent"}
busy={vm.busy}
onSave={(permissions) => void handleSaveNetwork(permissions)}
/>
@@ -320,6 +334,7 @@ interface NetworkPermissionEditorProps {
source: SystemPermissionSet | null;
inherited: SystemPermissionSet | null;
resolved: ResolvedAgentSystemPermissions | null;
+ showCrossProjectContactable: boolean;
busy: boolean;
onSave: (permissions: SystemPermissionSet | null) => void;
}
@@ -329,18 +344,27 @@ function NetworkPermissionEditor({
source,
inherited,
resolved,
+ showCrossProjectContactable,
busy,
onSave,
}: NetworkPermissionEditorProps) {
const current = source?.network ?? "";
+ const currentCrossProjectContactable =
+ source?.crossProjectContactable ?? false;
const [local, setLocal] = useState(current);
+ const [localCrossProjectContactable, setLocalCrossProjectContactable] =
+ useState(currentCrossProjectContactable);
useEffect(() => {
setLocal(current);
- }, [current]);
+ setLocalCrossProjectContactable(currentCrossProjectContactable);
+ }, [current, currentCrossProjectContactable]);
const readOnly = resolved?.control.mode === "readOnly";
- const changed = local !== current;
+ const changed =
+ local !== current ||
+ (showCrossProjectContactable &&
+ localCrossProjectContactable !== currentCrossProjectContactable);
const wanted = resolved?.wanted ?? source?.network ?? inherited?.network ?? null;
const effective = resolved?.effective ?? null;
const runtimeLocked = resolved?.runtimeLock.state === "locked";
@@ -432,12 +456,36 @@ function NetworkPermissionEditor({
)}
+ {showCrossProjectContactable && (
+
+ )}
+
diff --git a/frontend/src/features/permissions/permissions.test.tsx b/frontend/src/features/permissions/permissions.test.tsx
index b8239812..3c66bbc0 100644
--- a/frontend/src/features/permissions/permissions.test.tsx
+++ b/frontend/src/features/permissions/permissions.test.tsx
@@ -102,6 +102,33 @@ describe("PermissionsPanel", () => {
expect(screen.getAllByText("Réseau autorisé").length).toBeGreaterThan(0);
});
+ it("defaults cross-project contactability off and persists the agent checkbox", async () => {
+ const { permission } = await renderPanel();
+
+ fireEvent.click(screen.getByText("Builder"));
+
+ const checkbox = screen.getByLabelText(
+ "Contactable depuis d’autres projets",
+ ) as HTMLInputElement;
+ expect(checkbox.checked).toBe(false);
+ expect(
+ screen.getByText(
+ "Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte.",
+ ),
+ ).toBeTruthy();
+
+ fireEvent.click(checkbox);
+ fireEvent.click(screen.getByRole("button", { name: "Save network" }));
+
+ await waitFor(async () => {
+ const doc = await permission.getProjectSystemPermissions(PROJECT_ID);
+ expect(doc.agents?.[0]).toEqual({
+ agentId: expect.any(String),
+ permissions: { crossProjectContactable: true },
+ });
+ });
+ });
+
it("renders the network control read-only when persisted control says so", async () => {
const agent = new MockAgentGateway();
const permission = new MockPermissionGateway();
diff --git a/frontend/src/features/templates/TemplateEditor.tsx b/frontend/src/features/templates/TemplateEditor.tsx
index bd84a66f..8f009b49 100644
--- a/frontend/src/features/templates/TemplateEditor.tsx
+++ b/frontend/src/features/templates/TemplateEditor.tsx
@@ -39,6 +39,9 @@ const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [
{ value: "deny", label: "Réseau refusé" },
];
+const TEMPLATE_CROSS_PROJECT_HELP =
+ "Les agents créés avec ce template reprendront cette permission.";
+
const ALL_MCP_TOOLS = Array.from(
new Set(MCP_TOOL_GROUPS.flatMap((group) => group.tools)),
);
@@ -61,6 +64,9 @@ export function TemplateEditor({
const [network, setNetwork] = useState<"" | NetworkPolicy>(
template?.systemPermissions?.network ?? "",
);
+ const [crossProjectContactable, setCrossProjectContactable] = useState(
+ template?.systemPermissions?.crossProjectContactable ?? false,
+ );
const [allowedTools, setAllowedTools] = useState(
template?.mcpToolPermissions?.allowedTools ?? [],
);
@@ -85,7 +91,15 @@ export function TemplateEditor({
content,
defaultProfileId,
defaultAgentName: defaultAgentName.trim() || null,
- systemPermissions: network ? { network } : null,
+ systemPermissions:
+ network || crossProjectContactable
+ ? {
+ ...(network ? { network } : {}),
+ ...(crossProjectContactable
+ ? { crossProjectContactable: true }
+ : {}),
+ }
+ : null,
mcpToolPermissions:
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
});
@@ -256,6 +270,25 @@ export function TemplateEditor({
options={NETWORK_OPTIONS}
/>
+
+
diff --git a/frontend/src/features/templates/templates.test.tsx b/frontend/src/features/templates/templates.test.tsx
index ea3cc561..36a39373 100644
--- a/frontend/src/features/templates/templates.test.tsx
+++ b/frontend/src/features/templates/templates.test.tsx
@@ -173,6 +173,7 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
target: { value: "profile-main" },
});
chooseDropdownOption("template system network permission", "Réseau autorisé");
+ fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets"));
fireEvent.click(screen.getByLabelText("template MCP tool idea_ticket_read"));
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
@@ -186,11 +187,30 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
expect(templates[0]).toMatchObject({
defaultAgentName: "Main",
defaultProfileId: "profile-main",
- systemPermissions: { network: "allow" },
+ systemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
});
});
+ it("defaults template cross-project contactability off when the field is absent", async () => {
+ renderTemplatesPanel();
+ await waitForTemplatesIdle();
+
+ fireEvent.click(screen.getByRole("button", { name: "create template" }));
+ await waitFor(() => expect(screen.getByLabelText("template name")).toBeTruthy());
+
+ const checkbox = screen.getByLabelText(
+ "Contactable depuis d’autres projets",
+ ) as HTMLInputElement;
+
+ expect(checkbox.checked).toBe(false);
+ expect(
+ screen.getByText(
+ "Les agents créés avec ce template reprendront cette permission.",
+ ),
+ ).toBeTruthy();
+ });
+
it("the Create button is disabled when the name is empty", async () => {
renderTemplatesPanel();
await waitForTemplatesIdle();