Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b3b7143
docs: build spec for connector-grade onboarding (MCP vision arc)
claude Jul 11, 2026
875bb16
docs: resolve seat-invitation question — owner-driven (Eric, 2026-07-11)
claude Jul 11, 2026
9945c44
docs: add observe mode — the do-nothing adoption wedge — to the onboa…
claude Jul 11, 2026
1ba5ae0
docs: queue guest-seats gig/freelance lane; cross-link from onboardin…
claude Jul 11, 2026
6e5980f
fix(sdk): type the grantId-retry test's execute arg — main CI was red…
claude Jul 12, 2026
8b2494d
release(mcp): sanction-mcp 0.6.0 — align package with the platform re…
claude Jul 12, 2026
8dd3712
docs: build spec for demo companies — fictional clients, real engine
claude Jul 12, 2026
c0890bb
fix(audit): tolerate an RLS-shielded vault relation in the injection …
claude Jul 12, 2026
17fdb14
feat(demo): demo-company driver — seed/pulse/status + Meridian Analytics
claude Jul 12, 2026
8dcc535
Merge remote-tracking branch 'origin/main' into claude/sanction-mcp-v…
claude Jul 12, 2026
964a258
absorb pre-squash history — content landed in main via #184 squash
claude Jul 12, 2026
34ff95a
feat(demo): PR2 — history depth + Coastline Digital + Harbor & Wren LLP
claude Jul 12, 2026
5dea33d
feat(demo): PR3 — demo runbook + daily warm-pulse workflow
claude Jul 12, 2026
aaf947b
feat(demo): prime command — arm windowed state on DB-less targets
claude Jul 12, 2026
599f7bc
fix(demo): CodeRabbit review round — workflow hardening + Bluebird decay
claude Jul 12, 2026
6794e8e
absorb pre-squash history — demo arc landed in main via #186
claude Jul 12, 2026
b17b714
feat(engine): observe mode (OBS-1) — the do-nothing adoption wedge
claude Jul 12, 2026
a421249
fix(observe): truthful would_be for subtree caps + shared isObserved
claude Jul 12, 2026
15e82b7
absorb pre-squash history — observe mode landed in main via #187
claude Jul 12, 2026
9741f2f
feat(approvals): email lands the human ON the decision (APPROVE-UX)
claude Jul 12, 2026
40100d0
fix(tests): webhooks mock factories export approveUrlFor
claude Jul 12, 2026
f9e3139
fix(approvals): review round — backslash open-redirect guard + honest…
claude Jul 12, 2026
c8d0b79
fix(tests): approveUrlFor mocks encode ids like production
claude Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions app/api/access/v1/access-request/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from "@/lib/authzen"
import { createSpendPendingApproval, createToolPendingApproval, createProvisionPendingApproval, createCapabilityPendingApproval } from "@/lib/approvals"
import { authzenRateLimit } from "@/lib/authzenRateLimit"
import { deliverEvent, APPROVE_URL } from "@/lib/webhooks"
import { deliverEvent, approveUrlFor } from "@/lib/webhooks"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { sendEscalationEmail } from "@/lib/email"
import { logger } from "@/lib/log"

