diff --git a/docs/SLACK-SUBMISSION.md b/docs/SLACK-SUBMISSION.md index 5e03fd4..adf1e8b 100644 --- a/docs/SLACK-SUBMISSION.md +++ b/docs/SLACK-SUBMISSION.md @@ -24,6 +24,12 @@ agent are needed to exercise the approval flow. The app does not read channel messages. The Slack interaction records an authorization decision; the agent performs any subsequent action through its configured integration. +Tool-approval cards link to **Review request in Sanction** and explain that full +arguments are not shown in the channel. A signed-in wallet admin opens the linked +request and selects **Review exact request** to decrypt it. Slack membership alone +does not grant access to that view. The existing Slack Approve/Deny buttons remain +available; review is guidance, not a new enforced prerequisite. + | Listing field | Value | | --- | --- | | Landing / install page | https://getsanction.com/slack | diff --git a/lib/slack.ts b/lib/slack.ts index cdef6cf..ad729ad 100644 --- a/lib/slack.ts +++ b/lib/slack.ts @@ -8,6 +8,15 @@ const SLACK_ACTION_TTL_SECONDS = 2 * 60 * 60 const SLACK_ACTION_PURPOSE = "slack-approval-action" const REVIEW_URL = "https://getsanction.com/dashboard/approvals" +// Describes the existing admin-only dashboard read; never include the binding here. +export function slackApprovalReview(data: Record) { + const tool = data.action_type === "tool.invoke" || (data.action === "invoke" && typeof data.tool === "string") + return { + label: tool ? "Review request in Sanction" : "Review in Sanction", + context: tool ? [{ type: "context", elements: [{ type: "plain_text", text: "Full tool arguments are not shown in this card. A wallet admin can open Sanction and select Review exact request before approving." }] }] : [], + } +} + export function slackSigningSecret(): string | undefined { const secret = process.env.SANCTION_SLACK_SIGNING_SECRET return secret && secret.length > 0 ? secret : undefined @@ -143,6 +152,7 @@ export async function verifySlackActionToken(token: string): Promise, text: string, actionToken?: string): string { const blocks: unknown[] = [{ type: "section", text: { type: "mrkdwn", text } }] if (event === "approval.created" || event === "escalation.created") { + const review = slackApprovalReview(data) const reviewUrl = typeof data.approve_url === "string" ? data.approve_url : REVIEW_URL const elements: unknown[] = [] if (actionToken) { @@ -165,10 +175,11 @@ export function slackInteractivePayload(event: string, data: Record): stri const text = slackText(event, data) const blocks: unknown[] = [{ type: "section", text: { type: "mrkdwn", text } }] if (event === "approval.created" || event === "escalation.created") { + const review = slackApprovalReview(data) blocks.push({ type: "actions", elements: [ // Deep-link the button to the specific decision when the event carries it. - { type: "button", style: "primary", text: { type: "plain_text", text: "Review in Sanction" }, url: typeof data.approve_url === "string" ? data.approve_url : APPROVE_URL }, + { type: "button", style: "primary", text: { type: "plain_text", text: review.label }, url: typeof data.approve_url === "string" ? data.approve_url : APPROVE_URL }, ], }) + blocks.push(...review.context) } return JSON.stringify({ text: text.replace(/\*/g, ""), blocks }) } diff --git a/tests/delivery.test.ts b/tests/delivery.test.ts index daa5c3a..eb1befd 100644 --- a/tests/delivery.test.ts +++ b/tests/delivery.test.ts @@ -33,6 +33,28 @@ import { sendBudgetThresholdEmail } from "../lib/email" import { withTenant } from "../lib/rls" import { rateLimit, ipFromHeaders, clientIp } from "../lib/rateLimit" +describe("Slack exact-request review handoff", () => { + it.each([ + ["approval.created", { action_type: "tool.invoke" }], + ["escalation.created", { action: "invoke", tool: "fixture.echo" }], + ])("keeps %s details behind the admin review link in both delivery formats", async (event, shape) => { + const { slackPayload } = await vi.importActual("../lib/webhooks") + const { slackInteractivePayload } = await vi.importActual("../lib/slack") + const url = "https://getsanction.com/dashboard/approvals?review=request-test" + const data = { ...shape, approve_url: url, arguments: { body: "PRIVATE ARGUMENT" }, resource: { requestBinding: { encryptedValue: "SEALED BINDING" } } } + const payloads = [slackPayload(event, data), slackInteractivePayload(event, data, "Test request", "action-token")] + for (const raw of payloads) { + const payload = JSON.parse(raw) + const review = payload.blocks.find((block: { type: string }) => block.type === "actions").elements.find((element: { url?: string }) => element.url) + expect(review).toMatchObject({ text: { type: "plain_text", text: "Review request in Sanction" }, url }) + expect(payload.blocks.at(-1)).toMatchObject({ type: "context", elements: [{ type: "plain_text", text: expect.stringContaining("wallet admin") }] }) + expect(raw).toContain("Full tool arguments are not shown") + expect(raw).not.toMatch(/PRIVATE ARGUMENT|SEALED BINDING/) + } + expect(decryptMock).not.toHaveBeenCalled() + }) +}) + const COMMON = { walletId: "wallet_1", ownerEmail: "owner@example.com", agentName: "tenet", agentId: "agent_1" } beforeEach(() => {