P2 BLOCKING exit green: approval interception chain end-to-end

- tc-tools: Effect declarations -> §15 GatedCategory mapping, deny-by-default
  external reach, taint invariant property-tested (tainted external effects
  are NEVER auto-allowed)
- tc-safety: pending approvals with exact payload+preview, CAS decide with
  audit + single-use grant in one tx, checkpoint suspend/load, exclusive
  resume claim, expiry sweep, decided-unresumed work queue (migration 0004
  adds the outbox the gated email.send tool writes)
- tc-runtime: resumable LoopState checkpointed to agent_runs; gated tool ->
  approval row -> approval_required/run_suspended events -> suspend; resume
  consumes the grant BEFORE executing (spent grant = no execution), rejection
  feeds a structured refusal in-band; durable resume sweeper; continuous
  journal seq across suspension (tested). ContentPart::Text became a struct
  variant — internally-tagged newtype primitives don't serialize
- tc-api: GET/decide approvals endpoints (409 double-decide, tenant
  isolation), decision triggers in-process resume; full chain proven over
  HTTP incl. gateway resumeFrom continuation
- frontend: approval_required/run_suspended events, suspended reply state,
  inline ApprovalCard (§10: summary, category, exact payload preview,
  approve/reject -> decide + stream re-attach), /approvals queue page, nav
- E2E (14 journeys, workers:1 to serialize the shared backend): gated email
  blocks with disabled composer -> approve -> continuation + ✓ step + reload
  replay; reject -> ✗ step, nothing executed; queue page decides pending

106 Rust + 61 frontend tests + 14 Playwright journeys green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 04:58:47 -05:00
co-authored by Claude Fable 5
parent 9f9f507c15
commit de38449b41
62 changed files with 3576 additions and 203 deletions
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import type { UiPendingApproval } from "@/lib/gateway/transcript";
interface ApprovalCardProps {
approval: UiPendingApproval;
onDecide: (approvalId: string, decision: "approve" | "reject") => void;
/** Agent name for the "this claw wants to" line. */
agentName: string;
}
const CATEGORY_LABELS: Record<string, string> = {
outbound_message: "Outbound message",
secret_sharing: "Secret sharing",
access_change: "Access change",
financial_transaction: "Financial transaction",
file_deletion: "File deletion",
infra_access_grant: "Infrastructure access",
};
function previewField(preview: unknown, key: string): string | null {
if (typeof preview !== "object" || preview === null) {
return null;
}
const value = (preview as Record<string, unknown>)[key];
return typeof value === "string" ? value : null;
}
/** The §10 approval card: what the claw wants to do, the EXACT payload
* that will execute, and the explicit human decision. */
export function ApprovalCard({ approval, onDecide, agentName }: ApprovalCardProps) {
const [pending, setPending] = useState(false);
const summary =
previewField(approval.preview, "summary") ?? approval.actionType;
function decide(decision: "approve" | "reject") {
setPending(true);
onDecide(approval.id, decision);
}
return (
<section
aria-label="Review and approve"
className="mt-2 max-w-md rounded-(--radius) border border-accent/40 bg-surface-warm p-3 shadow-(--shadow-card) motion-safe:animate-[scale-in_var(--duration-fast)_var(--ease-app)]"
>
<p className="text-xs font-semibold uppercase tracking-wide text-accent">
Review &amp; approve
</p>
<p className="pt-1 text-sm">
{agentName} wants to: <span className="font-medium">{summary}</span>
</p>
<p className="pt-0.5 text-xxs text-muted-foreground">
{CATEGORY_LABELS[approval.category] ?? approval.category} ·{" "}
{approval.actionType}
</p>
<div className="mt-2 rounded-(--radius) border border-border bg-background p-2">
<p className="text-xxs uppercase tracking-wide text-muted-foreground">
Preview
</p>
<pre className="overflow-x-auto whitespace-pre-wrap pt-1 font-mono text-xs text-foreground">
{JSON.stringify(approval.preview, null, 2)}
</pre>
</div>
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
disabled={pending}
onClick={() => decide("reject")}
className="rounded-(--radius-button) border border-border px-4 py-1.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
>
Reject
</button>
<button
type="button"
disabled={pending}
onClick={() => decide("approve")}
className="rounded-(--radius-button) bg-accent px-4 py-1.5 text-xs font-medium text-background shadow-(--shadow-cta) hover:bg-coral-light disabled:opacity-50"
>
Approve
</button>
</div>
</section>
);
}