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,212 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user