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:
co-authored by
Claude Fable 5
parent
9f9f507c15
commit
de38449b41
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user