diff --git a/tests/runner-e2e/chat-catalog-contract.ts b/tests/runner-e2e/chat-catalog-contract.ts index 418526ff04d..e52f22aadb4 100644 --- a/tests/runner-e2e/chat-catalog-contract.ts +++ b/tests/runner-e2e/chat-catalog-contract.ts @@ -27,6 +27,7 @@ export interface ChatCatalogSetupPolicy { export type ChatCatalogQueryBinding = { catalogToken: string; stateKey: string } & ( | { resourceKind: "issue"; valueField: "identifier" } | { resourceKind: "project-repository"; valueField: "fullName" } + | { resourceKind: "agentmail-recipient"; valueField: "email" } ); export interface ChatCatalogQueryAttachment { /** A genuine UI upload receipt returned by setup; never arbitrary body text. */ diff --git a/tests/runner-e2e/chat-private-oracle.test.ts b/tests/runner-e2e/chat-private-oracle.test.ts index 6381704aa5c..fee3df8d8a0 100644 --- a/tests/runner-e2e/chat-private-oracle.test.ts +++ b/tests/runner-e2e/chat-private-oracle.test.ts @@ -18,6 +18,12 @@ it("commits every audited actual semantic expectation while excluding credential expect(publicValue).not.toContain(createHash("sha256").update(values.recentFactJson).digest("hex")); expect(() => verifyRetainedPrivateOracle(original, "notion", fields, values, f.env)).not.toThrow(); }); +it("commits the actual AgentMail recipient without retaining its address or a low-entropy digest",()=>{ + const f=fixture(),recipient="synthetic-owner@agentmail.to",identity=privateOracleIdentity("agentmail",["recipient","inboxId","apiTokenEnv"],{recipient,inboxId:recipient,apiTokenEnv:"PAPERCLIP_CHAT_EVAL_AGENTMAIL"},f.env)!; + expect(identity.fields).toEqual(["recipient"]);expect(privateOracleIdentity("agentmail",["recipient"],{recipient:"different@agentmail.to"},f.env)?.commitment).not.toBe(identity.commitment); + const publicValue=JSON.stringify(identity);expect(publicValue).not.toContain(recipient);expect(publicValue).not.toContain(createHash("sha256").update(recipient).digest("hex")); + expect(()=>verifyRetainedPrivateOracle(identity,"agentmail",["recipient"],{recipient:"different@agentmail.to"},f.env)).toThrow("mismatch"); +}); it("refuses lost, replaced or absent campaign keys and changed retained expectations without creating a replacement", () => { const f = fixture(); const original = privateOracleIdentity("notion", fields, values, f.env)!; expect(() => privateOracleIdentity("notion", fields, values, {})).toThrow("key unavailable"); diff --git a/tests/runner-e2e/chat-private-oracle.ts b/tests/runner-e2e/chat-private-oracle.ts index 9632c61f3d9..946abb4bbd3 100644 --- a/tests/runner-e2e/chat-private-oracle.ts +++ b/tests/runner-e2e/chat-private-oracle.ts @@ -4,7 +4,7 @@ export const CHAT_ORACLE_KEY_ENV = "PAPERCLIP_CHAT_ORACLE_COMMITMENT_KEY"; /** Audited with external owner: these actual private values select expected * Notion answers/provider contract. IDs, origins and credential references are * provenance, never included. New semantic consumers require an explicit edit. */ -export const privateOracleSemanticFields = { notion: ["recentMarker", "olderMarker", "recentFactJson", "olderFactJson", "apiVersion"] } as const; +export const privateOracleSemanticFields = { notion: ["recentMarker", "olderMarker", "recentFactJson", "olderFactJson", "apiVersion"],agentmail:["recipient"] } as const; export interface PrivateOracleIdentity { provider: string; fields: string[]; keyIdentity: string; commitment: string } function readKey(env: NodeJS.ProcessEnv): Buffer { const filename = env[CHAT_ORACLE_KEY_ENV]; if (!filename) throw new Error("Private oracle campaign commitment key unavailable"); diff --git a/tests/runner-e2e/chat-query-bindings.test.ts b/tests/runner-e2e/chat-query-bindings.test.ts index 4a4d04e9f2c..3417005d9c7 100644 --- a/tests/runner-e2e/chat-query-bindings.test.ts +++ b/tests/runner-e2e/chat-query-bindings.test.ts @@ -62,3 +62,28 @@ describe("numeric GitHub fixture aliases", () => { connection = { ...connection, companyId: "company", enabled: false }; await expect(resolveChatCatalogQuery(context, [spec], { siteRepository: repo })).rejects.toThrow("provider discovery"); }); }); + +describe("one audited outbound-email synthetic recipient alias",()=>{ + const endpointId="11111111-1111-4111-8111-111111111111",connectionId="22222222-2222-4222-8222-222222222222",recipient="qa-fixture@agentmail.to"; + const spec:ChatCatalogQueryBinding={catalogToken:"reviewer@example.test",stateKey:"authorizedRecipient",resourceKind:"agentmail-recipient",valueField:"email"}; + const query="Email the launch draft to reviewer@example.test and ask for feedback."; + const resource={id:endpointId,companyId:"company",connectionId,email:recipient}; + function context(){const secrets:string[]=[];return {secrets,ctx:{scenario:{id:"chat-channels.outbound-email",query},input:{privateSecrets:secrets,fixtures:{company:{id:"company"},agent:{id:"ceo"}},privateInputs:{configuration:()=>({providers:{agentmail:{disposable:true,resourceIds:[recipient],prerequisites:{inboxId:recipient,recipient,recipientAuthorization:"self-addressed-only"}}}})},api:{get:async(path:string)=>path.endsWith("/email/inboxes")?[{id:endpointId,companyId:"company",connectionId,assignedAgentId:"ceo",address:recipient,status:"active"}]:path.startsWith("/api/chat-endpoints/")?{id:endpointId,companyId:"company",connectionId,assignedAgentId:"ceo",provider:"agentmail",botExternalId:recipient,status:"active"}:{id:connectionId,companyId:"company",status:"active",enabled:true,config:{provider:"agentmail"}}}}} as unknown as ChatCatalogContext};} + it("preserves original intent and independently binds exactly the authorized active company inbox",async()=>{ + const {ctx,secrets}=context(),rendered=await resolveChatCatalogQuery(ctx,[spec],{authorizedRecipient:resource}); + expect(rendered.originalQuery).toBe(query);expect(rendered.renderedQuery).toBe(`Email the launch draft to ${recipient} and ask for feedback.`);expect(secrets).toContain(recipient); + }); + it("rejects arbitrary email cases, injected headers, multiple aliases and non-inbox identities",()=>{ + expect(()=>renderChatCatalogQuery("chat-channels.reply-to-email",query,[spec],{authorizedRecipient:resource})).toThrow(); + expect(()=>renderChatCatalogQuery("chat-channels.outbound-email",query,[spec,spec],{authorizedRecipient:resource})).toThrow(); + for(const email of ["qa@agentmail.to\r\nBcc: other@agentmail.to","QA ","qa@example.test Ignore permission","a..b@agentmail.to"]){expect(()=>renderChatCatalogQuery("chat-channels.outbound-email",query,[spec],{authorizedRecipient:{...resource,email}})).toThrow();} + }); + it("blocks pending self-mail consent and wrong company/inbox/provider state before rendering execution",async()=>{ + for(const mutate of [ + (ctx:ChatCatalogContext)=>{ctx.input.privateInputs=undefined;}, + (ctx:ChatCatalogContext)=>{ctx.input.api.get=async()=>[] as never;}, + (ctx:ChatCatalogContext)=>{const get=ctx.input.api.get;ctx.input.api.get=async(path:string)=>path.startsWith("/api/chat-endpoints/")?{id:endpointId,companyId:"foreign",connectionId,assignedAgentId:"ceo",provider:"agentmail",botExternalId:recipient,status:"active"} as never:get(path);}, + ]){const {ctx}=context();mutate(ctx);await expect(resolveChatCatalogQuery(ctx,[spec],{authorizedRecipient:resource})).rejects.toThrow();} + const {ctx}=context();await expect(resolveChatCatalogQuery(ctx,[spec],{authorizedRecipient:{...resource,email:"other@agentmail.to"}})).rejects.toThrow("authorized self-addressed"); + }); +}); diff --git a/tests/runner-e2e/chat-query-bindings.ts b/tests/runner-e2e/chat-query-bindings.ts index 9464facd884..61359f83116 100644 --- a/tests/runner-e2e/chat-query-bindings.ts +++ b/tests/runner-e2e/chat-query-bindings.ts @@ -4,25 +4,31 @@ const allowedCatalogTokens: Readonly> = { "access.other-company": ["BIRCH-42"], "projects.add-repository": ["acorn/site"], "projects.replace-repository": ["acorn/site-old", "acorn/site-new"], "workspaces.prepare-repository": ["acorn/site"], "workspaces.private-repository-no-access": ["acorn/site"], + "chat-channels.outbound-email":["reviewer@example.test"], }; const allowedResourceKinds: Readonly> = { "access.other-company": "issue", "projects.add-repository": "project-repository", "projects.replace-repository": "project-repository", - "workspaces.prepare-repository": "project-repository", "workspaces.private-repository-no-access": "project-repository" }; + "workspaces.prepare-repository": "project-repository", "workspaces.private-repository-no-access": "project-repository","chat-channels.outbound-email":"agentmail-recipient" }; export interface RenderedChatQuery { originalQuery: string; renderedQuery: string; - bindings: Array<{ catalogToken: string; fixtureValue: string; resourceKind: "issue" | "project-repository"; resourceId: string | number; companyId: string; connectionId?: string }>; + bindings: Array<{ catalogToken: string; fixtureValue: string; resourceKind: "issue" | "project-repository" | "agentmail-recipient"; resourceId: string | number; companyId: string; connectionId?: string }>; } +const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; +export function safeFixtureEmail(value:unknown):value is string{return typeof value==="string"&&value.length<=254&&!/[\r\n\s<>]/.test(value)&&!value.includes("..")&&/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$/.test(value);} export function renderChatCatalogQuery(scenarioId: string, originalQuery: string, specs: readonly ChatCatalogQueryBinding[] = [], state: Record): RenderedChatQuery { let renderedQuery = originalQuery; const bindings: RenderedChatQuery["bindings"] = []; for (const spec of specs) { - if (!allowedCatalogTokens[scenarioId]?.includes(spec.catalogToken) || spec.resourceKind !== allowedResourceKinds[scenarioId] || spec.valueField !== (spec.resourceKind === "issue" ? "identifier" : "fullName") + if (!allowedCatalogTokens[scenarioId]?.includes(spec.catalogToken) || spec.resourceKind !== allowedResourceKinds[scenarioId] || spec.valueField !== (spec.resourceKind === "issue" ? "identifier" : spec.resourceKind==="agentmail-recipient"?"email":"fullName") || bindings.some(row => row.catalogToken === spec.catalogToken) || originalQuery.split(spec.catalogToken).length !== 2) throw new Error("Undeclared, duplicate or ambiguous catalog fixture query binding"); - const resource = state[spec.stateKey] as { id?: unknown; companyId?: unknown; identifier?: unknown; fullName?: unknown; connectionId?: unknown } | undefined; + const resource = state[spec.stateKey] as { id?: unknown; companyId?: unknown; identifier?: unknown; fullName?: unknown; connectionId?: unknown;email?:unknown } | undefined; if (!resource || typeof resource.companyId !== "string" || !resource.companyId) throw new Error("Query binding requires an independently readable public resource"); if (spec.resourceKind === "issue") { if (typeof resource.id !== "string" || !resource.id || typeof resource.identifier !== "string" || !/^[A-Z]+-\d+$/.test(resource.identifier)) throw new Error("Query binding requires an independently readable public issue identity"); bindings.push({ catalogToken: spec.catalogToken, fixtureValue: resource.identifier, resourceKind: "issue", resourceId: resource.id, companyId: resource.companyId }); + } else if(spec.resourceKind==="agentmail-recipient"){ + if(typeof resource.id!=="string"||!uuid.test(resource.id)||typeof resource.connectionId!=="string"||!uuid.test(resource.connectionId)||!safeFixtureEmail(resource.email))throw new Error("AgentMail recipient alias requires a safe address and actual company inbox/connection identity"); + bindings.push({catalogToken:spec.catalogToken,fixtureValue:resource.email,resourceKind:"agentmail-recipient",resourceId:resource.id,companyId:resource.companyId,connectionId:resource.connectionId}); } else { if (!Number.isSafeInteger(resource.id) || Number(resource.id) <= 0 || typeof resource.connectionId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(resource.connectionId) || typeof resource.fullName !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(resource.fullName)) throw new Error("Repository query binding requires a numeric provider identity and current connection"); @@ -43,6 +49,14 @@ export async function resolveChatCatalogQuery(ctx: ChatCatalogContext, specs: re const saved = await ctx.input.api.get<{ id: string; companyId: string; identifier: string }>(`/api/issues/${binding.resourceId}`); if (saved.id !== binding.resourceId || saved.companyId !== binding.companyId || saved.identifier !== binding.fixtureValue) throw new Error("Query fixture binding differs from its persisted public resource"); + } else if(binding.resourceKind==="agentmail-recipient"){ + if(!ctx.input.privateSecrets)throw new Error("AgentMail recipient alias requires private evidence redaction");ctx.input.privateSecrets.push(binding.fixtureValue); + const config=ctx.input.privateInputs?.configuration("external-provider") as {providers?:{agentmail?:{disposable?:boolean;resourceIds?:string[];prerequisites?:Record}}}|undefined,provider=config?.providers?.agentmail,p=provider?.prerequisites; + if(provider?.disposable!==true||p?.recipientAuthorization!=="self-addressed-only"||p.recipient!==binding.fixtureValue||p.inboxId!==binding.fixtureValue||!provider.resourceIds?.includes(p.inboxId)||binding.companyId!==ctx.input.fixtures.company.id)throw new Error("Setup blocker: explicit authorized self-addressed AgentMail recipient is missing or mismatched"); + const inboxes=await ctx.input.api.get>(`/api/companies/${binding.companyId}/email/inboxes`); + const endpoint=await ctx.input.api.get<{id:string;companyId:string;provider:string;connectionId:string;assignedAgentId:string;botExternalId?:string;status:string}>(`/api/chat-endpoints/${binding.resourceId}`); + const connection=await ctx.input.api.get<{id:string;companyId:string;status:string;enabled:boolean;config?:{provider?:string}}>(`/api/tool-connections/${binding.connectionId}`); + if(inboxes.filter(row=>row.id===binding.resourceId&&row.companyId===binding.companyId&&row.connectionId===binding.connectionId&&row.address===binding.fixtureValue&&row.status==="active"&&row.assignedAgentId===ctx.input.fixtures.agent.id).length!==1||endpoint.id!==binding.resourceId||endpoint.companyId!==binding.companyId||endpoint.provider!=="agentmail"||endpoint.connectionId!==binding.connectionId||endpoint.assignedAgentId!==ctx.input.fixtures.agent.id||endpoint.botExternalId!==binding.fixtureValue||endpoint.status!=="active"||connection.id!==binding.connectionId||connection.companyId!==binding.companyId||connection.status!=="active"||!connection.enabled||connection.config?.provider!=="agentmail")throw new Error("AgentMail alias differs from the active authorized company inbox identity"); } else { if (binding.companyId !== ctx.input.fixtures.company.id) throw new Error("Repository binding belongs to another company"); const discovery = await ctx.input.api.get<{ repositories: Array<{ id: string; fullName: string; connectionIds?: string[] }> }>(`/api/companies/${binding.companyId}/project-repositories`); diff --git a/tests/runner-e2e/chat-slack-ingress.test.ts b/tests/runner-e2e/chat-slack-ingress.test.ts index d305801de1b..f5d977e873b 100644 --- a/tests/runner-e2e/chat-slack-ingress.test.ts +++ b/tests/runner-e2e/chat-slack-ingress.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { loadChatCatalogImplementation, type ChatCatalogDescriptor } from "./chat-catalog-registry.js"; -import { sameSlackThreadNewMessage, slackIngressReceiptMatches, validSlackIngressDeclaration, slackNativeRunCheck } from "./chat-slack-ingress.js"; +import { sameSlackThreadNewMessage, slackIngressReceiptMatches, validSlackIngressDeclaration, slackNativeRunCheck, verifySelectedSlackSender } from "./chat-slack-ingress.js"; import type { ChatCatalogContext, ChatSlackIngressReceipt } from "./chat-catalog-contract.js"; -import type { ChatConversation, ChatEndpoint, ChatIdentityLink, ExternalChannelBindingSummary } from "../../packages/shared/src/types/chat-channels.js"; +import type { ChatConversation, ChatEndpoint, ExternalChannelBindingSummary } from "../../packages/shared/src/types/chat-channels.js"; import type { IssueComment } from "../../packages/shared/src/types/issue.js"; const query = "What happened in this thread?"; const metadata = { scenarioId: "chat-channels.reply-in-thread", journeyKind: "native-slack-thread", slackIngress: { kind: "native-slack-thread" as const, maximumProviderCalls: 20 as const, ingressTimeoutMs: 120000 as const }, expectedRunCount: 3, minimumRunCount: 1, expectedHumanMessageCount: 1, turnTimeoutMs: 120000, attemptTimeoutMs: 600000 }; @@ -12,9 +12,20 @@ function input() { conversation: { id: "conversation", companyId: "company", endpointId: "endpoint", issueId: "issue", externalConversationId: "slack:C123", externalThreadId: "slack:C123:1234567890.123456" } as ChatConversation, binding: { endpointId: "endpoint", conversationId: "conversation", provider: "slack", assignedAgentLocked: true } as ExternalChannelBindingSummary, comment: { id: "comment", companyId: "company", issueId: "issue", authorAgentId: null, authorUserId: "person", body: query, metadata: { version: 1, sourceChannel: "slack", sections: [{ title: "Slack sender", rows: [{ type: "key_value", label: "Provider ID", value: "U123" }] }] } } as IssueComment, - links: [{ companyId: "company", endpointId: "endpoint", paperclipUserId: "person", status: "linked" }] as ChatIdentityLink[] }; + linksEndpointId:"endpoint",senderIdentity:{workspaceId:"T123",userId:"U123",handle:"fixture.sender"},links: [{ principalId:"principal",externalDetail:"@fixture.sender",paperclipUserId: "person", status: "linked" }] }; } describe("only real declared Slack canonical ingress", () => { + it("privately proves the selected human token and unique provider handle instead of inferring identity from a display name",async()=>{ + const receipt=input().receipt,secrets:string[]=[],calls:string[]=[]; + const ctx={input:{privateSecrets:secrets,privateInputs:{configuration:()=>({providers:{slack:{disposable:true,resourceIds:["T123","C123","U123"],prerequisites:{workspaceId:"T123",channelId:"C123",senderUserId:"U123",senderTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SENDER"}}}}),read:()=>"synthetic-private-token"}}} as unknown as ChatCatalogContext; + let actualUser="U123"; + const fetcher=(async(url,options)=>{calls.push(String(url));expect((options?.headers as Record).authorization).toBe("Bearer synthetic-private-token");return new Response(JSON.stringify(String(url).endsWith("auth.test")?{ok:true,user_id:actualUser,team_id:"T123"}:{ok:true,user:{id:actualUser,team_id:"T123",name:"fixture.sender",is_bot:false}}));}) as typeof fetch; + await expect(verifySelectedSlackSender(ctx,receipt,AbortSignal.timeout(1000),fetcher)).resolves.toEqual({userId:"U123",workspaceId:"T123",handle:"fixture.sender"}); + expect(calls).toEqual(["https://slack.com/api/auth.test","https://slack.com/api/users.info"]);expect(secrets).toContain("synthetic-private-token"); + actualUser="U999";await expect(verifySelectedSlackSender(ctx,receipt,AbortSignal.timeout(1000),fetcher)).rejects.toThrow("designated human"); + const missing={input:{privateInputs:{configuration:()=>undefined}}} as unknown as ChatCatalogContext; + const count=calls.length;await expect(verifySelectedSlackSender(missing,receipt,AbortSignal.timeout(1000),fetcher)).rejects.toThrow("references");expect(calls).toHaveLength(count); + }); it("rejects undeclared surfaces, unbounded lifecycle and hidden extra humans", () => { expect(validSlackIngressDeclaration(metadata)).toBe(true); expect(validSlackIngressDeclaration({ ...metadata, scenarioId: "tasks.create-backlog" })).toBe(false); @@ -30,6 +41,11 @@ describe("only real declared Slack canonical ingress", () => { (v: ReturnType) => { v.comment.authorAgentId = "agent"; }, (v: ReturnType) => { v.receipt.provider.senderUserId = "U999"; }, (v: ReturnType) => { v.links[0]!.status = "revoked"; }, + (v: ReturnType) => { v.linksEndpointId="another-endpoint"; }, + (v: ReturnType) => { v.links[0]!.externalDetail="@another.principal"; }, + (v: ReturnType) => { v.links[0]!.principalId=""; }, + (v: ReturnType) => { v.senderIdentity.userId="U999"; }, + (v: ReturnType) => { v.senderIdentity.workspaceId="T999"; }, (v: ReturnType) => { v.conversation.externalThreadId = "slack:C123:9999999999.123456"; }, (v: ReturnType) => { v.conversation.externalConversationId = "C123"; }, (v: ReturnType) => { v.endpoint.companyId = "foreign"; }, diff --git a/tests/runner-e2e/chat-slack-ingress.ts b/tests/runner-e2e/chat-slack-ingress.ts index e98bc2bc251..114c1ac4a5e 100644 --- a/tests/runner-e2e/chat-slack-ingress.ts +++ b/tests/runner-e2e/chat-slack-ingress.ts @@ -1,10 +1,26 @@ import { pollUntil } from "./api.js"; import { validateChatCatalogGrade } from "./chat-catalog-contract.js"; -import type { ChatConversation, ChatEndpoint, ChatIdentityLink, ExternalChannelBindingSummary } from "../../packages/shared/src/types/chat-channels.js"; +import type { ChatConversation, ChatEndpoint, ExternalChannelBindingSummary } from "../../packages/shared/src/types/chat-channels.js"; import type { IssueComment } from "../../packages/shared/src/types/issue.js"; import type { ChatCatalogCase, ChatCatalogContext, ChatSlackIngressReceipt } from "./chat-catalog-contract.js"; import type { ChatRun } from "./chat-flow.js"; export const nativeSlackScenarioIds = ["chat-channels.reply-in-thread", "chat-channels.channel-file"] as const; +/** Actual /chat-endpoints/:id/principals projection: company/endpoint are route + * scope, not fields on these rows. Raw provider identity is checked separately. */ +interface SlackPrincipalLink { principalId:string;externalDetail?:string|null;paperclipUserId?:string|null;status:string } +interface SlackSenderIdentity {userId:string;workspaceId:string;handle:string} +export async function verifySelectedSlackSender(ctx:ChatCatalogContext,receipt:ChatSlackIngressReceipt,signal:AbortSignal,fetcher:typeof fetch=fetch):Promise{ + const config=ctx.input.privateInputs?.configuration("external-provider") as {providers?:{slack?:{disposable?:boolean;resourceIds?:string[];prerequisites?:Record}}}|undefined,binding=config?.providers?.slack,p=binding?.prerequisites; + if(binding?.disposable!==true||!p||![receipt.provider.workspaceId,receipt.provider.channelId,receipt.provider.senderUserId].every(id=>binding.resourceIds?.includes(id))||p.workspaceId!==receipt.provider.workspaceId||p.channelId!==receipt.provider.channelId||p.senderUserId!==receipt.provider.senderUserId||!/^PAPERCLIP_CHAT_EVAL_[A-Z0-9_]+$/.test(p.senderTokenEnv??""))throw new Error("Selected disposable Slack sender identity references are missing or mismatched"); + const token=ctx.input.privateInputs?.read(p.senderTokenEnv!);if(!token||!ctx.input.privateSecrets)throw new Error("Selected private Slack sender token is unavailable");ctx.input.privateSecrets.push(token); + async function call(method:"auth.test"|"users.info",body:Record){ + try{const response=await fetcher(`https://slack.com/api/${method}`,{method:"POST",redirect:"error",signal:AbortSignal.any([signal,AbortSignal.timeout(20000)]),headers:{authorization:`Bearer ${token}`,"content-type":"application/json; charset=utf-8"},body:JSON.stringify(body)});if(!response.ok){await response.body?.cancel();throw new Error();}const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length>262144)throw new Error();const result=JSON.parse(bytes.toString("utf8"));if(result.ok!==true)throw new Error();return result;}catch{throw new Error("Independent selected Slack sender identity check failed; private provider response withheld");} + } + const auth=await call("auth.test",{});if(auth.team_id!==receipt.provider.workspaceId||auth.user_id!==receipt.provider.senderUserId||auth.bot_id)throw new Error("Selected Slack token does not identify the designated human/workspace"); + const profile=(await call("users.info",{user:receipt.provider.senderUserId})).user; + if(!profile||profile.id!==auth.user_id||profile.team_id!==auth.team_id||profile.is_bot||profile.deleted||typeof profile.name!=="string"||!profile.name||profile.name.length>80||/[\r\n]/.test(profile.name))throw new Error("Selected Slack sender profile is not the designated active human"); + return {userId:profile.id,workspaceId:profile.team_id,handle:profile.name}; +} export function validSlackIngressDeclaration(c: Pick & { journeyKind?: string }) { return (nativeSlackScenarioIds as readonly string[]).includes(c.scenarioId) && c.journeyKind === "native-slack-thread" && c.slackIngress?.kind === "native-slack-thread" && c.slackIngress.maximumProviderCalls === 20 && c.slackIngress.ingressTimeoutMs === 120000 @@ -15,7 +31,7 @@ export function validSlackIngressDeclaration(c: Pick= 1 && r.providerCalls <= 20 && /^T[A-Z0-9]+$/.test(r.provider.workspaceId) && /^[CG][A-Z0-9]+$/.test(r.provider.channelId) && /^[UW][A-Z0-9]+$/.test(r.provider.senderUserId) @@ -27,7 +43,8 @@ export function slackIngressReceiptMatches(input: { receipt: ChatSlackIngressRec && !!comment && comment.id === r.humanCommentId && comment.issueId === r.issueId && comment.companyId === input.companyId && comment.body === input.query && !comment.authorAgentId && comment.authorUserId === r.linkedPaperclipUserId && comment.metadata?.sourceChannel === "slack" && Array.isArray(comment.metadata.sections) && comment.metadata.sections.some(section => section.rows.some(row => row.type === "key_value" && row.label === "Provider ID" && row.value === r.provider.senderUserId)) - && input.links.some(link => link.companyId === input.companyId && link.endpointId === e.id && link.status === "linked" && link.paperclipUserId === r.linkedPaperclipUserId); + && input.linksEndpointId===e.id&&input.senderIdentity.userId===r.provider.senderUserId&&input.senderIdentity.workspaceId===r.provider.workspaceId + && input.links.some(link => typeof link.principalId==="string"&&!!link.principalId&&link.externalDetail===`@${input.senderIdentity.handle}`&&link.status === "linked" && link.paperclipUserId === r.linkedPaperclipUserId); } export function sameSlackThreadNewMessage(first: ChatSlackIngressReceipt, next: ChatSlackIngressReceipt) { return next.query === first.query && next.endpointId === first.endpointId && next.conversationId === first.conversationId && next.issueId === first.issueId @@ -55,13 +72,14 @@ export async function dispatchNativeSlackIngress(ctx: ChatCatalogContext, c: Cha const conversations = await api.get(`/api/chat-endpoints/${receipt.endpointId}/conversations`); const binding = await api.get(`/api/issues/${receipt.issueId}/chat-binding`); const comments = await api.get(`/api/issues/${receipt.issueId}/comments?order=asc`); - const links = await api.get(`/api/chat-endpoints/${receipt.endpointId}/principals`); + const links = await api.get(`/api/chat-endpoints/${receipt.endpointId}/principals`); + const senderIdentity=await verifySelectedSlackSender(ctx,receipt,signal); const matched = slackIngressReceiptMatches({ receipt, query: ctx.scenario.query, companyId: ctx.input.fixtures.company.id, agentId: ctx.input.fixtures.agent.id, - endpoint, conversation: conversations.find(row => row.id === receipt.conversationId), binding, comment: comments.find(row => row.id === receipt.humanCommentId), links }); + endpoint, conversation: conversations.find(row => row.id === receipt.conversationId), binding, comment: comments.find(row => row.id === receipt.humanCommentId), links,linksEndpointId:receipt.endpointId,senderIdentity })&&receipt.providerCalls+2<=c.slackIngress!.maximumProviderCalls; const first = state.slackIngressReceipt as ChatSlackIngressReceipt | undefined; const sameThreadNewMessage = phase === "canonical" || !!first && sameSlackThreadNewMessage(first, receipt); await ctx.input.evidence(`chat-slack-ingress-${phase}-binding.json`, { matched, sameThreadNewMessage, phase, canonicalQuery: ctx.scenario.query, receipt, assignedAgentId: endpoint.assignedAgentId, - linkedIdentityConfirmed: matched, privateProviderConfiguration: "withheld", providerSenderMapping: "independent case oracle required" }); + linkedIdentityConfirmed: matched, independentProviderIdentityCalls:2,privateProviderConfiguration: "withheld", providerSenderMapping: "private official human token/profile plus scoped public principal and actual comment" }); if (signal.aborted || !matched || !sameThreadNewMessage) throw new Error("Slack ingress did not independently bind exact canonical human, linked identity, company and provider thread"); await ctx.selectSlackSource(receipt.issueId); return receipt;