Files
clawmates/crates/tc-safety/tests/approvals.rs
T
Omar SobhandClaude Fable 5 de38449b41 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]>
2026-06-10 04:58:47 -05:00

213 lines
7.2 KiB
Rust

use serde_json::json;
use tc_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, GatedCategory, Role, User, UserId, Workspace,
WorkspaceId,
};
use tc_safety::{
approvals, checkpoint, grants, ApprovalStatus, Decision, NewApproval, SafetyError,
};
struct Seed {
workspace: Workspace,
owner: User,
agent: Agent,
run_id: uuid::Uuid,
}
async fn seeded(pool: &sqlx::PgPool) -> Seed {
let workspace = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
tc_db::repo::workspaces::insert(pool, &workspace)
.await
.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: workspace.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
tc_db::repo::users::insert(pool, &owner).await.unwrap();
let agent = Agent {
id: AgentId::new(),
workspace_id: workspace.id,
name: "Scout".into(),
job_title: "Analyst".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: String::new(),
wallpaper: String::new(),
managed_by: owner.id,
status: AgentStatus::Online,
};
tc_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await
.unwrap();
let session = tc_db::repo::sessions::create(pool, agent.id, workspace.id, "Chat")
.await
.unwrap();
let run_id = tc_db::repo::runs::create(pool, session.id).await.unwrap();
Seed {
workspace,
owner,
agent,
run_id,
}
}
fn new_approval(seed: &Seed) -> NewApproval {
NewApproval {
workspace_id: seed.workspace.id,
run_id: seed.run_id,
session_key: "agent:x-claw-0:session:y:z".into(),
action_type: "email.send".into(),
category: GatedCategory::OutboundMessage,
payload: json!({"to": "[email protected]", "subject": "Q2", "body": "Numbers"}),
preview: json!({"summary": "Send email to [email protected]", "body": "Numbers"}),
requested_by_agent: seed.agent.id,
taint_sources: vec![],
expires_at: None,
}
}
#[tokio::test]
async fn created_approvals_are_listed_pending_with_exact_preview() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
assert_eq!(approval.status, ApprovalStatus::Pending);
let pending = approvals::list_pending(&pool, seed.workspace.id)
.await
.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, approval.id);
assert_eq!(
pending[0].preview["summary"],
"Send email to [email protected]"
);
assert_eq!(pending[0].category, GatedCategory::OutboundMessage);
}
#[tokio::test]
async fn approve_is_a_cas_that_mints_one_grant_and_audits() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
let decided = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve)
.await
.unwrap();
assert_eq!(decided.status, ApprovalStatus::Approved);
assert_eq!(decided.decided_by, Some(seed.owner.id));
// Double-decide is rejected, not silently absorbed.
let again = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve).await;
assert!(matches!(again, Err(SafetyError::AlreadyDecided)));
// Decision is audit-logged.
let audited = sqlx::query_scalar::<_, i64>(
"SELECT count(*) FROM audit_log WHERE event_type = 'approval.approved'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(audited, 1);
// The grant is single-use: first consumption succeeds, second refuses.
grants::consume(&pool, approval.id).await.unwrap();
let reused = grants::consume(&pool, approval.id).await;
assert!(matches!(reused, Err(SafetyError::GrantUnavailable)));
}
#[tokio::test]
async fn reject_mints_no_grant() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
let decided = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Reject)
.await
.unwrap();
assert_eq!(decided.status, ApprovalStatus::Rejected);
let no_grant = grants::consume(&pool, approval.id).await;
assert!(matches!(no_grant, Err(SafetyError::GrantUnavailable)));
}
#[tokio::test]
async fn pending_approvals_cannot_be_consumed() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
let blocked = grants::consume(&pool, approval.id).await;
assert!(matches!(blocked, Err(SafetyError::GrantUnavailable)));
}
#[tokio::test]
async fn checkpoint_round_trips_and_resume_claim_is_exclusive() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let state = json!({"request": {"model": "scripted"}, "step_seq": 3});
checkpoint::suspend(&pool, seed.run_id, &state)
.await
.unwrap();
let run = tc_db::repo::runs::get(&pool, seed.run_id).await.unwrap();
assert_eq!(run.state, tc_domain::RunState::AwaitingApproval);
let loaded = checkpoint::load(&pool, seed.run_id).await.unwrap();
assert_eq!(loaded, state);
// Exactly one resumer can claim the run.
assert!(checkpoint::claim_resume(&pool, seed.run_id).await.unwrap());
assert!(!checkpoint::claim_resume(&pool, seed.run_id).await.unwrap());
}
#[tokio::test]
async fn expiry_sweep_expires_overdue_pending_approvals() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let mut overdue = new_approval(&seed);
overdue.expires_at = Some(time::OffsetDateTime::now_utc() - time::Duration::minutes(5));
let approval = approvals::create(&pool, overdue).await.unwrap();
let expired = approvals::sweep_expired(&pool).await.unwrap();
assert_eq!(expired, 1);
let after = approvals::get(&pool, approval.id).await.unwrap();
assert_eq!(after.status, ApprovalStatus::Expired);
// Expired approvals cannot be decided.
let late = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve).await;
assert!(matches!(late, Err(SafetyError::AlreadyDecided)));
}
#[tokio::test]
async fn decided_unresumed_finds_runs_awaiting_resume() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
checkpoint::suspend(&pool, seed.run_id, &json!({"s": 1}))
.await
.unwrap();
// Nothing decided yet.
assert!(approvals::decided_unresumed(&pool)
.await
.unwrap()
.is_empty());
approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve)
.await
.unwrap();
let ready = approvals::decided_unresumed(&pool).await.unwrap();
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].run_id, seed.run_id);
assert_eq!(ready[0].approval_id, approval.id);
}