Expand Down Expand Up @@ -243,22 +243,22 @@ export async function POST(req: NextRequest) {
agent: agent.name,
resource: { kind: sarc.t, ...sarc },
reason,
approve_url: APPROVE_URL,
approve_url: approveUrlFor(escalated.id),
}),
deliverEvent(agent.walletId, "escalation.created", {
request_id: escalated.id,
agent: agent.name,
amount_usd: amountUsd,
merchant,
approve_url: APPROVE_URL,
approve_url: approveUrlFor(escalated.id),
}),
sendEscalationEmail(agent.wallet.ownerEmail, {
agentName: agent.name,
amountUsd,
merchant,
category: sarc.t === "tool" ? "tool" : sarc.t === "capability" ? "capability" : sarc.category,
description: reason,
approveUrl: APPROVE_URL,
approveUrl: approveUrlFor(escalated.id),
}).catch((err) => log.warn("escalation email failed", { err: String(err) })),
]),
)
Expand Down Expand Up @@ -294,7 +294,7 @@ function taskResponse(id: string, status: string, origin: string) {
id,
status,
status_endpoint: `${origin}${ACCESS_REQUEST_PATH}/${id}`,
links: { review: APPROVE_URL },
links: { review: approveUrlFor(id) },
},
}
}
Expand Down
8 changes: 4 additions & 4 deletions app/api/v1/authorize/capability/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { decisionEvidence } from "@/lib/evidence"
import { createCapabilityPendingApproval } from "@/lib/approvals"
import { consumeCapabilityGrant } from "@/lib/grants"
import { deliverEvent, APPROVE_URL } from "@/lib/webhooks"
import { deliverEvent, approveUrlFor } from "@/lib/webhooks"
import { sendEscalationEmail } from "@/lib/email"
import { REMEDIATION, deriveReplayCode, type DecisionCode } from "@/lib/decisions"
import { logger } from "@/lib/log"
Expand Down Expand Up @@ -144,13 +144,13 @@ export async function POST(req: NextRequest) {
agent: agent.name,
resource: { kind: "capability", capability },
reason: decision.reason,
approve_url: APPROVE_URL,
approve_url: approveUrlFor(escalated.id),
}),
deliverEvent(agent.walletId, "escalation.created", {
request_id: escalated.id, agent: agent.name, action: "use", capability, approve_url: APPROVE_URL,
request_id: escalated.id, agent: agent.name, action: "use", capability, approve_url: approveUrlFor(escalated.id),
}),
sendEscalationEmail(agent.wallet.ownerEmail, {
agentName: agent.name, amountUsd: 0, merchant: capability, category: "capability", description: decision.reason ?? null, approveUrl: APPROVE_URL,
agentName: agent.name, amountUsd: 0, merchant: capability, category: "capability", description: decision.reason ?? null, approveUrl: approveUrlFor(escalated.id),
}).catch((err) => log.warn("escalation email failed", { err: String(err) })),
]),
)
Expand Down
8 changes: 4 additions & 4 deletions app/api/v1/authorize/provision/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { accessRequestOffer, publicOrigin } from "@/lib/authzen"
import { evaluate } from "@/lib/evaluation"
import { PROVISION_STATELESS, PROVISION_STATEFUL, type ProvisionContext } from "@/lib/rules/provision"
import type { SpendContext } from "@/lib/rules/spend"
import { deliverEvent, APPROVE_URL } from "@/lib/webhooks"
import { deliverEvent, approveUrlFor } from "@/lib/webhooks"
import { sendEscalationEmail } from "@/lib/email"
import { verifyExecutionJWT } from "@/lib/jwt"
import { logger } from "@/lib/log"
Expand Down Expand Up @@ -369,13 +369,13 @@ export async function POST(req: NextRequest) {
agent: agent.name,
resource: approval?.resourceJson ?? { kind: "provision", resource, line_item, quantity, unit_price_usd, amount_usd, category, description },
reason: approval?.reason ?? "Exceeds escalation threshold",
approve_url: APPROVE_URL,
approve_url: approveUrlFor(result.id),
}),
deliverEvent(agent.walletId, "escalation.created", {
approval_id: approval?.id, request_id: result.id, agent: agent.name, action: "allocate", amount_usd, resource, line_item, quantity, category, description, approve_url: APPROVE_URL,
approval_id: approval?.id, request_id: result.id, agent: agent.name, action: "allocate", amount_usd, resource, line_item, quantity, category, description, approve_url: approveUrlFor(result.id),
}),
sendEscalationEmail(agent.wallet.ownerEmail, {
agentName: agent.name, amountUsd: amount_usd, merchant: resourceSummary, category, description, approveUrl: APPROVE_URL,
agentName: agent.name, amountUsd: amount_usd, merchant: resourceSummary, category, description, approveUrl: approveUrlFor(result.id),
}).catch((err) => log.warn("escalation email failed", { err: String(err) })),
]),
)
Expand Down
8 changes: 4 additions & 4 deletions app/api/v1/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { APPEALABLE_DENIALS, decisionEvidence, limitFromDecision } from "@/lib/e
import { accessRequestOffer, publicOrigin } from "@/lib/authzen"
import { evaluate } from "@/lib/evaluation"
import { SPEND_STATELESS, SPEND_STATEFUL, type SpendContext } from "@/lib/rules/spend"
import { deliverEvent, APPROVE_URL } from "@/lib/webhooks"
import { deliverEvent, approveUrlFor } from "@/lib/webhooks"
import { sendEscalationEmail } from "@/lib/email"
import { verifyExecutionJWT } from "@/lib/jwt"
import { logger } from "@/lib/log"
Expand Down Expand Up @@ -394,14 +394,14 @@ export async function POST(req: NextRequest) {
agent: agent.name,
resource: approval?.resourceJson ?? { kind: "spend", action, amount_usd, merchant, category, description },
reason: approval?.reason ?? "Exceeds escalation threshold",
approve_url: APPROVE_URL,
approve_url: approveUrlFor(result.id),
}),
deliverEvent(agent.walletId, "escalation.created", {
approval_id: approval?.id, request_id: result.id, agent: agent.name, action, amount_usd, merchant, category, description, approve_url: APPROVE_URL,
approval_id: approval?.id, request_id: result.id, agent: agent.name, action, amount_usd, merchant, category, description, approve_url: approveUrlFor(result.id),
}),
// Email the owner directly, so escalations reach them even with no webhook registered.
sendEscalationEmail(agent.wallet.ownerEmail, {
agentName: agent.name, amountUsd: amount_usd, merchant, category, description, approveUrl: APPROVE_URL,
agentName: agent.name, amountUsd: amount_usd, merchant, category, description, approveUrl: approveUrlFor(result.id),
}).catch((err) => log.warn("escalation email failed", { err: String(err) })),
]),
)
Expand Down
8 changes: 4 additions & 4 deletions app/api/v1/authorize/tool/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { decideTool, TOOL_REMEDIATION, type ToolDecisionCode } from "@/lib/toolD
import { decisionEvidence } from "@/lib/evidence"
import { createToolPendingApproval } from "@/lib/approvals"
import { consumeToolGrant } from "@/lib/grants"
import { deliverEvent, APPROVE_URL } from "@/lib/webhooks"
import { deliverEvent, approveUrlFor } from "@/lib/webhooks"
import { sendEscalationEmail } from "@/lib/email"
import { REMEDIATION, deriveReplayCode, isObserved, type DecisionCode } from "@/lib/decisions"
import { logger } from "@/lib/log"
Expand Down Expand Up @@ -166,13 +166,13 @@ export async function POST(req: NextRequest) {
agent: agent.name,
resource: { kind: "tool", tool, server: server ?? null },
reason: decision.reason,
approve_url: APPROVE_URL,
approve_url: approveUrlFor(escalated.id),
}),
deliverEvent(agent.walletId, "escalation.created", {
request_id: escalated.id, agent: agent.name, action: "invoke", tool, server: server ?? null, approve_url: APPROVE_URL,
request_id: escalated.id, agent: agent.name, action: "invoke", tool, server: server ?? null, approve_url: approveUrlFor(escalated.id),
}),
sendEscalationEmail(agent.wallet.ownerEmail, {
agentName: agent.name, amountUsd: 0, merchant: server ? `${tool} (${server})` : tool, category: "tool", description: decision.reason ?? null, approveUrl: APPROVE_URL,
agentName: agent.name, amountUsd: 0, merchant: server ? `${tool} (${server})` : tool, category: "tool", description: decision.reason ?? null, approveUrl: approveUrlFor(escalated.id),
}).catch((err) => log.warn("escalation email failed", { err: String(err) })),
]),
)
Expand Down
126 changes: 124 additions & 2 deletions app/dashboard/approvals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { ApprovalQueue, type PendingApproval } from "@/components/approval-queue
import { WebhookSettings } from "@/components/webhook-settings"
import { listPendingApprovals } from "@/lib/approvals"
import { getViewWallet } from "@/lib/session"
import { redirect } from "next/navigation"
import Link from "next/link"
import { subtreeWalletIds } from "@/lib/walletSubtree"

