mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Evaluate confidential delegation across real private audiences
This commit is contained in:
1 parent
f8b2bb3b49
commit
caa71d29c8
4 files changed
+118
-6
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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<ReturnType<typeof observerPerson>> | 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<PrivateTask>(`/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<PrivateDelegationObservation> {
|
||||
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<PrivateTask>(`/api/issues/${state.sourceId}`), ctx.input.api.get<PrivateTask>(`/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()];
|
||||
@@ -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);
|
||||
|
||||
@@ -15,6 +15,14 @@ function event(action: string, entityId: string, details?: Record<string, unknow
|
||||
function valid(scenario: string): InternalObservation {
|
||||
const o = base(scenario);
|
||||
switch (scenario) {
|
||||
case "tasks.private-task": {
|
||||
o.state.worker = { id: "morgan" }; o.state.confidentialMarker = "Confidential marker";
|
||||
o.tasks[0] = { ...o.tasks[0]!, title: "Confidential launch announcement", assigneeAgentId: "morgan" };
|
||||
o.activity = [event("issue.created", "task")];
|
||||
o.runs.push({ id: "private-worker", companyId: "company", agentId: "morgan", status: "running", contextSnapshot: { issueId: "task" } });
|
||||
Object.assign(o, { sourcePrivate: true, projectPrivate: true, leadProjectStatus: 200, workerProjectStatus: 200, workerTaskStatus: 200,
|
||||
observerCompanyStatus: 200, observerTaskStatus: 404, observerSourceStatus: 404, observerProjectsStatus: 200, observerProjectsBody: "[]", observerSearchStatus: 200, observerSearchResults: "[]" }); break;
|
||||
}
|
||||
case "tasks.completion-with-gate": {
|
||||
const policy = { stages: [{ id: "review-stage", type: "review", participants: [{ type: "user", userId: "riley" }] }] };
|
||||
const t = { ...o.tasks[0]!, status: "in_review", reviewPolicy: "human_only", executionPolicy: policy,
|
||||
@@ -185,6 +193,7 @@ function valid(scenario: string): InternalObservation {
|
||||
return o;
|
||||
}
|
||||
const corrupt: Record<string, (o: InternalObservation) => 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user