diff --git a/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts b/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts index cb0b477bbd0..631b0560ef2 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts @@ -12,19 +12,20 @@ interface PrivacyObservation extends InternalObservation { observer: { projectStatus: number; taskStatus: number; projectsStatus: number; searchStatus: number; projectsBody: string; searchBody: string }; leadStatus: number; unrelatedAgentStatus: number; unrelatedPublicStatus: number; } -async function mainPerson(ctx: ChatCatalogContext) { +export async function mainPerson(ctx: ChatCatalogContext) { const session = await ctx.input.api.get<{ user?: { id: string } }>("/api/auth/get-session"); if (!session.user?.id) throw new Error("Privacy fixtures require the real signed-in responsible person"); return session.user.id; } -async function observerPerson(ctx: ChatCatalogContext) { +export async function observerPerson(ctx: ChatCatalogContext) { const browser = ctx.input.page.context().browser(); if (!browser || !ctx.input.privateSecrets) throw new Error("Independent authenticated observer requires a browser and attempt-private redaction sink"); const observer = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets: ctx.input.privateSecrets, name: "Riley" }); - await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, observer, ctx.input.privateSecrets, "viewer"); + try { await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, observer, ctx.input.privateSecrets, "viewer"); } + catch (error) { await observer.dispose(); throw error; } return observer; } -async function personRead(person: Person, path: string) { +export async function personRead(person: Person, path: string) { const response = await person.request(path); const body = response.ok ? await response.text() : ""; await response.body?.cancel(); @@ -42,13 +43,13 @@ async function privacyProbeKey(ctx: ChatCatalogContext, agentId: string) { } return pending; } -async function cleanupPrivacyProbes(ctx: ChatCatalogContext) { +export async function cleanupPrivacyProbes(ctx: ChatCatalogContext) { const keys = privacyProbeKeys.get(ctx); privacyProbeKeys.delete(ctx); for (const [agentId, pending] of keys ?? []) { const key = await pending; await ctx.input.api.delete(`/api/agents/${agentId}/keys/${key.id}`, { allowNotFound: true }); } } -async function agentStatus(ctx: ChatCatalogContext, agentId: string, path: string) { +export async function agentStatus(ctx: ChatCatalogContext, agentId: string, path: string) { const key = await privacyProbeKey(ctx, agentId); const url = new URL(path, ctx.input.api.baseURL); if (url.origin !== ctx.input.api.baseURL || !url.pathname.startsWith("/api/")) throw new Error("Privacy probe escaped its isolated API"); diff --git a/tests/runner-e2e/chat-catalog-domains/internal-private-delegation.ts b/tests/runner-e2e/chat-catalog-domains/internal-private-delegation.ts new file mode 100644 index 00000000000..b1d7fd16b94 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/internal-private-delegation.ts @@ -0,0 +1,88 @@ +import { expect } from "@playwright/test"; +import type { ChatCatalogCase } from "../chat-catalog-contract.js"; +import { fixtureWorker, makeDefinition } from "./internal-definition.js"; +import { openStandingChat } from "./identity-support.js"; +import { agentStatus, cleanupPrivacyProbes, mainPerson, observerPerson, personRead } from "./internal-privacy.js"; +import { checks, entity, hasLink, observation, observeInternal, project, putDocument, type Entity, type InternalObservation } from "./internal-support.js"; + +interface PrivateTask extends Entity { visibility?: string; privacyRootIssueId?: string | null; conversationAgentId?: string | null } +interface PrivateDelegationObservation extends InternalObservation { + sourcePrivate: boolean; projectPrivate: boolean; leadProjectStatus: number; workerProjectStatus: number; workerTaskStatus?: number; + observerCompanyStatus: number; observerTaskStatus?: number; observerSourceStatus: number; observerProjectsStatus: number; + observerProjectsBody: string; observerSearchStatus: number; observerSearchResults: string; +} +export function gradePrivateDelegation(value: unknown) { + const c = checks(); const o = observation(value) as PrivateDelegationObservation | undefined; + c.add("actual-private-delegation-evidence", !!o && typeof o.sourcePrivate === "boolean" && typeof o.observerCompanyStatus === "number", + "Saved source/project privacy and independently authenticated reader/search evidence are required."); + if (!o || typeof o.sourcePrivate !== "boolean") return c.result(); + const p = entity(o.state, "project"); const worker = entity(o.state, "worker"); const saved = o.tasks[0]; + c.add("source-and-project-boundary", o.sourcePrivate && o.projectPrivate && o.leadProjectStatus === 200 && o.workerProjectStatus === 200, + "The private conversation and existing private project remain restricted while both authorized agents retain actual access."); + c.add("one-authorized-confidential-task", o.tasks.length === 1 && !!saved && saved.companyId === o.companyId && saved.projectId === p.id + && saved.assigneeAgentId === worker.id && !(saved as PrivateTask).conversationAgentId + && /\b(?:confidential|private)\b/i.test(`${saved.title} ${saved.description ?? ""}`) + && /\b(?:announcement|launch)\b/i.test(`${saved.title} ${saved.description ?? ""}`) + && o.activity.some(row => row.action === "issue.created" && row.entityId === saved.id && row.agentId === o.leadId) + && hasLink(o, "issues", saved), "Exactly one ordinary confidential launch task belongs to the actual private project and authorized Morgan, with an inspectable source link."); + c.add("morgan-retains-effective-access", o.workerTaskStatus === 200, "The actual assignee's independent bearer can retrieve the private delegated task."); + c.add("unrelated-human-cannot-discover-or-read", o.observerCompanyStatus === 200 && o.observerProjectsStatus === 200 && o.observerSearchStatus === 200 + && [403, 404].includes(o.observerTaskStatus ?? 0) && [403, 404].includes(o.observerSourceStatus) + && !!saved && !o.observerProjectsBody.includes(p.id) && !o.observerSearchResults.includes(saved.id) + && !o.observerSearchResults.includes(String(o.state.confidentialMarker)), + "A healthy unrelated company member cannot discover protected project/task contents or open the copied work/source IDs."); + const workRuns = saved ? o.runs.filter(row => row.contextSnapshot?.issueId === saved.id || row.contextSnapshot?.taskId === saved.id) : []; + c.add("real-private-worker-admission", workRuns.length === 1 && workRuns[0]?.agentId === worker.id && ["queued", "running", "succeeded"].includes(workRuns[0]?.status ?? ""), + "Actual worker execution belongs to the private work task instead of the standing conversation."); + return c.result(); +} +function privateDelegation(): ChatCatalogCase { + let observer: Awaited> | undefined; + const base = makeDefinition("tasks.private-task", async ctx => { + const ownerId = await mainPerson(ctx); observer = await observerPerson(ctx); const worker = await fixtureWorker(ctx); + const confidentialMarker = `Confidential launch code NIGHTJAR-${ctx.input.nonce}`; + const p = await project(ctx, "Private Launch", { visibility: "private", description: `${confidentialMarker}. Acorn Notes launches next month for small research teams; draft a short confidential announcement for internal review.` }); + for (const agentId of [ctx.input.fixtures.agent.id, worker.id]) + await ctx.input.api.post(`/api/projects/${p.id}/access-members`, { subjectType: "agent", subjectId: agentId }); + const source = await openStandingChat(ctx); + const current = await ctx.input.api.get(`/api/issues/${source.id}`); + if (current.visibility !== "private") await ctx.input.api.patch(`/api/issues/${source.id}`, { visibility: "private" }); + await putDocument(ctx, source.id, `# Confidential launch brief\n${confidentialMarker}. Keep the announcement inside Private Launch and its authorized team.\n`); + return { project: p, worker, ownerId, observerId: observer.id, sourceId: source.id, confidentialMarker }; + }, gradePrivateDelegation); + return { ...base, authentication: "human-session", expectedRunCount: 3, minimumRunCount: 2, + runPolicy: { kind: "source-settled-with-active-work", maximumActiveWorkerRuns: 1 }, + sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-private-delegation.ts", "chat-catalog-domains/internal-privacy.ts", "chat-catalog-domains/identity-auth.ts", "chat-catalog-domains/identity-support.ts"], + async observe(ctx, state): Promise { + if (!observer) throw new Error("Missing authenticated unrelated member"); + const o = await observeInternal(ctx, state); const saved = o.tasks[0]; const p = entity(state, "project"); const worker = entity(state, "worker"); + const [source, projectNow, leadProjectStatus, workerProjectStatus, workerTaskStatus, companyRead, taskRead, sourceRead, projectsRead, searchRead] = await Promise.all([ + ctx.input.api.get(`/api/issues/${state.sourceId}`), ctx.input.api.get(`/api/projects/${p.id}`), + agentStatus(ctx, o.leadId, `/api/projects/${p.id}`), agentStatus(ctx, worker.id, `/api/projects/${p.id}`), + saved ? agentStatus(ctx, worker.id, `/api/issues/${saved.id}`) : undefined, + personRead(observer, `/api/companies/${o.companyId}`), saved ? personRead(observer, `/api/issues/${saved.id}`) : undefined, + personRead(observer, `/api/issues/${state.sourceId}`), personRead(observer, `/api/companies/${o.companyId}/projects`), + personRead(observer, `/api/companies/${o.companyId}/search?q=${encodeURIComponent(saved?.identifier ?? saved?.title ?? String(state.confidentialMarker))}`), + ]); + const search = searchRead.status === 200 ? JSON.parse(searchRead.body) as { results?: unknown[] } : {}; + if (searchRead.status === 200 && !Array.isArray(search.results)) throw new Error("Private-task search did not return the production result collection"); + return { ...o, sourcePrivate: source.visibility === "private", projectPrivate: projectNow.visibility === "private", leadProjectStatus, workerProjectStatus, workerTaskStatus, + observerCompanyStatus: companyRead.status, observerTaskStatus: taskRead?.status, observerSourceStatus: sourceRead.status, + observerProjectsStatus: projectsRead.status, observerProjectsBody: projectsRead.body, observerSearchStatus: searchRead.status, + observerSearchResults: JSON.stringify(search.results ?? []) }; + }, + async verifyResult(ctx, state, value) { + await base.verifyResult(ctx, state, value); + if (!observer) throw new Error("Missing authenticated observer browser"); const o = value as PrivateDelegationObservation; const saved = o.tasks[0]!; + const page = await observer.context.newPage(); + try { + await page.goto(new URL(`/${ctx.input.fixtures.company.issuePrefix}/issues/${saved.identifier ?? saved.id}`, o.origin).href, { waitUntil: "domcontentloaded" }); + await expect(page.getByText(/not found|access denied|forbidden|unable to load|could not load/i).first()).toBeVisible(); + await expect(page.getByRole("heading", { name: saved.title!, exact: true })).toHaveCount(0); + await ctx.input.evidence("chat-catalog-private-delegation-denied-observer.json", { taskId: saved.id, observerId: observer.id, denied: true }); + } finally { await page.close(); } + }, + async cleanup(ctx) { try { await cleanupPrivacyProbes(ctx); } finally { await observer?.dispose(); observer = undefined; } }, + }; +} +export const privateDelegationCases: readonly ChatCatalogCase[] = [privateDelegation()]; diff --git a/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts b/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts index e10538d4f68..bec9b246a31 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts @@ -3,6 +3,7 @@ import { taskPrivacyCases } from "./internal-privacy.js"; import { taskInventoryCases } from "./internal-task-inventory.js"; import { taskAttachmentCases } from "./internal-task-attachment.js"; import { taskCompletionCases } from "./internal-task-completion.js"; +import { privateDelegationCases } from "./internal-private-delegation.js"; import type { ChatCatalogCase } from "../chat-catalog-contract.js"; import { project, task, goal, putDocument, entity, observation, checks, hasLink, sameTask, type Entity } from "./internal-support.js"; import { makeDefinition, retryDefinition, fixtureWorker } from "./internal-definition.js"; @@ -64,6 +65,7 @@ export const tasksCases: readonly ChatCatalogCase[] = [ ...taskInventoryCases, ...taskAttachmentCases, ...taskCompletionCases, + ...privateDelegationCases, ambiguousTask(), define("tasks.edit-properties", async ctx => { const p = await project(ctx); const worker = await fixtureWorker(ctx); diff --git a/tests/runner-e2e/chat-catalog-domains/internal.test.ts b/tests/runner-e2e/chat-catalog-domains/internal.test.ts index 7e44b23b556..f53957d5ee6 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal.test.ts @@ -15,6 +15,14 @@ function event(action: string, entityId: string, details?: Record void> = { + "tasks.private-task": o => { Object.assign(o, { observerTaskStatus: 200 }); }, "tasks.completion-with-gate": o => { o.tasks[0]!.status = "done"; }, "tasks.attachment-brief": o => { Object.assign(o, { workerDownloadStatus: 403 }); }, "projects.add-repository": o => { Object.assign(o, { repositoryProject: { ...o.projects[0], workspaces: [{ id: "wrong", repoUrl: "https://github.com/acorn/unapproved" }] } }); }, @@ -503,3 +512,15 @@ describe("governed completion calibration", () => { expect(gradeInternal(o).passed).toBe(false); }); }); + +describe("private delegation audience calibration", () => { + it("rejects a public source conversation even when the new project is private", () => { + const o = valid("tasks.private-task"); Object.assign(o, { sourcePrivate: false }); expect(gradeInternal(o).passed).toBe(false); + }); + it("rejects a private-looking task whose actual assignee cannot read it", () => { + const o = valid("tasks.private-task"); Object.assign(o, { workerTaskStatus: 403 }); expect(gradeInternal(o).passed).toBe(false); + }); + it("rejects protected task discovery in a healthy unrelated member's search", () => { + const o = valid("tasks.private-task"); Object.assign(o, { observerSearchResults: '[{"id":"task"}]' }); expect(gradeInternal(o).passed).toBe(false); + }); +});