- 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]>
213 lines
7.2 KiB
Rust
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);
|
|
}
|