fix(evals): follow secure personal-value confirmation before consumer work

Co-Authored-By: Paperclip <paperclip@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-05 13:42:54 -06:00
1 parent d1beaac55b
commit fb3d13f1de
8 files changed
+175 -23

No files matched your search

+27 -2
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { askUserQuestionsPayloadSchema } from "../../packages/shared/src/validators/issue.js";
import type { AskUserQuestionsInteraction } from "../../packages/shared/src/types/issue.js";
import type { RequestConfirmationInteraction, AskUserQuestionsInteraction } from "../../packages/shared/src/types/issue.js";
import type { ChatFlowInput, ChatIssue, ChatRun } from "./chat-flow.js";
import type { CredentialName } from "./types.js";
import type { ChatExternalInputRequirement } from "./chat-private-inputs.js";
@@ -245,6 +245,7 @@ export function chatCatalogHumanHistoryMatches(input: {
}): boolean {
const count = input.implementation.setupHumanMessageCount ?? 0;
return (!input.questionAnswer || chatCatalogAllowsHumanQuestionAnswer(input.implementation.scenarioId ?? ""))
&& (!input.questionAnswer?.personalSecretConfirmation || input.implementation.scenarioId === "secrets.personal-secret")
&& (!(input.questionAnswer?.companyEnvironmentNameAnswer||input.questionAnswer?.companySavedSecretNameAnswer) || input.implementation.scenarioId === "secrets.configure-company-secret")
&& validChatCatalogHumanCounts(input.implementation)
&& input.boundary.humanCommentIds.length === count
@@ -269,7 +270,7 @@ export function chatCatalogSavedSecretNameIntent(text:string):boolean {
&&/\b(?:saved?|stored?|created?|added|used?|under)\b/i.test(prompt)
&&!/\b(?:value|plaintext|password|CEO|Riley|Jordan|another|other person)\b/i.test(prompt);
}
export interface ChatCatalogExpectedHumanQuestionAnswer { interactionId: string; sourceRunId: string; userId: string; companyEnvironmentNameAnswer?: { questionId: string; payloadHash: string; value: "DEPLOYMENT_API_KEY" }; companySavedSecretNameAnswer?: { questionId: string; payloadHash: string; value: string; secretId: string } }
export interface ChatCatalogExpectedHumanQuestionAnswer { interactionId: string; sourceRunId: string; userId: string; personalSecretConfirmation?: { definitionId: string; secretId: string; payloadHash: string }; companyEnvironmentNameAnswer?: { questionId: string; payloadHash: string; value: "DEPLOYMENT_API_KEY" }; companySavedSecretNameAnswer?: { questionId: string; payloadHash: string; value: string; secretId: string } }
export interface ChatCatalogVerifiedHumanQuestionAnswer extends ChatCatalogExpectedHumanQuestionAnswer { continuationRunId: string; resolvedAt: string }
export interface ChatCatalogHumanQuestionAudit {
companyId?: string; entityId?: string; action?: string; actorType?: string; actorId?: string;
@@ -278,10 +279,34 @@ export interface ChatCatalogHumanQuestionAudit {
export function verifyChatCatalogHumanQuestionAnswer(input: {
expected: ChatCatalogExpectedHumanQuestionAnswer; companyId: string; issueId: string; agentId: string;
canonicalComment: { id: string; authorUserId?: string };
personalSecrets?: Array<{ definition: { id: string }; secret: { id: string; companyId: string; scope: string; ownerUserId?: string | null; userSecretDefinitionId?: string | null; status: string; createdAt: string } | null }>;
companySecrets?: Array<{id:string;companyId:string;scope:string;key:string;status:string;createdByUserId?:string|null;createdAt:string}>;
interactions: Array<Partial<AskUserQuestionsInteraction>>; activity: ChatCatalogHumanQuestionAudit[];
runs: Array<{ id: string; companyId?: string; agentId: string; status: string; runtimeMode?: string; startedAt?: string | null; contextSnapshot?: Record<string, unknown> | null }>;
}): ChatCatalogVerifiedHumanQuestionAnswer | undefined {
if (input.expected.personalSecretConfirmation) {
const b = input.expected, binding = b.personalSecretConfirmation!, rows = input.interactions.filter(row => row.id === b.interactionId);
const row = rows[0] as unknown as Partial<RequestConfirmationInteraction> | undefined;
const source = input.runs.find(run => run.id === b.sourceRunId), continuations = input.runs.filter(run => run.contextSnapshot?.interactionId === b.interactionId), continuation = continuations[0];
const entries = input.personalSecrets?.filter(entry => entry.definition.id === binding.definitionId), secret = entries?.[0]?.secret;
const audits = input.activity.filter(event => event.action === "issue.thread_interaction_accepted" && event.details?.interactionId === b.interactionId), audit = audits[0];
const resolvedAt = Date.parse(String(row?.resolvedAt));
if (b.companyEnvironmentNameAnswer || b.companySavedSecretNameAnswer || rows.length !== 1 || !row || row.kind !== "request_confirmation" || row.status !== "accepted"
|| row.companyId !== input.companyId || row.issueId !== input.issueId || row.sourceRunId !== b.sourceRunId || row.createdByAgentId !== input.agentId || row.createdByUserId
|| (row.addresseeUserId != null && row.addresseeUserId !== b.userId) || row.addresseeAgentId || !row.originCommentIds?.includes(input.canonicalComment.id)
|| b.userId !== input.canonicalComment.authorUserId || !chatCatalogHumanQuestionPolicies(row) || row.continuationPolicy !== "wake_assignee"
|| row.resolvedByUserId !== b.userId || row.resolvedByAgentId || row.result?.outcome !== "accepted" || row.result.resumeFailure || row.dismissedAt
|| !row.payload || row.payload.target || row.payload.toolAction || row.payload.secretProposal || row.payload.connectionAuthorization || row.payload.qualityReview
|| !row.payload.prompt.includes(binding.definitionId) || createHash("sha256").update(JSON.stringify(row.payload)).digest("hex") !== binding.payloadHash
|| entries?.length !== 1 || !secret || secret.id !== binding.secretId || secret.companyId !== input.companyId || secret.scope !== "user" || secret.status !== "active"
|| secret.ownerUserId !== b.userId || secret.userSecretDefinitionId !== binding.definitionId || !Number.isFinite(Date.parse(secret.createdAt)) || !Number.isFinite(resolvedAt) || Date.parse(secret.createdAt) > resolvedAt || Date.parse(secret.createdAt) < Date.parse(source?.startedAt ?? "")
|| audits.length !== 1 || audit?.companyId !== input.companyId || audit.entityId !== input.issueId || audit.actorType !== "user" || audit.actorId !== b.userId || audit.details?.interactionKind !== "request_confirmation" || audit.details.interactionStatus !== "accepted"
|| !source || source.companyId !== input.companyId || source.agentId !== input.agentId || source.status !== "succeeded" || source.runtimeMode !== "native" || source.contextSnapshot?.issueId !== input.issueId || source.contextSnapshot.commentId !== input.canonicalComment.id
|| !Number.isFinite(Date.parse(source.startedAt ?? "")) || Date.parse(source.startedAt!) > resolvedAt
|| continuations.length !== 1 || !continuation || continuation.id === source.id || continuation.companyId !== input.companyId || continuation.agentId !== input.agentId || continuation.status !== "succeeded" || continuation.runtimeMode !== "native" || continuation.contextSnapshot?.issueId !== input.issueId
|| !Number.isFinite(Date.parse(continuation.startedAt ?? "")) || Date.parse(continuation.startedAt!) < resolvedAt) return;
return { ...b, continuationRunId: continuation.id, resolvedAt: new Date(resolvedAt).toISOString() };
}
const b = input.expected, rows = input.interactions.filter(row => row.id === b.interactionId), row = rows[0];
if (rows.length !== 1 || !row || b.userId !== input.canonicalComment.authorUserId
|| row.companyId !== input.companyId || row.issueId !== input.issueId || row.sourceRunId !== b.sourceRunId
@@ -4961,6 +4961,9 @@
"scenarioId": "secrets.personal-secret",
"sourceFiles": [
"chat-catalog-domains/external-personal-secret.ts",
"chat-catalog-domains/external-personal-secret-confirmation.ts",
"chat-catalog-contract.ts",
"chat-catalog-flow.ts",
"chat-catalog-domains/external-secret-question.ts",
"chat-catalog-domains/external-secret-route.ts",
"chat-catalog-domains/external-secret-endpoint.ts",
@@ -4985,7 +4988,7 @@
"../../ui/src/pages/secrets/SetMyUserSecretDialog.tsx",
"chat-scenarios/secrets.yaml"
],
"oracleVersion": "paperclip.chat-catalog.personal-secret.v9",
"oracleVersion": "paperclip.chat-catalog.personal-secret.v10",
"expectedRunCount": 3,
"expectedHumanMessageCount": 2,
"minimumRunCount": 3,
@@ -0,0 +1,36 @@
import { expect, it, vi } from "vitest";
import { createHash } from "node:crypto";
import { personalSecretConfirmationHandoff, confirmThenStartPersonalConsumer } from "./external-personal-secret-confirmation.js";
import { personalSecretGuidance } from "./external-personal-secret.js";
import { verifyChatCatalogHumanQuestionAnswer, chatCatalogHumanHistoryMatches } from "../chat-catalog-contract.js";
const binding = { companyId: "company", issueId: "source", agentId: "ceo", userId: "person", runId: "initial", definitionId: "definition", canonicalCommentId: "human" };
function card() { return { id: "confirmation", kind: "request_confirmation", status: "pending", companyId: "company", issueId: "source", sourceRunId: "initial", createdByAgentId: "ceo", originCommentIds: ["human"], resolverPolicy: "anyone", continuationPolicy: "wake_assignee", payload: { version: 1, prompt: "Never paste personal publishing credentials in chat. Open Settings Secrets, My secrets, Set my value for PERSONAL_PUBLISHING_KEY definition definition. When you have saved it, confirm this card so the requirement is recorded.", acceptLabel: "Confirm" } }; }
it("admits the native secure-save confirmation shape without manufacturing a chat reply", () => {
expect(personalSecretConfirmationHandoff(card(), binding, personalSecretGuidance)).toMatchObject({ interactionId: "confirmation", acceptLabel: "Confirm" });
});
it.each(["company", "issue", "run", "creator", "origin", "definition", "approval", "unsafe", "payload action"])("rejects unbound or non-save %s cards", fault => {
const row = card();
if(fault==="company")row.companyId="foreign";if(fault==="issue")row.issueId="foreign";if(fault==="run")row.sourceRunId="foreign";if(fault==="creator")row.createdByAgentId="foreign";if(fault==="origin")row.originCommentIds=[];
if(fault==="definition")row.payload.prompt=row.payload.prompt.replace("definition definition","definition foreign");
if(fault==="approval")row.payload.prompt+=" Approve deployment execution.";if(fault==="unsafe")row.payload.prompt+=" Paste the credential here in chat.";
if(fault==="payload action")Object.assign(row.payload,{toolAction:{toolName:"deploy"}});
expect(personalSecretConfirmationHandoff(row,binding,personalSecretGuidance)).toBeUndefined();
});
function accounting(){
const row={...card(),status:"accepted",resolvedByUserId:"person",resolvedAt:"2026-11-05T12:01:00Z",result:{version:1,outcome:"accepted"}};
const payloadHash=createHash("sha256").update(JSON.stringify(row.payload)).digest("hex");
return {expected:{interactionId:"confirmation",sourceRunId:"initial",userId:"person",personalSecretConfirmation:{definitionId:"definition",secretId:"secret",payloadHash}},companyId:"company",issueId:"source",agentId:"ceo",canonicalComment:{id:"human",authorUserId:"person"},interactions:[row],personalSecrets:[{definition:{id:"definition"},secret:{id:"secret",companyId:"company",scope:"user",ownerUserId:"person",userSecretDefinitionId:"definition",status:"active",createdAt:"2026-11-05T12:00:50Z"}}],activity:[{companyId:"company",entityId:"source",action:"issue.thread_interaction_accepted",actorType:"user",actorId:"person",details:{interactionId:"confirmation",interactionKind:"request_confirmation",interactionStatus:"accepted"}}],runs:[{id:"initial",companyId:"company",agentId:"ceo",status:"succeeded",runtimeMode:"native",startedAt:"2026-11-05T12:00:00Z",contextSnapshot:{issueId:"source",commentId:"human"}},{id:"continuation",companyId:"company",agentId:"ceo",status:"succeeded",runtimeMode:"native",startedAt:"2026-11-05T12:01:01Z",contextSnapshot:{issueId:"source",interactionId:"confirmation"}}]};
}
it("counts initial chat plus one bound actual human confirmation exactly once",()=>{
const input=accounting(),answer=verifyChatCatalogHumanQuestionAnswer(input as never);expect(answer).toMatchObject({continuationRunId:"continuation"});
expect(chatCatalogHumanHistoryMatches({implementation:{scenarioId:"secrets.personal-secret",expectedHumanMessageCount:2},human:[{id:"human",body:"Original"}],questionAnswer:answer,runIds:["initial","continuation"],boundary:{humanCommentIds:[],runIds:[]},renderedQuery:"Original"})).toBe(true);
});
it.each(["resolver","audit","payload","save","continuation","canonical"])("rejects forged or out-of-order confirmation %s proof",fault=>{
const input=accounting();if(fault==="resolver")input.interactions[0]!.resolvedByUserId="other";if(fault==="audit")input.activity[0]!.actorId="other";if(fault==="payload")input.interactions[0]!.payload.prompt+=" changed";if(fault==="save")input.personalSecrets[0]!.secret.createdAt="2026-11-05T12:02:00Z";if(fault==="continuation")input.runs[1]!.contextSnapshot.interactionId="foreign";if(fault==="canonical")input.interactions[0]!.originCommentIds=[];
expect(verifyChatCatalogHumanQuestionAnswer(input as never)).toBeUndefined();
});
it("starts the authorized consumer only after ordinary confirmation completes, without retries",async()=>{
let resolve!:()=>void;const accepted=new Promise<void>(r=>resolve=r),confirm=vi.fn(()=>accepted),start=vi.fn(async()=>{});
const pending=confirmThenStartPersonalConsumer(confirm,start);expect(confirm).toHaveBeenCalledTimes(1);expect(start).not.toHaveBeenCalled();resolve();await pending;expect(start).toHaveBeenCalledTimes(1);
const rejected=vi.fn(async()=>{throw Error("rejected");});await expect(confirmThenStartPersonalConsumer(rejected,start)).rejects.toThrow("rejected");expect(start).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,36 @@
import { createHash } from "node:crypto";
import { expect, type Page } from "@playwright/test";
import type { RequestConfirmationInteraction } from "../../../packages/shared/src/types/issue.js";
import { requestConfirmationPayloadSchema } from "../../../packages/shared/src/validators/issue.js";
import { chatCatalogHumanQuestionPolicies } from "../chat-catalog-contract.js";
export interface PersonalConfirmationBinding { companyId: string; issueId: string; agentId: string; userId: string; runId: string; definitionId: string; canonicalCommentId: string }
export function personalSecretConfirmationHandoff(value: unknown, b: PersonalConfirmationBinding, guidance: (text: string) => boolean, status: "pending" | "accepted" = "pending") {
const row = value as Partial<RequestConfirmationInteraction> | undefined, p = row?.payload;
if (!row?.id || row.kind !== "request_confirmation" || row.status !== status || row.companyId !== b.companyId || row.issueId !== b.issueId
|| row.sourceRunId !== b.runId || row.createdByAgentId !== b.agentId || row.createdByUserId || row.addresseeAgentId
|| (row.addresseeUserId != null && row.addresseeUserId !== b.userId) || !row.originCommentIds?.includes(b.canonicalCommentId)
|| !chatCatalogHumanQuestionPolicies(row) || row.continuationPolicy !== "wake_assignee" || !p
|| p.target || p.toolAction || p.secretProposal || p.connectionAuthorization || p.qualityReview
|| !requestConfirmationPayloadSchema.safeParse(p).success || row.dismissedAt
|| (status === "pending" ? row.resolvedByUserId || row.resolvedByAgentId : row.resolvedByUserId !== b.userId || row.resolvedByAgentId || row.result?.outcome !== "accepted")) return;
const text = [p.prompt, p.detailsMarkdown, p.acceptLabel].filter(Boolean).join("\n");
if (!guidance(text) || !new RegExp(`\\bdefinition\\s+${b.definitionId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text) || !/\bPERSONAL_PUBLISHING_KEY\b/.test(text) || !/my secrets/i.test(text)
|| !/\b(?:when|once|after)\b[^.!?]*\b(?:saved|set|entered)\b[^.!?]*\bconfirm\b|\bconfirm\b[^.!?]*\b(?:saved|set|entered)\b/i.test(text)
|| /\b(?:approve|authorize|permission|deploy|delete|execute|purchase)\b/i.test(text)) return;
return { interactionId: row.id, payloadHash: createHash("sha256").update(JSON.stringify(p)).digest("hex"), acceptLabel: p.acceptLabel ?? "Approve", prompt: p.prompt };
}
export async function acceptPersonalSecretConfirmation(page: Page, issueId: string, interactionId: string, label: string, prompt: string, remaining: () => number) {
const card = page.getByTestId("task-chat-composer-takeover");
await expect(card.getByText(prompt, { exact: true })).toBeVisible({ timeout: remaining() });
const [response] = await Promise.all([
page.waitForResponse(res => new URL(res.url()).pathname === `/api/issues/${issueId}/interactions/${interactionId}/accept` && res.request().method() === "POST", { timeout: remaining() }),
card.getByRole("button", { name: label, exact: true }).click({ timeout: remaining() }),
]);
if (!response.ok()) throw new Error("Personal secure-entry confirmation failed");
return response.json() as Promise<unknown>;
}
export async function confirmThenStartPersonalConsumer(confirm: () => Promise<void>, start: () => Promise<void>) {
await confirm();
await start();
}
@@ -25,12 +25,13 @@ function measuredQuestion() {
{ id: "not-yet", label: "Not yet / need help", description: "I have not saved it yet or need help locating the page." }] }] }),
};
}
it("actual personal-secret act admits a bound secure-entry question before any private human action", async () => {
it.each(["question", "confirmation"])("actual personal-secret act admits a bound secure-entry %s before any private human action", async kind => {
vi.useFakeTimers(); vi.setSystemTime(10_000);
const query = "Set up my personal publishing credential for this task.";
const run = { id: "initial", companyId: "company", agentId: "ceo", runtimeMode: "native", status: "succeeded", contextSnapshot: { issueId: "source", commentId: "human" } };
const human = { id: "human", companyId: "company", issueId: "source", authorUserId: "person", body: query };
port.observation = { companyId: "company", phaseReplies: [], links: [], interactions: [measuredQuestion()] };
const confirmation = { id: "confirmation", companyId: "company", issueId: "source", sourceRunId: "initial", createdByAgentId: "ceo", originCommentIds: ["human"], resolverPolicy: "anyone", continuationPolicy: "wake_assignee", kind: "request_confirmation", status: "pending", payload: { version: 1, prompt: "Set your personal publishing credential in Settings Secrets, My secrets, Set my value, PERSONAL_PUBLISHING_KEY definition definition. Never paste credentials in chat. When you have saved it, confirm this card.", acceptLabel: "Confirm" } };
port.observation = { companyId: "company", phaseReplies: [], links: [], interactions: [kind === "confirmation" ? confirmation : measuredQuestion()] };
let reached = false;
const ctx = { observationDeadlineAt: 640_000, scenario: { query }, input: { fixtures: { company: { id: "company", issuePrefix: "ACORN" }, agent: { id: "ceo" } }, api: { get: async (path: string) => {
if (path.endsWith("/comments?order=asc")) return [human];
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { questionSetToAskUserQuestionsPayload } from "../../../packages/shared/src/question-set.js";
import { expect, it } from "vitest";
import {
@@ -282,3 +283,20 @@ it("accepts an excluded chat destination without hiding a later positive request
it("rejects an affirmative chat destination after an excluded destination", () => {
expect(personalSecretGuidance("Enter your personal publishing credential in the secure form — not here, but in chat.")).toBe(false);
});
it("accepts a saved-value confirmation without a fabricated initial reply and rejects consumer-before-confirm or absent source proof", () => {
const o = valid();
const payload = { version: 1, prompt: "Open Settings Secrets, My secrets, Set my value for your personal publishing credential PERSONAL_PUBLISHING_KEY definition definition. Never paste the value in chat. When you have saved it, confirm this card.", acceptLabel: "Confirm" };
const row = { id: "confirmation", companyId: "company", issueId: "source", sourceRunId: "initial", createdByAgentId: "ceo", originCommentIds: ["human"], kind: "request_confirmation", status: "accepted", resolverPolicy: "anyone", continuationPolicy: "wake_assignee", resolvedByUserId: "person", resolvedAt: "2026-11-03T23:59:59Z", result: { version: 1, outcome: "accepted" }, payload };
Object.assign(o.state, { sourceIssueId: "source", initialConfirmationId: row.id, canonicalHumanCommentId: "human", initialConfirmationPayloadHash: createHash("sha256").update(JSON.stringify(payload)).digest("hex") });
Object.assign(o.state.currentSecret, { createdAt: "2026-11-03T23:59:58Z" });
Object.assign(o.runs[2]!, { agentId: "ceo", contextSnapshot: { issueId: "source", interactionId: row.id } });
o.phaseReplies.shift();
(o.interactions as unknown[]).push(row);
(o.activity as unknown[]).push({ companyId: "company", entityId: "source", action: "issue.thread_interaction_accepted", actorType: "user", actorId: "person", details: { interactionId: row.id, interactionStatus: "accepted" } });
expect(gradePersonalSecret(o).passed).toBe(true);
const started = o.runs[1]!.startedAt; o.runs[1]!.startedAt = "2026-11-03T23:59:57Z";
expect(gradePersonalSecret(o).passed).toBe(false); o.runs[1]!.startedAt = started;
o.state.workerReceiptEvidence.events = [];
expect(gradePersonalSecret(o).passed).toBe(false);
});
@@ -1,3 +1,4 @@
import { personalSecretConfirmationHandoff, acceptPersonalSecretConfirmation, confirmThenStartPersonalConsumer } from "./external-personal-secret-confirmation.js";
import type {Page} from "@playwright/test";
import {visibleSecretSettingsURL} from "./external-secret-route.js";
import { createHash, randomBytes } from "node:crypto";
@@ -228,17 +229,21 @@ export function gradePersonalSecret(value: unknown) {
last = o.phaseReplies
.filter((row) => row.runId !== o.state.initialRunId)
.at(-1);
const confirmationRow = o.interactions.find(row => row.id === o.state.initialConfirmationId);
const confirmation = personalSecretConfirmationHandoff(confirmationRow, { companyId: o.companyId, issueId: String(o.state.sourceIssueId), agentId: o.leadId, userId: String(o.state.currentPersonId), runId: String(o.state.initialRunId), definitionId: String(o.state.definitionId), canonicalCommentId: String(o.state.canonicalHumanCommentId) }, personalSecretGuidance, "accepted");
const confirmationAccepted = !!confirmation && confirmation.payloadHash === o.state.initialConfirmationPayloadHash
&& o.activity.some(event => event.action === "issue.thread_interaction_accepted" && event.companyId === o.companyId && event.entityId === o.state.sourceIssueId && event.actorType === "user" && event.actorId === o.state.currentPersonId && event.details?.interactionId === confirmation.interactionId && event.details.interactionStatus === "accepted");
const secret = o.state.currentSecret as Partial<CompanySecret> | undefined;
c.add(
"missing-own-required-credential-and-truthful-handoff",
o.state.currentMissingBefore === true &&
o.state.requiredReferenceBefore === true &&
o.state.otherConfiguredBefore === true &&
o.state.currentPersonId !== o.state.otherPersonId &&
((!!initial && personalSecretGuidance(initial.body)) || questionAnswered) &&
((!!initial && personalSecretGuidance(initial.body)) || questionAnswered || confirmationAccepted) &&
o.state.agentValueCreateStatus === 403,
"Another person's real stored value does not satisfy the current person's required publishing definition, and chat directs secure own-value entry before claiming completion.",
);
const secret = o.state.currentSecret as Partial<CompanySecret> | undefined;
const workerReceipt = personalSecretWorkerReceipt(o.runs.find(run => run.id === o.state.workerRunId), o.state.workerReceiptEvidence as PersonalSecretWorkerEvidence | undefined,
{ companyId: o.companyId, agentId: String(o.state.morganId), taskId: String(o.state.taskId), userId: String(o.state.currentPersonId) });
c.add(
@@ -292,7 +297,10 @@ export function gradePersonalSecret(value: unknown) {
);
c.add(
"post-entry-confirmation-is-separate-and-sanitized",
!!last &&
!!last && (!o.state.initialConfirmationId || (confirmationAccepted && !!secret?.createdAt && !!confirmationRow?.resolvedAt
&& Date.parse(String(secret.createdAt)) <= Date.parse(confirmationRow.resolvedAt)
&& Date.parse(String(o.runs.find(run => run.id === o.state.workerRunId)?.startedAt)) >= Date.parse(confirmationRow.resolvedAt)
&& o.runs.some(run => run.id === last.runId && run.agentId === o.leadId && run.contextSnapshot?.interactionId === o.state.initialConfirmationId))) &&
o.runs.some(
(run) => run.id === last.runId && run.status === "succeeded",
) &&
@@ -444,10 +452,12 @@ export const personalSecretExternalCases = [
authentication: "human-session",
expectedHumanQuestionAnswer(value) {
const o = externalObservation(value);
if (!o?.state.initialQuestionId || !gradePersonalSecret(value).passed) return;
if (!o || !gradePersonalSecret(value).passed) return;
if (o.state.initialConfirmationId) return { interactionId: String(o.state.initialConfirmationId), sourceRunId: String(o.state.initialRunId), userId: String(o.state.currentPersonId), personalSecretConfirmation: { definitionId: String(o.state.definitionId), secretId: String(o.state.secretId), payloadHash: String(o.state.initialConfirmationPayloadHash) } };
if (!o.state.initialQuestionId) return;
return { interactionId: String(o.state.initialQuestionId), sourceRunId: String(o.state.initialRunId), userId: String(o.state.currentPersonId) };
},
oracleVersion: "paperclip.chat-catalog.personal-secret.v9",
oracleVersion: "paperclip.chat-catalog.personal-secret.v10",
setupPolicy: { readiness: "preserve-scenario", invariantCheckIds: ["current-owner-missing-required-personal-value"] },
expectedRunCount: 3,
minimumRunCount: 3,
@@ -455,6 +465,9 @@ export const personalSecretExternalCases = [
attemptTimeoutMs: 660000,
sourceFiles: [
"chat-catalog-domains/external-personal-secret.ts",
"chat-catalog-domains/external-personal-secret-confirmation.ts",
"chat-catalog-contract.ts",
"chat-catalog-flow.ts",
"chat-catalog-domains/external-secret-question.ts",
"chat-catalog-domains/external-secret-route.ts",
"chat-catalog-domains/external-secret-endpoint.ts",
@@ -574,6 +587,8 @@ export const personalSecretExternalCases = [
reply = initial.phaseReplies.at(-1);
remaining();
const pendingQuestions = initial.interactions.filter(row => row.status === "pending" && (row as Partial<AskUserQuestionsInteraction>).sourceRunId === run.id);
const confirmationBinding = { companyId: source.companyId, issueId: source.id, agentId: run.agentId, userId: String(state.currentPersonId), runId: run.id, definitionId: String(state.definitionId), canonicalCommentId: canonical[0]!.id };
const confirmation = pendingQuestions.length === 1 ? personalSecretConfirmationHandoff(pendingQuestions[0], confirmationBinding, personalSecretGuidance) : undefined;
const question = pendingQuestions.length === 1 ? personalSecretQuestionHandoff(pendingQuestions[0], {
companyId: source.companyId, issueId: source.id, agentId: run.agentId, userId: String(state.currentPersonId), runId: run.id, definitionId: String(state.definitionId),
}) : undefined;
@@ -581,9 +596,10 @@ export const personalSecretExternalCases = [
&& comments.some(comment => comment.id === reply.commentId && comment.companyId === source.companyId
&& comment.issueId === source.id && comment.authorAgentId === run.agentId && comment.createdByRunId === run.id
&& comment.body?.trim() === reply.body.trim()) && personalSecretGuidance(reply.body);
if (initial.companyId !== source.companyId || (pendingQuestions.length ? !question : !commentHandoff))
if (initial.companyId !== source.companyId || (pendingQuestions.length ? !question && !confirmation : !commentHandoff))
throw new Error("Personal setup must direct the current person's secure publishing value before human action");
state.initialRunId = run.id;
if (confirmation) Object.assign(state, { initialConfirmationId: confirmation.interactionId, initialConfirmationPayloadHash: confirmation.payloadHash, canonicalHumanCommentId: canonical[0]!.id });
if (question) Object.assign(state, { initialQuestionId: question.interactionId,
initialQuestionOptionId: question.optionId, initialQuestionPayloadHash: question.payloadHash });
if (
@@ -596,19 +612,19 @@ export const personalSecretExternalCases = [
);
const path = `/${ctx.input.fixtures.company.issuePrefix}/company/settings/secrets`,
link = commentHandoff ? await personalSecretSetupURL(ctx.input.page,reply!,initial.origin,String(ctx.input.fixtures.company.issuePrefix),String(state.definitionId),remaining()) : undefined;
if (!link && !question)
if (!link && !question && !confirmation)
throw new Error(
"Actual reply must direct the supported own-value settings path for this definition",
);
const url = new URL(question ? path : link!, initial.origin);
if (!question) url.searchParams.set("definition", String(state.definitionId));
const url = new URL(question || confirmation ? path : link!, initial.origin);
if (!question && !confirmation) url.searchParams.set("definition", String(state.definitionId));
const receipt = await privateBrowserEntry({
mode: "personal-secret-create",
companyId: ctx.input.fixtures.company.id,
personal: {
definitionId: String(state.definitionId),
userId: String(state.currentPersonId),
viaMySecrets: !!question,
viaMySecrets: !!question || !!confirmation,
},
baseURL: initial.origin,
setupURL: url.href,
@@ -646,9 +662,25 @@ export const personalSecretExternalCases = [
);
state.otherMutationStatus = denied.status;
await denied.body?.cancel();
await ctx.input.api.patch(`/api/issues/${state.taskId}`, {
status: "todo",
});
if (confirmation) {
if (denied.status !== 404) throw new Error("Personal confirmation requires actual other-owner isolation");
const own = (await ctx.input.api.get<OwnEntry[]>(`${root(ctx)}/me/user-secrets`)).find(row => row.definition.id === state.definitionId)?.secret;
const cards = await ctx.input.api.get<unknown[]>(`/api/issues/${source.id}/interactions`);
const current = personalSecretConfirmationHandoff(cards.find(row => (row as { id?: string }).id === confirmation.interactionId), confirmationBinding, personalSecretGuidance);
if (!own || own.id !== state.secretId || own.ownerUserId !== state.currentPersonId || own.userSecretDefinitionId !== state.definitionId || own.status !== "active"
|| cards.some(row => (row as { id?: string; status?: string }).status === "pending" && (row as { id?: string }).id !== confirmation.interactionId)
|| !current || current.payloadHash !== confirmation.payloadHash) throw new Error("Personal confirmation requires unchanged card and actual saved own value");
await confirmThenStartPersonalConsumer(
async () => {
const response = await acceptPersonalSecretConfirmation(ctx.input.page, source.id, confirmation.interactionId, confirmation.acceptLabel, confirmation.prompt, remaining);
const accepted = personalSecretConfirmationHandoff(response, confirmationBinding, personalSecretGuidance, "accepted");
const audit = await ctx.input.api.get<Array<{ companyId: string; entityId: string; actorType: string; actorId: string; action: string; details?: Record<string, unknown> }>>(`/api/issues/${source.id}/activity`);
if (!accepted || accepted.payloadHash !== confirmation.payloadHash || !audit.some(event => event.companyId === source.companyId && event.entityId === source.id && event.actorType === "user" && event.actorId === state.currentPersonId && event.action === "issue.thread_interaction_accepted" && event.details?.interactionId === confirmation.interactionId && event.details.interactionStatus === "accepted")) throw new Error("Personal secure-entry confirmation lacks actual human acceptance audit");
},
() => ctx.input.api.patch(`/api/issues/${state.taskId}`, { status: "todo" }).then(() => undefined),
);
}
if (!confirmation) await ctx.input.api.patch(`/api/issues/${state.taskId}`, { status: "todo" });
const result = await pollUntil({
label:
"current-person publishing credential actually delivered to Morgan",
@@ -700,7 +732,7 @@ export const personalSecretExternalCases = [
form.getByRole("button", { name: question.submitLabel, exact: true }).click({ timeout: remaining() }),
]);
if (!response.ok()) throw new Error("Secure-entry human question response failed");
} else await sendChatMessage(ctx.input.page, ctx.scenario.query);
} else if (!confirmation) await sendChatMessage(ctx.input.page, ctx.scenario.query);
},
async observe(ctx, state) {
const fixture = fixtures.get(ctx)!;
+5 -4
View File
@@ -68,16 +68,17 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) {
? implementation!.expectedHumanQuestionAnswer?.(observation) : undefined;
let questionAnswer: ReturnType<typeof verifyChatCatalogHumanQuestionAnswer>;
if (expectedAnswer && evidence.issue && canonicalHuman) {
const [interactions, activity, companySecrets] = await Promise.all([
const [interactions, activity, companySecrets, personalSecrets] = await Promise.all([
input.api.get<AskUserQuestionsInteraction[]>(`/api/issues/${evidence.issue.id}/interactions`),
input.api.get<ChatCatalogHumanQuestionAudit[]>(`/api/issues/${evidence.issue.id}/activity`),
expectedAnswer.companySavedSecretNameAnswer ? input.api.get<NonNullable<Parameters<typeof verifyChatCatalogHumanQuestionAnswer>[0]["companySecrets"]>>(`/api/companies/${input.fixtures.company.id}/secrets`) : undefined,
expectedAnswer.personalSecretConfirmation ? undefined : expectedAnswer.companySavedSecretNameAnswer ? input.api.get<NonNullable<Parameters<typeof verifyChatCatalogHumanQuestionAnswer>[0]["companySecrets"]>>(`/api/companies/${input.fixtures.company.id}/secrets`) : undefined,
expectedAnswer.personalSecretConfirmation ? input.api.get<NonNullable<Parameters<typeof verifyChatCatalogHumanQuestionAnswer>[0]["personalSecrets"]>>(`/api/companies/${input.fixtures.company.id}/me/user-secrets`) : undefined,
]);
questionAnswer = verifyChatCatalogHumanQuestionAnswer({ expected: expectedAnswer, companyId: input.fixtures.company.id,
issueId: evidence.issue.id, agentId: input.fixtures.agent.id, canonicalComment: canonicalHuman,
interactions, activity, companySecrets, runs: evidence.runs });
interactions, activity, companySecrets, personalSecrets, runs: evidence.runs });
}
const humanAccounting = { basis: expectedAnswer ? "comments_and_declared_credential_question_answer" : "comments", rawHumanCommentCount: human.length, verifiedHumanQuestionAnswerCount: questionAnswer ? 1 : 0,
const humanAccounting = { basis: expectedAnswer?.personalSecretConfirmation ? "comments_and_declared_personal_secure_confirmation" : expectedAnswer ? "comments_and_declared_credential_question_answer" : "comments", rawHumanCommentCount: human.length, verifiedHumanQuestionAnswerCount: questionAnswer ? 1 : 0,
accountedHumanActionCount: human.length + (questionAnswer ? 1 : 0), declaredHumanActionCount: implementation!.expectedHumanMessageCount ?? 1,
questionAnswer: questionAnswer ?? null, expectedQuestionAnswerVerified: expectedAnswer ? !!questionAnswer : null };