export const metadata: Metadata = {
Expand Down Expand Up @@ -64,14 +66,23 @@ const statusClasses: Record<string, string> = {
expired: "border-border bg-muted text-muted-foreground",
}

export default async function ApprovalsPage() {
export default async function ApprovalsPage({ searchParams }: { searchParams: Promise<{ review?: string }> }) {
const { review } = await searchParams
const view = await getViewWallet()
// A ?review deep link is someone answering an escalation email. Neither the
// public demo fallback nor a bare "no wallet" screen may answer that click —
// an empty inbox reads as "already resolved" while the real decision waits
// in another wallet. Send them to sign in, and bring them straight back.
if (review && (!view || !view.isSession)) {
redirect(`/login?next=${encodeURIComponent(`/dashboard/approvals?review=${review}`)}`)
}
if (!view) return <NoWallet />
const walletId = view.id

const pendingRows = await listPendingApprovals(walletId)
const pending: PendingApproval[] = pendingRows.map((r) => ({
id: r.id,
sourceId: r.sourceId,
actionType: r.actionType,
reason: r.reason,
code: r.code,
Expand Down Expand Up @@ -126,8 +137,119 @@ export default async function ApprovalsPage() {
: [],
])

// The email landing moment: resolve what the deep-linked decision is doing
// right now, and say it plainly — waiting on you, already decided, waiting
// on a pool below you, or not visible from this wallet.
type Focus =
| { kind: "pending" }
| { kind: "resolved"; status: string; decidedAt: string | null; note: string | null; grant: string | null; title: string; agentName: string }
| { kind: "descendant"; poolName: string; title: string; resolved: boolean }
| { kind: "missing" }
let focus: Focus | null = null
if (review) {
if (pending.some((a) => a.id === review || a.sourceId === review)) {
focus = { kind: "pending" }
} else {
const row = await db.pendingApproval.findFirst({
where: { OR: [{ id: review }, { sourceType: "authorization_request", sourceId: review }] },
include: {
agent: { select: { name: true } },
wallet: { select: { id: true, name: true } },
grants: { select: { status: true }, take: 1, orderBy: { createdAt: "desc" } },
},
})
if (!row) {
focus = { kind: "missing" }
} else if (row.wallet.id === walletId) {
focus = {
kind: "resolved",
status: row.status,
decidedAt: row.resolvedAt?.toLocaleString() ?? null,
note: row.resolutionNote,
grant: row.grants[0]?.status ?? null,
title: resourceTitle(asRecord(row.resourceJson), row.actionType),
agentName: row.agent.name,
}
} else if (descendantIds.includes(row.wallet.id)) {
focus = {
kind: "descendant",
poolName: row.wallet.name,
title: resourceTitle(asRecord(row.resourceJson), row.actionType),
// A stale email link must not claim "waiting" for a settled decision.
resolved: row.status !== "pending",
}
} else {
focus = { kind: "missing" }
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<div className="min-h-screen max-w-6xl mx-auto space-y-6 p-6">
{focus && (
<Card
className={
focus.kind === "pending"
? "border-emerald-600/40 bg-emerald-500/[0.06]"
: "bg-card border-border"
}
>
<CardContent className="px-4 py-4">
{focus.kind === "pending" && (
<>
<p className="text-sm font-medium text-foreground">This decision is waiting on you.</p>
<p className="mt-1 text-xs text-muted-foreground">
It&apos;s highlighted below. The agent is paused mid-action: approving mints a single-use grant it
redeems to proceed; rejecting stops it. If you do nothing, policy settles it at the deadline.
</p>
</>
)}
{focus.kind === "resolved" && (
<>
<p className="text-sm font-medium text-foreground">
Already decided: {focus.title} · {focus.agentName} — <span className="uppercase">{focus.status}</span>
{focus.decidedAt ? ` at ${focus.decidedAt}` : ""}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{focus.status === "approved"
? focus.grant === "consumed"
? "The grant it issued was redeemed — the agent completed the action."
: "It issued a single-use grant" + (focus.grant ? ` (currently ${focus.grant})` : "") + "."
: focus.status === "expired"
? "No one decided in time, so policy settled it — the fail-closed default."
: "The agent was told no and stood down."}
{focus.note ? ` Note: ${focus.note}` : ""} The full record is in the Resolved list below.
</p>
</>
)}
{focus.kind === "descendant" && (
<>
<p className="text-sm font-medium text-foreground">
This decision ({focus.title}) {focus.resolved ? "was already decided in" : "is waiting in"}{" "}
<strong>{focus.poolName}</strong>.
</p>
<p className="mt-1 text-xs text-muted-foreground">
{focus.resolved
? "Its owner settled it — sign in with that pool's key to see the full record."
: 'Each pool\u2019s own owner decides — you can see it waiting under "Waiting in your pools" below, but the approve belongs to that pool\u2019s inbox. Sign in with that pool\u2019s key to decide.'}
</p>
</>
)}
{focus.kind === "missing" && (
<>
<p className="text-sm font-medium text-foreground">That decision isn&apos;t visible from this wallet.</p>
<p className="mt-1 text-xs text-muted-foreground">
The email went to the owner of the wallet that raised it. {" "}
<Link className="underline" href={`/login?next=${encodeURIComponent(`/dashboard/approvals?review=${review}`)}`}>
Sign in with that wallet&apos;s key
</Link>{" "}
to land back here on the decision.
</p>
</>
)}
</CardContent>
</Card>
)}
<div>
<h1 className="font-display text-xl font-semibold tracking-tight text-foreground">Authorization inbox</h1>
<p className="mt-1 text-sm text-muted-foreground">
Expand Down Expand Up @@ -178,7 +300,7 @@ export default async function ApprovalsPage() {
<Badge className="bg-[oklch(0.55_0.1_85)]/10 text-[oklch(0.5_0.1_85)] dark:text-[oklch(0.82_0.11_85)] border border-[oklch(0.55_0.1_85)]/25 font-mono">{pending.length}</Badge>
)}
</div>
<ApprovalQueue pending={pending} editable={view.isSession} />
<ApprovalQueue pending={pending} editable={view.isSession} focusId={review} />

{orgPending.length > 0 && (
<Card className="bg-card border-border">
Expand Down
12 changes: 11 additions & 1 deletion app/login/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ import { sendMagicLinkEmail } from "@/lib/email"

export type LoginState = { error: string }

// Only ever redirect within the app: a next must be a local absolute path.
// Rejected: protocol-relative //host, and anything carrying a backslash (raw
// or %5C-encoded) — browsers normalize \ to / so "/\evil.com" becomes the
// off-site "//evil.com". Anything suspicious falls back to the dashboard.
function safeNext(raw: unknown): string {
const next = typeof raw === "string" ? raw : ""
const ok = next.startsWith("/") && !next.startsWith("//") && !next.includes("\\") && !/%5c/i.test(next)
return ok ? next : "/dashboard"
}

export async function loginAction(_prev: LoginState, form: FormData): Promise<LoginState> {
const ip = ipFromHeaders(await headers())
const rl = await rateLimit("login", ip, 30, 600)
Expand All @@ -24,7 +34,7 @@ export async function loginAction(_prev: LoginState, form: FormData): Promise<Lo
if (!wallet) return { error: "That key doesn't match a wallet. Use the management key (sk_…) from signup." }

await setSession(key)
redirect("/dashboard")
redirect(safeNext(form.get("next")))
}

export async function logoutAction() {
Expand Down
5 changes: 3 additions & 2 deletions app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ function Divider({ label }: { label: string }) {

export const dynamic = "force-dynamic"

export default function LoginPage() {
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) {
const { next } = await searchParams
return (
<div className={`sanction ${brandFontVars}`} style={{ minHeight: "100vh", background: "var(--surface-page)", color: "var(--text-body)" }}>
<header className="border-b" style={{ borderColor: "var(--paper-3)" }}>
Expand All @@ -49,7 +50,7 @@ export default function LoginPage() {
Have a management key (<code className="font-mono">sk_…</code>)? Sign in with it →
</summary>
<div className="mt-4">
<LoginForm />
<LoginForm next={next} />
</div>
</details>

Expand Down
Loading
Loading