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,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO execution_grants (id, approval_id, nonce)\n VALUES ($1, $2, $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "0da90e00e009575f8570de01d2ee0e7cbd6cea8605b331b44a96049e361e378c"
}
@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.run_id, a.id AS approval_id, a.status\n FROM approvals a\n JOIN agent_runs r ON r.id = a.run_id\n WHERE a.status IN ('approved', 'rejected')\n AND r.state = 'awaiting_approval'\n ORDER BY a.decided_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "run_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "approval_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "status",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false
]
},
"hash": "2390b39f2359c75dc13c5749adbb912534d29ed74a920e3940338cb63369693f"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE execution_grants\n SET consumed = true, consumed_at = now()\n WHERE approval_id = $1 AND consumed = false\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "2dcf8d290a8219810eb3340de4a5c7d3e92f8acde30195333731a1924a117968"
}
@@ -0,0 +1,94 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, run_id, session_key, action_type,\n category, payload, preview, requested_by_agent,\n taint_sources, status, decided_by, created_at\n FROM approvals\n WHERE workspace_id = $1 AND status = 'pending'\n ORDER BY created_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "run_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "session_key",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "action_type",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "category",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "payload",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"name": "preview",
"type_info": "Jsonb"
},
{
"ordinal": 8,
"name": "requested_by_agent",
"type_info": "Uuid"
},
{
"ordinal": 9,
"name": "taint_sources",
"type_info": "TextArray"
},
{
"ordinal": 10,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "decided_by",
"type_info": "Uuid"
},
{
"ordinal": 12,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false
]
},
"hash": "2e38e993b07b0e7f9f61c23dea9209faafbc8fbe06b15012dd14b0b51d71b62b"
}
@@ -0,0 +1,104 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO approvals\n (id, workspace_id, run_id, session_key, action_type, category,\n payload, preview, requested_by_agent, taint_sources, status,\n expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', $11)\n RETURNING id, workspace_id, run_id, session_key, action_type,\n category, payload, preview, requested_by_agent,\n taint_sources, status, decided_by, created_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "run_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "session_key",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "action_type",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "category",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "payload",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"name": "preview",
"type_info": "Jsonb"
},
{
"ordinal": 8,
"name": "requested_by_agent",
"type_info": "Uuid"
},
{
"ordinal": 9,
"name": "taint_sources",
"type_info": "TextArray"
},
{
"ordinal": 10,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "decided_by",
"type_info": "Uuid"
},
{
"ordinal": 12,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Uuid",
"Text",
"Text",
"Text",
"Jsonb",
"Jsonb",
"Uuid",
"TextArray",
"Timestamptz"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false
]
},
"hash": "40540e457a09c543487f49cf782fdf84bd5fcce6f35601332288c1901fd8faee"
}
@@ -0,0 +1,96 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE approvals\n SET status = $2, decided_by = $3, decided_at = now()\n WHERE id = $1 AND status = 'pending'\n RETURNING id, workspace_id, run_id, session_key, action_type,\n category, payload, preview, requested_by_agent,\n taint_sources, status, decided_by, created_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "run_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "session_key",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "action_type",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "category",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "payload",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"name": "preview",
"type_info": "Jsonb"
},
{
"ordinal": 8,
"name": "requested_by_agent",
"type_info": "Uuid"
},
{
"ordinal": 9,
"name": "taint_sources",
"type_info": "TextArray"
},
{
"ordinal": 10,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "decided_by",
"type_info": "Uuid"
},
{
"ordinal": 12,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false
]
},
"hash": "482852be248fb6961f54dcbbb45ad0c2532966e1d9f467ce40d32b99ebfc3a1f"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT 1 AS x FROM approvals WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "x",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "4ef893a7e6e42739910e1fa7ec169d96d69910abf5f903d3690dfe2cae47c217"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE agent_runs SET state = 'running', updated_at = now()\n WHERE id = $1 AND state = 'awaiting_approval'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "7817ece28b619bb84e5c4dfac7b766be79b10e43b8aea1d704983d8703af9586"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO outbox (id, workspace_id, agent_id, recipient, subject, body)\n VALUES ($1, $2, $3, $4, $5, $6)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Uuid",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "96fbd944f8e9363b7be01affe85a1b6bd54183e635088bc6dcf80934e5412011"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO audit_log\n (workspace_id, actor_kind, actor_id, event_type, subject_type,\n subject_id, detail)\n VALUES ($1, 'user', $2, $3, 'approval', $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Jsonb"
]
},
"nullable": []
},
"hash": "9bfcbca59dff3744ac0a1cdbdb1ccd27a45a0e299d3a59423259d2df648e45b7"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE agent_runs\n SET checkpoint = $2, state = 'awaiting_approval', updated_at = now()\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Jsonb"
]
},
"nullable": []
},
"hash": "a1e909c4be1add6f6e43947ef95aad247f2748cb6f89cadf4c6433abdb81d8f3"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT checkpoint FROM agent_runs WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "checkpoint",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "b6a4e6ec07d810102690f94149f8610f379cb1e43fd054f5d045ac6f4f853412"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE approvals SET status = 'expired'\n WHERE status = 'pending' AND expires_at IS NOT NULL AND expires_at < now()",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "f7bef1eca457a8f986131756a7f0b1c99ff398824aafa70aa8c222079b940232"
}
@@ -0,0 +1,94 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, run_id, session_key, action_type,\n category, payload, preview, requested_by_agent,\n taint_sources, status, decided_by, created_at\n FROM approvals WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "run_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "session_key",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "action_type",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "category",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "payload",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"name": "preview",
"type_info": "Jsonb"
},
{
"ordinal": 8,
"name": "requested_by_agent",
"type_info": "Uuid"
},
{
"ordinal": 9,
"name": "taint_sources",
"type_info": "TextArray"
},
{
"ordinal": 10,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "decided_by",
"type_info": "Uuid"
},
{
"ordinal": 12,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false
]
},
"hash": "fe53e0a71d3cd00649fa1d36c388a4c9ce076c30cb1b5e125c3a64539172c08a"
}
Generated
+29
View File
@@ -2776,6 +2776,7 @@ dependencies = [
"tc-domain", "tc-domain",
"tc-llm", "tc-llm",
"tc-runtime", "tc-runtime",
"tc-safety",
"tc-testkit", "tc-testkit",
"thiserror", "thiserror",
"time", "time",
@@ -2864,6 +2865,24 @@ dependencies = [
"tc-db", "tc-db",
"tc-domain", "tc-domain",
"tc-llm", "tc-llm",
"tc-safety",
"tc-testkit",
"tc-tools",
"thiserror",
"time",
"tokio",
"uuid",
]
[[package]]
name = "tc-safety"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"sqlx",
"tc-db",
"tc-domain",
"tc-testkit", "tc-testkit",
"thiserror", "thiserror",
"time", "time",
@@ -2882,6 +2901,16 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "tc-tools"
version = "0.1.0"
dependencies = [
"proptest",
"serde",
"serde_json",
"tc-domain",
]
[[package]] [[package]]
name = "teamclaw-server" name = "teamclaw-server"
version = "0.1.0" version = "0.1.0"
+2
View File
@@ -6,6 +6,8 @@ members = [
"crates/tc-db", "crates/tc-db",
"crates/tc-llm", "crates/tc-llm",
"crates/tc-runtime", "crates/tc-runtime",
"crates/tc-tools",
"crates/tc-safety",
"crates/tc-testkit", "crates/tc-testkit",
"crates/tc-auth", "crates/tc-auth",
"crates/tc-api", "crates/tc-api",
+3
View File
@@ -77,6 +77,9 @@ async fn run() -> Result<(), String> {
max_tokens: 4096, max_tokens: 4096,
}, },
); );
// Durable §15 path: expires overdue approvals and resumes decided runs
// even if the deciding request's process died mid-flight.
runtime.spawn_resume_sweeper(std::time::Duration::from_secs(2));
let app = tc_api::router(tc_api::AppState::new(pool, runtime)); let app = tc_api::router(tc_api::AppState::new(pool, runtime));
let listener = tokio::net::TcpListener::bind(config.listen_addr) let listener = tokio::net::TcpListener::bind(config.listen_addr)
+1
View File
@@ -17,6 +17,7 @@ tc-auth = { path = "../tc-auth" }
tc-db = { path = "../tc-db" } tc-db = { path = "../tc-db" }
tc-domain = { path = "../tc-domain" } tc-domain = { path = "../tc-domain" }
tc-runtime = { path = "../tc-runtime" } tc-runtime = { path = "../tc-runtime" }
tc-safety = { path = "../tc-safety" }
thiserror = { workspace = true } thiserror = { workspace = true }
time = { workspace = true } time = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
+3
View File
@@ -13,6 +13,8 @@ pub enum ApiError {
Forbidden, Forbidden,
#[error("not found")] #[error("not found")]
NotFound, NotFound,
#[error("conflict")]
Conflict,
#[error("internal error")] #[error("internal error")]
Internal, Internal,
} }
@@ -43,6 +45,7 @@ impl IntoResponse for ApiError {
ApiError::Unauthorized => StatusCode::UNAUTHORIZED, ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
ApiError::Forbidden => StatusCode::FORBIDDEN, ApiError::Forbidden => StatusCode::FORBIDDEN,
ApiError::NotFound => StatusCode::NOT_FOUND, ApiError::NotFound => StatusCode::NOT_FOUND,
ApiError::Conflict => StatusCode::CONFLICT,
ApiError::Internal => StatusCode::INTERNAL_SERVER_ERROR, ApiError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
}; };
(status, Json(json!({ "error": self.to_string() }))).into_response() (status, Json(json!({ "error": self.to_string() }))).into_response()
+10
View File
@@ -48,6 +48,16 @@ pub fn router(state: AppState) -> Router {
.route("/api/sessions", post(routes::sessions::create)) .route("/api/sessions", post(routes::sessions::create))
.route("/api/sessions/history", get(routes::sessions::history)) .route("/api/sessions/history", get(routes::sessions::history))
.route("/api/gateway", post(routes::gateway::gateway)) .route("/api/gateway", post(routes::gateway::gateway))
.route("/api/approvals", get(routes::approvals::list))
.route("/api/approvals/{id}", get(routes::approvals::get))
.route(
"/api/approvals/{id}/approve",
post(routes::approvals::approve),
)
.route(
"/api/approvals/{id}/reject",
post(routes::approvals::reject),
)
.route("/api/team/claws", get(routes::team::claws)) .route("/api/team/claws", get(routes::team::claws))
.route("/api/team/members", get(routes::team::members)) .route("/api/team/members", get(routes::team::members))
.route("/api/team/credits", get(routes::team::credits)) .route("/api/team/credits", get(routes::team::credits))
+93
View File
@@ -0,0 +1,93 @@
use axum::extract::{Path, State};
use axum::Json;
use serde_json::{json, Value};
use tc_safety::{approvals, Approval, Decision, ResumeReady, SafetyError};
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
impl From<SafetyError> for ApiError {
fn from(err: SafetyError) -> Self {
match err {
SafetyError::NotFound => ApiError::NotFound,
SafetyError::AlreadyDecided => ApiError::Conflict,
_ => ApiError::Internal,
}
}
}
/// Loads an approval, hiding other workspaces' approvals entirely.
async fn workspace_approval(
state: &AppState,
user: &tc_auth::AuthedUser,
id: Uuid,
) -> Result<Approval, ApiError> {
let approval = approvals::get(&state.pool, id).await?;
if approval.workspace_id != user.workspace_id {
return Err(ApiError::NotFound);
}
Ok(approval)
}
/// GET /api/approvals — the pending review queue (§10), oldest first.
pub async fn list(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<Approval>>, ApiError> {
let pending = approvals::list_pending(&state.pool, user.workspace_id).await?;
Ok(Json(pending))
}
/// GET /api/approvals/{id}
pub async fn get(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Approval>, ApiError> {
Ok(Json(workspace_approval(&state, &user, id).await?))
}
async fn decide(
state: AppState,
user: tc_auth::AuthedUser,
id: Uuid,
decision: Decision,
) -> Result<Json<Value>, ApiError> {
workspace_approval(&state, &user, id).await?;
let approval = approvals::decide(&state.pool, id, user.user_id, decision).await?;
// Kick the resume immediately; the sweeper remains the durable
// fallback if this process dies first.
let runtime = state.runtime.clone();
let ready = ResumeReady {
run_id: approval.run_id,
approval_id: approval.id,
approved: decision == Decision::Approve,
};
tokio::spawn(async move {
let _ = runtime.resume_run(ready).await;
});
Ok(Json(
json!({ "id": approval.id, "status": approval.status }),
))
}
/// POST /api/approvals/{id}/approve — executes the gated action (§15:
/// approve-only execution, idempotent, audited).
pub async fn approve(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
decide(state, user, id, Decision::Approve).await
}
/// POST /api/approvals/{id}/reject — the agent learns the refusal in-band.
pub async fn reject(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
decide(state, user, id, Decision::Reject).await
}
+1
View File
@@ -1,3 +1,4 @@
pub mod approvals;
pub mod auth; pub mod auth;
pub mod claws; pub mod claws;
pub mod gateway; pub mod gateway;
+326
View File
@@ -0,0 +1,326 @@
//! The §15 chain over HTTP: gateway suspends, the approvals API decides,
//! and the gateway resume streams the continuation.
use std::sync::Arc;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use serde_json::{json, Value};
use tc_api::AppState;
use tc_auth::AuthService;
use tc_domain::{Role, User, UserId, Workspace, WorkspaceId};
use tc_llm::ScriptedProvider;
use tc_runtime::{Runtime, RuntimeConfig};
const SCENARIOS: &str = r#"
[[scenario]]
marker = "[[scenario:gated-email]]"
[[scenario.turns]]
events = [
{ type = "text", text = "I'll send that email." },
{ type = "tool_use", name = "email.send", input = { to = "[email protected]", subject = "Q2", body = "Revenue is up." } },
]
[[scenario.turns]]
events = [
{ type = "text", text = " The email step is finished." },
]
"#;
struct TestServer {
base: String,
client: reqwest::Client,
}
async fn serve(pool: sqlx::PgPool) -> TestServer {
let runtime = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig {
model: "scripted".into(),
max_tokens: 1024,
},
);
let app = tc_api::router(AppState::new(pool, runtime));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
TestServer {
base: format!("http://{addr}"),
client: reqwest::Client::new(),
}
}
async fn seed_and_login(pool: &sqlx::PgPool, server: &TestServer) -> (String, String) {
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
tc_db::repo::workspaces::insert(pool, &ws).await.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: ws.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();
AuthService::new(pool.clone())
.set_password(owner.id, "pw")
.await
.unwrap();
let token = server
.client
.post(format!("{}/api/auth/login", server.base))
.json(&json!({"email": owner.email, "password": "pw"}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_owned();
let claw: Value = server
.client
.post(format!("{}/api/claws", server.base))
.bearer_auth(&token)
.json(&json!({"name": "Scout", "job_title": "Analyst"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
(token, claw["id"].as_str().unwrap().to_owned())
}
async fn collect_sse(response: reqwest::Response) -> Vec<(String, String, Value)> {
let mut events = Vec::new();
let mut stream = response.bytes_stream().eventsource();
while let Some(event) = stream.next().await {
let event = event.unwrap();
let data: Value = serde_json::from_str(&event.data).unwrap();
let name = event.event.clone();
let done = matches!(name.as_str(), "run_completed" | "error" | "run_suspended");
events.push((event.id, name, data));
if done {
break;
}
}
events
}
/// Starts the gated run and returns (session_key, approval_id, last_seq).
async fn suspend_gated_run(
server: &TestServer,
token: &str,
claw_id: &str,
) -> (String, String, i64) {
let session: Value = server
.client
.post(format!("{}/api/sessions", server.base))
.bearer_auth(token)
.json(&json!({"clawId": claw_id, "title": "Email"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let session_key = session["sessionKey"].as_str().unwrap().to_owned();
let events = collect_sse(
server
.client
.post(format!("{}/api/gateway?clawId={claw_id}", server.base))
.bearer_auth(token)
.json(&json!({
"sessionKey": session_key,
"message": "send it [[scenario:gated-email]]"
}))
.send()
.await
.unwrap(),
)
.await;
let (last_id, name, data) = events.last().unwrap();
assert_eq!(name, "run_suspended");
let approval_id = data["approval_id"].as_str().unwrap().to_owned();
(session_key, approval_id, last_id.parse().unwrap())
}
#[tokio::test]
async fn full_chain_over_http_executes_only_after_approval() {
let pool = tc_testkit::test_pool().await;
let server = serve(pool.clone()).await;
let (token, claw_id) = seed_and_login(&pool, &server).await;
let (session_key, approval_id, last_seq) = suspend_gated_run(&server, &token, &claw_id).await;
// Queued: the approvals API lists it with the exact preview.
let queue: Value = server
.client
.get(format!("{}/api/approvals", server.base))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let pending = queue.as_array().unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0]["id"], approval_id);
assert_eq!(
pending[0]["preview"]["summary"],
"Send email to [email protected]"
);
// Blocked while pending.
let outbox = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM outbox")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(outbox, 0);
// Approve over HTTP.
let approve = server
.client
.post(format!(
"{}/api/approvals/{approval_id}/approve",
server.base
))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(approve.status(), 200);
// Double-decide is a conflict.
let again = server
.client
.post(format!(
"{}/api/approvals/{approval_id}/approve",
server.base
))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(again.status(), 409);
// Re-attach the gateway from where we left off: the continuation
// streams the approved execution through to completion.
let continuation = collect_sse(
server
.client
.post(format!("{}/api/gateway?clawId={claw_id}", server.base))
.bearer_auth(&token)
.json(&json!({"sessionKey": session_key, "resumeFrom": last_seq}))
.send()
.await
.unwrap(),
)
.await;
let kinds: Vec<&str> = continuation.iter().map(|(_, n, _)| n.as_str()).collect();
assert!(kinds.contains(&"step_finished"));
assert_eq!(*kinds.last().unwrap(), "run_completed");
let outbox = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM outbox")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(outbox, 1);
// The queue is clear again.
let queue: Value = server
.client
.get(format!("{}/api/approvals", server.base))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(queue.as_array().unwrap().is_empty());
}
#[tokio::test]
async fn reject_over_http_executes_nothing() {
let pool = tc_testkit::test_pool().await;
let server = serve(pool.clone()).await;
let (token, claw_id) = seed_and_login(&pool, &server).await;
let (session_key, approval_id, last_seq) = suspend_gated_run(&server, &token, &claw_id).await;
let reject = server
.client
.post(format!(
"{}/api/approvals/{approval_id}/reject",
server.base
))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(reject.status(), 200);
let continuation = collect_sse(
server
.client
.post(format!("{}/api/gateway?clawId={claw_id}", server.base))
.bearer_auth(&token)
.json(&json!({"sessionKey": session_key, "resumeFrom": last_seq}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(continuation.last().unwrap().1, "run_completed");
let outbox = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM outbox")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(outbox, 0);
}
#[tokio::test]
async fn approvals_are_tenant_isolated() {
let pool = tc_testkit::test_pool().await;
let server = serve(pool.clone()).await;
let (token, claw_id) = seed_and_login(&pool, &server).await;
let (other_token, _) = seed_and_login(&pool, &server).await;
let (_, approval_id, _) = suspend_gated_run(&server, &token, &claw_id).await;
// Another workspace sees an empty queue and cannot decide.
let queue: Value = server
.client
.get(format!("{}/api/approvals", server.base))
.bearer_auth(&other_token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(queue.as_array().unwrap().is_empty());
let foreign = server
.client
.post(format!(
"{}/api/approvals/{approval_id}/approve",
server.base
))
.bearer_auth(&other_token)
.send()
.await
.unwrap();
assert_eq!(foreign.status(), 404);
}
+28
View File
@@ -30,4 +30,32 @@ impl GatedCategory {
GatedCategory::FileDeletion, GatedCategory::FileDeletion,
GatedCategory::InfraAccessGrant, GatedCategory::InfraAccessGrant,
]; ];
/// Storage form matching the `approvals.category` CHECK constraint.
pub fn as_str(&self) -> &'static str {
match self {
GatedCategory::OutboundMessage => "outbound_message",
GatedCategory::SecretSharing => "secret_sharing",
GatedCategory::AccessChange => "access_change",
GatedCategory::FinancialTransaction => "financial_transaction",
GatedCategory::FileDeletion => "file_deletion",
GatedCategory::InfraAccessGrant => "infra_access_grant",
}
}
}
impl std::str::FromStr for GatedCategory {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"outbound_message" => Ok(GatedCategory::OutboundMessage),
"secret_sharing" => Ok(GatedCategory::SecretSharing),
"access_change" => Ok(GatedCategory::AccessChange),
"financial_transaction" => Ok(GatedCategory::FinancialTransaction),
"file_deletion" => Ok(GatedCategory::FileDeletion),
"infra_access_grant" => Ok(GatedCategory::InfraAccessGrant),
other => Err(format!("unknown gated category: {other}")),
}
}
} }
+1 -1
View File
@@ -42,7 +42,7 @@ impl AnthropicProvider {
.parts .parts
.iter() .iter()
.map(|part| match part { .map(|part| match part {
ContentPart::Text(text) => json!({"type": "text", "text": text}), ContentPart::Text { text } => json!({"type": "text", "text": text}),
ContentPart::ToolUse { id, name, input } => { ContentPart::ToolUse { id, name, input } => {
json!({"type": "tool_use", "id": id, "name": name, "input": input}) json!({"type": "tool_use", "id": id, "name": name, "input": input})
} }
+1 -1
View File
@@ -35,7 +35,7 @@ impl OpenAiCompatProvider {
let mut tool_calls: Vec<Value> = Vec::new(); let mut tool_calls: Vec<Value> = Vec::new();
for part in &message.parts { for part in &message.parts {
match part { match part {
ContentPart::Text(t) => text.push_str(t), ContentPart::Text { text: t } => text.push_str(t),
ContentPart::ToolUse { id, name, input } => tool_calls.push(json!({ ContentPart::ToolUse { id, name, input } => tool_calls.push(json!({
"id": id, "id": id,
"type": "function", "type": "function",
+13 -1
View File
@@ -15,7 +15,12 @@ pub enum ChatRole {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart { pub enum ContentPart {
Text(String), // Struct variant (not newtype): internally-tagged enums cannot
// serialize newtype primitives, and this type round-trips through
// `agent_runs.checkpoint`.
Text {
text: String,
},
ToolUse { ToolUse {
id: String, id: String,
name: String, name: String,
@@ -27,6 +32,13 @@ pub enum ContentPart {
}, },
} }
impl ContentPart {
/// Convenience constructor for plain text parts.
pub fn text(value: impl Into<String>) -> ContentPart {
ContentPart::Text { text: value.into() }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChatMessage { pub struct ChatMessage {
pub role: ChatRole, pub role: ChatRole,
+2 -2
View File
@@ -92,7 +92,7 @@ impl LlmProvider for ScriptedProvider {
.iter() .iter()
.flat_map(|m| m.parts.iter()) .flat_map(|m| m.parts.iter())
.filter_map(|p| match p { .filter_map(|p| match p {
ContentPart::Text(t) => Some(t.as_str()), ContentPart::Text { text } => Some(text.as_str()),
_ => None, _ => None,
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -149,7 +149,7 @@ impl LlmProvider for ScriptedProvider {
.rev() .rev()
.flat_map(|m| m.parts.iter()) .flat_map(|m| m.parts.iter())
.find_map(|p| match p { .find_map(|p| match p {
ContentPart::Text(t) => Some(t.clone()), ContentPart::Text { text } => Some(text.clone()),
_ => None, _ => None,
}) })
.unwrap_or_default(); .unwrap_or_default();
+1 -1
View File
@@ -20,7 +20,7 @@ fn simple_request(model: &str) -> ChatRequest {
system: "Answer in exactly one short sentence.".into(), system: "Answer in exactly one short sentence.".into(),
messages: vec![ChatMessage { messages: vec![ChatMessage {
role: ChatRole::User, role: ChatRole::User,
parts: vec![ContentPart::Text("Say the word 'pong'.".into())], parts: vec![ContentPart::text("Say the word 'pong'.")],
}], }],
tools: vec![], tools: vec![],
model: model.into(), model: model.into(),
+1 -1
View File
@@ -34,7 +34,7 @@ fn request_with_user_text(text: &str) -> ChatRequest {
system: "You are Scout.".into(), system: "You are Scout.".into(),
messages: vec![ChatMessage { messages: vec![ChatMessage {
role: ChatRole::User, role: ChatRole::User,
parts: vec![ContentPart::Text(text.into())], parts: vec![ContentPart::text(text)],
}], }],
tools: vec![], tools: vec![],
model: "scripted".into(), model: "scripted".into(),
+2
View File
@@ -15,6 +15,8 @@ sqlx = { workspace = true }
tc-db = { path = "../tc-db" } tc-db = { path = "../tc-db" }
tc-domain = { path = "../tc-domain" } tc-domain = { path = "../tc-domain" }
tc-llm = { path = "../tc-llm" } tc-llm = { path = "../tc-llm" }
tc-safety = { path = "../tc-safety" }
tc-tools = { path = "../tc-tools" }
thiserror = { workspace = true } thiserror = { workspace = true }
time = { workspace = true } time = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
+14
View File
@@ -30,6 +30,18 @@ pub enum RunEventBody {
status: String, status: String,
output: Value, output: Value,
}, },
/// A gated action awaits human review (§15): carries the exact preview
/// of what will execute, surfaced as the approval card (§10).
ApprovalRequired {
approval_id: Uuid,
category: String,
action_type: String,
preview: Value,
},
/// The run is blocked until the pending approval is decided.
RunSuspended {
approval_id: Uuid,
},
RunCompleted { RunCompleted {
message_id: String, message_id: String,
}, },
@@ -46,6 +58,8 @@ impl RunEventBody {
RunEventBody::TextDelta { .. } => "text_delta", RunEventBody::TextDelta { .. } => "text_delta",
RunEventBody::StepStarted { .. } => "step_started", RunEventBody::StepStarted { .. } => "step_started",
RunEventBody::StepFinished { .. } => "step_finished", RunEventBody::StepFinished { .. } => "step_finished",
RunEventBody::ApprovalRequired { .. } => "approval_required",
RunEventBody::RunSuspended { .. } => "run_suspended",
RunEventBody::RunCompleted { .. } => "run_completed", RunEventBody::RunCompleted { .. } => "run_completed",
RunEventBody::Error { .. } => "error", RunEventBody::Error { .. } => "error",
} }
+1 -1
View File
@@ -8,4 +8,4 @@ mod tools;
pub use events::{RunEventBody, RunEventEnvelope}; pub use events::{RunEventBody, RunEventEnvelope};
pub use runtime::{Runtime, RuntimeConfig, RuntimeError, StartedRun}; pub use runtime::{Runtime, RuntimeConfig, RuntimeError, StartedRun};
pub use tools::{ClockNow, Tool, ToolRegistry}; pub use tools::{ClockNow, EmailSend, Tool, ToolContext, ToolRegistry};
+445 -168
View File
@@ -1,18 +1,28 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use futures::StreamExt; use futures::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sqlx::PgPool; use sqlx::PgPool;
use tc_db::repo::{messages, run_events, runs, sessions, steps}; use tc_db::repo::{messages, run_events, runs, sessions, steps};
use tc_db::DbError; use tc_db::DbError;
use tc_domain::{MessageRole, MessageWithSteps, SessionId, Step, StepStatus}; use tc_domain::{
shard_of, AgentId, MessageId, MessageRole, MessageWithSteps, SessionId, SessionKey, Step,
StepStatus, WorkspaceId,
};
use tc_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent, LlmProvider, StopReason}; use tc_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent, LlmProvider, StopReason};
use tc_safety::{approvals, checkpoint, grants, NewApproval, ResumeReady, SafetyError};
use tc_tools::{GateDecision, GatePolicy, TaintSet};
use tokio::sync::{broadcast, Mutex}; use tokio::sync::{broadcast, Mutex};
use uuid::Uuid; use uuid::Uuid;
use crate::events::{RunEventBody, RunEventEnvelope}; use crate::events::{RunEventBody, RunEventEnvelope};
use crate::tools::ToolRegistry; use crate::tools::{ToolContext, ToolRegistry};
/// How long a pending approval stays decidable before it expires.
const APPROVAL_TTL: time::Duration = time::Duration::hours(24);
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RuntimeConfig { pub struct RuntimeConfig {
@@ -24,8 +34,12 @@ pub struct RuntimeConfig {
pub enum RuntimeError { pub enum RuntimeError {
#[error(transparent)] #[error(transparent)]
Db(#[from] DbError), Db(#[from] DbError),
#[error("safety error: {0}")]
Safety(#[from] SafetyError),
#[error("llm error: {0}")] #[error("llm error: {0}")]
Llm(String), Llm(String),
#[error("checkpoint corrupt: {0}")]
Checkpoint(String),
} }
/// Handle returned to the caller: the run id plus a live event receiver /// Handle returned to the caller: the run id plus a live event receiver
@@ -35,6 +49,37 @@ pub struct StartedRun {
pub events: broadcast::Receiver<RunEventEnvelope>, pub events: broadcast::Receiver<RunEventEnvelope>,
} }
/// A tool call the model requested that has not executed yet.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PendingTool {
id: String,
name: String,
input: Value,
}
/// The complete, serializable state of an in-flight run. This is what
/// `agent_runs.checkpoint` stores while a run awaits approval; resume
/// deserializes it and continues exactly where the loop stopped.
#[derive(Debug, Serialize, Deserialize)]
struct LoopState {
session_id: SessionId,
workspace_id: WorkspaceId,
agent_id: AgentId,
reply_message_id: MessageId,
request: ChatRequest,
full_text: String,
step_seq: i32,
event_seq: i64,
assistant_parts: Vec<ContentPart>,
result_parts: Vec<ContentPart>,
pending_tools: Vec<PendingTool>,
}
enum Outcome {
Completed,
Suspended,
}
/// Owns active run channels; one instance lives in the server state. /// Owns active run channels; one instance lives in the server state.
/// Cheap to clone (shared inner state). /// Cheap to clone (shared inner state).
#[derive(Clone)] #[derive(Clone)]
@@ -63,6 +108,10 @@ impl Runtime {
} }
} }
pub fn pool(&self) -> &PgPool {
&self.inner.pool
}
/// Live-attach to a run that is still streaming. /// Live-attach to a run that is still streaming.
pub async fn subscribe(&self, run_id: Uuid) -> Option<broadcast::Receiver<RunEventEnvelope>> { pub async fn subscribe(&self, run_id: Uuid) -> Option<broadcast::Receiver<RunEventEnvelope>> {
self.inner self.inner
@@ -85,57 +134,160 @@ impl Runtime {
let history = messages::history(&inner.pool, session_id).await?; let history = messages::history(&inner.pool, session_id).await?;
let run_id = runs::create(&inner.pool, session_id).await?; let run_id = runs::create(&inner.pool, session_id).await?;
let (sender, receiver) = broadcast::channel(1024); let receiver = self.open_channel(run_id).await;
inner.channels.lock().await.insert(run_id, sender.clone());
let runtime = self.clone(); messages::append(
let user_text = user_text.to_owned(); &inner.pool,
tokio::spawn(async move { session_id,
let result = runtime MessageRole::User,
.run_loop( json!({"text": user_text}),
run_id, )
session_id, .await?;
&agent.system_prompt, sessions::touch(&inner.pool, session_id).await?;
history, // The reply row exists up front so steps can attach while streaming.
&user_text, let reply = messages::append(
&sender, &inner.pool,
) session_id,
.await; MessageRole::Agent,
if let Err(error) = result { json!({"text": ""}),
let _ = runs::set_state( )
&runtime.inner.pool, .await?;
run_id,
tc_domain::RunState::Failed,
Some(&error.to_string()),
)
.await;
let mut seq = last_journaled_seq(&runtime.inner.pool, run_id).await;
let _ = runtime
.emit(
run_id,
&sender,
&mut seq,
RunEventBody::Error {
message: error.to_string(),
},
)
.await;
}
runtime.inner.channels.lock().await.remove(&run_id);
});
let state = LoopState {
session_id,
workspace_id: agent.workspace_id,
agent_id: agent.id,
reply_message_id: reply.id,
request: ChatRequest {
system: agent.system_prompt.clone(),
messages: chat_messages(&history, user_text),
tools: inner.tools.descriptors(),
model: inner.config.model.clone(),
max_tokens: inner.config.max_tokens,
},
full_text: String::new(),
step_seq: 0,
event_seq: 0,
assistant_parts: Vec::new(),
result_parts: Vec::new(),
pending_tools: Vec::new(),
};
self.spawn_drive(run_id, state, true);
Ok(StartedRun { Ok(StartedRun {
run_id, run_id,
events: receiver, events: receiver,
}) })
} }
/// Resumes a suspended run after its approval was decided. Exactly one
/// caller wins the claim; everyone else returns quietly.
pub async fn resume_run(&self, ready: ResumeReady) -> Result<(), RuntimeError> {
if !checkpoint::claim_resume(&self.inner.pool, ready.run_id).await? {
return Ok(());
}
let raw = checkpoint::load(&self.inner.pool, ready.run_id).await?;
let mut state: LoopState =
serde_json::from_value(raw).map_err(|e| RuntimeError::Checkpoint(e.to_string()))?;
self.open_channel(ready.run_id).await;
let runtime = self.clone();
tokio::spawn(async move {
let result = runtime.resolve_gated_tool(ready, &mut state).await;
match result {
Ok(()) => runtime.spawn_drive_inline(ready.run_id, state).await,
Err(error) => runtime.fail_run(ready.run_id, &error.to_string()).await,
}
});
Ok(())
}
/// The durable resume path: periodically expires overdue approvals and
/// resumes runs whose approvals were decided (survives crashes between
/// decision and resumption).
pub fn spawn_resume_sweeper(&self, interval: Duration) {
let runtime = self.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(interval);
loop {
tick.tick().await;
let _ = approvals::sweep_expired(&runtime.inner.pool).await;
if let Ok(ready) = approvals::decided_unresumed(&runtime.inner.pool).await {
for item in ready {
let _ = runtime.resume_run(item).await;
}
}
}
});
}
async fn open_channel(&self, run_id: Uuid) -> broadcast::Receiver<RunEventEnvelope> {
let (sender, receiver) = broadcast::channel(1024);
self.inner.channels.lock().await.insert(run_id, sender);
receiver
}
fn spawn_drive(&self, run_id: Uuid, state: LoopState, emit_started: bool) {
let runtime = self.clone();
tokio::spawn(async move {
if emit_started {
let mut seq = state.event_seq;
if runtime
.emit(run_id, &mut seq, RunEventBody::RunStarted { run_id })
.await
.is_err()
{
runtime
.fail_run(run_id, "could not journal run start")
.await;
return;
}
let mut state = state;
state.event_seq = seq;
runtime.spawn_drive_inline(run_id, state).await;
} else {
runtime.spawn_drive_inline(run_id, state).await;
}
});
}
async fn spawn_drive_inline(&self, run_id: Uuid, state: LoopState) {
match self.drive(run_id, state).await {
Ok(Outcome::Completed) | Ok(Outcome::Suspended) => {}
Err(error) => self.fail_run(run_id, &error.to_string()).await,
}
self.inner.channels.lock().await.remove(&run_id);
}
async fn fail_run(&self, run_id: Uuid, message: &str) {
let _ = runs::set_state(
&self.inner.pool,
run_id,
tc_domain::RunState::Failed,
Some(message),
)
.await;
let mut seq = runs::get(&self.inner.pool, run_id)
.await
.map(|r| r.last_event_id)
.unwrap_or(0);
let _ = self
.emit(
run_id,
&mut seq,
RunEventBody::Error {
message: message.to_owned(),
},
)
.await;
self.inner.channels.lock().await.remove(&run_id);
}
/// Journals an event, then broadcasts it. Persist-before-emit is the /// Journals an event, then broadcasts it. Persist-before-emit is the
/// invariant that makes replay equal to what live observers saw. /// invariant that makes replay equal to what live observers saw.
async fn emit( async fn emit(
&self, &self,
run_id: Uuid, run_id: Uuid,
sender: &broadcast::Sender<RunEventEnvelope>,
seq: &mut i64, seq: &mut i64,
event: RunEventBody, event: RunEventBody,
) -> Result<(), RuntimeError> { ) -> Result<(), RuntimeError> {
@@ -143,153 +295,219 @@ impl Runtime {
let payload = serde_json::to_value(&event).expect("event serializes"); let payload = serde_json::to_value(&event).expect("event serializes");
run_events::append(&self.inner.pool, run_id, *seq, event.type_name(), payload).await?; run_events::append(&self.inner.pool, run_id, *seq, event.type_name(), payload).await?;
runs::set_last_event(&self.inner.pool, run_id, *seq).await?; runs::set_last_event(&self.inner.pool, run_id, *seq).await?;
let _ = sender.send(RunEventEnvelope { seq: *seq, event }); if let Some(sender) = self.inner.channels.lock().await.get(&run_id) {
let _ = sender.send(RunEventEnvelope { seq: *seq, event });
}
Ok(()) Ok(())
} }
async fn run_loop( /// Applies the human decision to the gated tool at the head of the
/// pending queue: approval consumes the single-use grant and executes;
/// rejection feeds a structured refusal back to the model.
async fn resolve_gated_tool(
&self, &self,
run_id: Uuid, ready: ResumeReady,
session_id: SessionId, state: &mut LoopState,
system_prompt: &str,
history: Vec<MessageWithSteps>,
user_text: &str,
sender: &broadcast::Sender<RunEventEnvelope>,
) -> Result<(), RuntimeError> { ) -> Result<(), RuntimeError> {
let mut seq: i64 = 0; let Some(tool) = state.pending_tools.first().cloned() else {
self.emit( return Err(RuntimeError::Checkpoint(
run_id, "resumed run has no pending tool".into(),
sender, ));
&mut seq, };
RunEventBody::RunStarted { run_id }, state.pending_tools.remove(0);
) let ctx = ToolContext {
.await?; pool: self.inner.pool.clone(),
workspace_id: state.workspace_id,
agent_id: state.agent_id,
};
messages::append( state.step_seq += 1;
&self.inner.pool, self.emit_step_started(ready.run_id, state, &tool).await?;
session_id,
MessageRole::User,
json!({"text": user_text}),
)
.await?;
sessions::touch(&self.inner.pool, session_id).await?;
// The reply row exists up front so steps can attach to it while the let (status, output) = if !ready.approved {
// run streams; its text is finalized at the end. (
let reply = messages::append( StepStatus::Error,
&self.inner.pool, json!({"rejected": "The human reviewer rejected this action."}),
session_id, )
MessageRole::Agent, } else {
json!({"text": ""}), match grants::consume(&self.inner.pool, ready.approval_id).await {
) // The grant is consumed BEFORE the action runs: even a
.await?; // racing resumer cannot execute a gated call twice.
Ok(()) => match self
.inner
.tools
.execute(&ctx, &tool.name, tool.input.clone())
.await
{
Ok(value) => (StepStatus::Ok, value),
Err(message) => (StepStatus::Error, json!({"error": message})),
},
Err(_) => (
StepStatus::Error,
json!({"error": "execution grant unavailable (already used or never issued)"}),
),
}
};
let mut request = ChatRequest { self.record_step(state, &tool, status, &output).await?;
system: system_prompt.to_owned(), self.emit_step_finished(ready.run_id, state, status, &output)
messages: chat_messages(&history, user_text), .await?;
tools: self.inner.tools.descriptors(), state.assistant_parts.push(ContentPart::ToolUse {
model: self.inner.config.model.clone(), id: tool.id.clone(),
max_tokens: self.inner.config.max_tokens, name: tool.name,
input: tool.input,
});
state.result_parts.push(ContentPart::ToolResult {
tool_use_id: tool.id,
content: output,
});
Ok(())
}
/// The run loop. Processes pending tool calls (suspending at the first
/// gated one), then streams the provider; repeats until the model ends
/// its turn.
async fn drive(&self, run_id: Uuid, mut state: LoopState) -> Result<Outcome, RuntimeError> {
let policy = GatePolicy;
let ctx = ToolContext {
pool: self.inner.pool.clone(),
workspace_id: state.workspace_id,
agent_id: state.agent_id,
}; };
let mut full_text = String::new();
let mut step_seq: i32 = 0;
loop { loop {
while let Some(tool) = state.pending_tools.first().cloned() {
let effects = self.inner.tools.effects_of(&tool.name);
// Taint plumbing arrives with untrusted sources (P3);
// inputs today originate from workspace humans only.
let taint = TaintSet::clean();
if let GateDecision::RequireApproval(category) = policy.classify(effects, &taint) {
let session = sessions::get(&self.inner.pool, state.session_id).await?;
let session_key = SessionKey {
agent_id: state.agent_id,
shard: shard_of(state.agent_id),
session_id: state.session_id,
message_id: state.reply_message_id,
};
let approval = approvals::create(
&self.inner.pool,
NewApproval {
workspace_id: session.workspace_id,
run_id,
session_key: session_key.to_string(),
action_type: tool.name.clone(),
category,
payload: tool.input.clone(),
preview: self.inner.tools.preview_of(&tool.name, &tool.input),
requested_by_agent: state.agent_id,
taint_sources: taint.as_strings(),
expires_at: Some(time::OffsetDateTime::now_utc() + APPROVAL_TTL),
},
)
.await?;
let mut seq = state.event_seq;
self.emit(
run_id,
&mut seq,
RunEventBody::ApprovalRequired {
approval_id: approval.id,
category: category.as_str().to_owned(),
action_type: approval.action_type.clone(),
preview: approval.preview.clone(),
},
)
.await?;
self.emit(
run_id,
&mut seq,
RunEventBody::RunSuspended {
approval_id: approval.id,
},
)
.await?;
state.event_seq = seq;
// Checkpoint AFTER journaling so resume continues from
// the right sequence number.
let snapshot = serde_json::to_value(&state)
.map_err(|e| RuntimeError::Checkpoint(e.to_string()))?;
checkpoint::suspend(&self.inner.pool, run_id, &snapshot).await?;
return Ok(Outcome::Suspended);
}
// Ungated: execute now.
state.pending_tools.remove(0);
state.step_seq += 1;
self.emit_step_started(run_id, &mut state, &tool).await?;
let (status, output) = match self
.inner
.tools
.execute(&ctx, &tool.name, tool.input.clone())
.await
{
Ok(value) => (StepStatus::Ok, value),
Err(message) => (StepStatus::Error, json!({"error": message})),
};
self.record_step(&state, &tool, status, &output).await?;
self.emit_step_finished(run_id, &mut state, status, &output)
.await?;
state.assistant_parts.push(ContentPart::ToolUse {
id: tool.id.clone(),
name: tool.name.clone(),
input: tool.input.clone(),
});
state.result_parts.push(ContentPart::ToolResult {
tool_use_id: tool.id.clone(),
content: output,
});
}
// Feed any finished tool batch back to the model.
if !state.assistant_parts.is_empty() {
state.request.messages.push(ChatMessage {
role: ChatRole::Assistant,
parts: std::mem::take(&mut state.assistant_parts),
});
state.request.messages.push(ChatMessage {
role: ChatRole::User,
parts: std::mem::take(&mut state.result_parts),
});
}
let mut stream = self let mut stream = self
.inner .inner
.provider .provider
.stream(request.clone()) .stream(state.request.clone())
.await .await
.map_err(|e| RuntimeError::Llm(e.to_string()))?; .map_err(|e| RuntimeError::Llm(e.to_string()))?;
let mut tool_uses: Vec<(String, String, Value)> = Vec::new();
let mut stop = StopReason::EndTurn; let mut stop = StopReason::EndTurn;
while let Some(event) = stream.next().await { while let Some(event) = stream.next().await {
match event.map_err(|e| RuntimeError::Llm(e.to_string()))? { match event.map_err(|e| RuntimeError::Llm(e.to_string()))? {
LlmEvent::TextDelta(delta) => { LlmEvent::TextDelta(delta) => {
full_text.push_str(&delta); state.full_text.push_str(&delta);
self.emit(run_id, sender, &mut seq, RunEventBody::TextDelta { delta }) let mut seq = state.event_seq;
self.emit(run_id, &mut seq, RunEventBody::TextDelta { delta })
.await?; .await?;
state.event_seq = seq;
} }
LlmEvent::ToolUse { id, name, input } => { LlmEvent::ToolUse { id, name, input } => {
tool_uses.push((id, name, input)); state.pending_tools.push(PendingTool { id, name, input });
} }
LlmEvent::Stop(reason) => stop = reason, LlmEvent::Stop(reason) => stop = reason,
} }
} }
if tool_uses.is_empty() || stop != StopReason::ToolUse { if state.pending_tools.is_empty() || stop != StopReason::ToolUse {
break; break;
} }
let mut assistant_parts = Vec::new();
let mut result_parts = Vec::new();
for (tool_use_id, name, input) in tool_uses {
step_seq += 1;
self.emit(
run_id,
sender,
&mut seq,
RunEventBody::StepStarted {
step_seq,
tool: name.clone(),
input: input.clone(),
},
)
.await?;
let outcome = self.inner.tools.execute(&name, input.clone()).await;
let (status, output) = match outcome {
Ok(value) => (StepStatus::Ok, value),
Err(message) => (StepStatus::Error, json!({"error": message})),
};
steps::append(
&self.inner.pool,
&Step {
id: Uuid::now_v7(),
message_id: reply.id,
seq: step_seq,
kind: "tool_call".into(),
tool_name: Some(name.clone()),
input: Some(input.clone()),
output: Some(output.clone()),
taint: vec![],
status,
},
)
.await?;
self.emit(
run_id,
sender,
&mut seq,
RunEventBody::StepFinished {
step_seq,
status: status.as_str().to_owned(),
output: output.clone(),
},
)
.await?;
assistant_parts.push(ContentPart::ToolUse {
id: tool_use_id.clone(),
name,
input,
});
result_parts.push(ContentPart::ToolResult {
tool_use_id,
content: output,
});
}
request.messages.push(ChatMessage {
role: ChatRole::Assistant,
parts: assistant_parts,
});
request.messages.push(ChatMessage {
role: ChatRole::User,
parts: result_parts,
});
} }
messages::set_content(&self.inner.pool, reply.id, json!({"text": full_text})).await?; messages::set_content(
&self.inner.pool,
state.reply_message_id,
json!({"text": state.full_text}),
)
.await?;
runs::set_state( runs::set_state(
&self.inner.pool, &self.inner.pool,
run_id, run_id,
@@ -297,12 +515,80 @@ impl Runtime {
None, None,
) )
.await?; .await?;
let mut seq = state.event_seq;
self.emit( self.emit(
run_id, run_id,
sender,
&mut seq, &mut seq,
RunEventBody::RunCompleted { RunEventBody::RunCompleted {
message_id: reply.id.to_string(), message_id: state.reply_message_id.to_string(),
},
)
.await?;
Ok(Outcome::Completed)
}
async fn emit_step_started(
&self,
run_id: Uuid,
state: &mut LoopState,
tool: &PendingTool,
) -> Result<(), RuntimeError> {
let mut seq = state.event_seq;
self.emit(
run_id,
&mut seq,
RunEventBody::StepStarted {
step_seq: state.step_seq,
tool: tool.name.clone(),
input: tool.input.clone(),
},
)
.await?;
state.event_seq = seq;
Ok(())
}
async fn emit_step_finished(
&self,
run_id: Uuid,
state: &mut LoopState,
status: StepStatus,
output: &Value,
) -> Result<(), RuntimeError> {
let mut seq = state.event_seq;
self.emit(
run_id,
&mut seq,
RunEventBody::StepFinished {
step_seq: state.step_seq,
status: status.as_str().to_owned(),
output: output.clone(),
},
)
.await?;
state.event_seq = seq;
Ok(())
}
async fn record_step(
&self,
state: &LoopState,
tool: &PendingTool,
status: StepStatus,
output: &Value,
) -> Result<(), RuntimeError> {
steps::append(
&self.inner.pool,
&Step {
id: Uuid::now_v7(),
message_id: state.reply_message_id,
seq: state.step_seq,
kind: "tool_call".into(),
tool_name: Some(tool.name.clone()),
input: Some(tool.input.clone()),
output: Some(output.clone()),
taint: vec![],
status,
}, },
) )
.await?; .await?;
@@ -326,22 +612,13 @@ fn chat_messages(history: &[MessageWithSteps], user_text: &str) -> Vec<ChatMessa
}; };
Some(ChatMessage { Some(ChatMessage {
role, role,
parts: vec![ContentPart::Text(text.to_owned())], parts: vec![ContentPart::text(text)],
}) })
}) })
.collect(); .collect();
out.push(ChatMessage { out.push(ChatMessage {
role: ChatRole::User, role: ChatRole::User,
parts: vec![ContentPart::Text(user_text.to_owned())], parts: vec![ContentPart::text(user_text)],
}); });
out out
} }
/// Sequence continuation for the failure path: errors are journaled after
/// whatever the loop already wrote.
async fn last_journaled_seq(pool: &PgPool, run_id: Uuid) -> i64 {
runs::get(pool, run_id)
.await
.map(|r| r.last_event_id)
.unwrap_or(0)
}
+113 -8
View File
@@ -1,20 +1,38 @@
//! Built-in ungated tools. P1 ships server-side tools with no external //! Built-in tools. Each tool declares its effects (spec §15); the gate
//! effects; sandboxed environment tools and the gate policy arrive in P2. //! policy decides from those declarations whether human approval is
//! required before execution.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use serde_json::{json, Value}; use serde_json::{json, Value};
use sqlx::PgPool;
use tc_domain::{AgentId, WorkspaceId};
use tc_llm::ToolDescriptor; use tc_llm::ToolDescriptor;
use tc_tools::Effect;
use uuid::Uuid;
/// Execution context handed to tools: who is acting, for which tenant.
#[derive(Clone)]
pub struct ToolContext {
pub pool: PgPool,
pub workspace_id: WorkspaceId,
pub agent_id: AgentId,
}
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait Tool: Send + Sync { pub trait Tool: Send + Sync {
fn descriptor(&self) -> ToolDescriptor; fn descriptor(&self) -> ToolDescriptor;
async fn execute(&self, input: Value) -> Result<Value, String>; /// Declared effects; the gate policy classifies from these.
fn effects(&self) -> &'static [Effect];
/// The exact human-facing preview for approval cards (§10).
fn preview(&self, input: &Value) -> Value {
input.clone()
}
async fn execute(&self, ctx: &ToolContext, input: Value) -> Result<Value, String>;
} }
/// Current UTC time — the simplest real tool; lets scenarios and the UI /// Current UTC time — effect-free, never gated.
/// exercise full step traces without a sandbox.
pub struct ClockNow; pub struct ClockNow;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -27,7 +45,11 @@ impl Tool for ClockNow {
} }
} }
async fn execute(&self, _input: Value) -> Result<Value, String> { fn effects(&self) -> &'static [Effect] {
&[]
}
async fn execute(&self, _ctx: &ToolContext, _input: Value) -> Result<Value, String> {
let now = time::OffsetDateTime::now_utc() let now = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339) .format(&time::format_description::well_known::Rfc3339)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -35,6 +57,69 @@ impl Tool for ClockNow {
} }
} }
/// Queues an outbound email — a §15 gated category (outbound message).
/// The real effect is an `outbox` row; the delivery transport drains the
/// queue in P4. This row must only ever exist after explicit approval.
pub struct EmailSend;
#[async_trait::async_trait]
impl Tool for EmailSend {
fn descriptor(&self) -> ToolDescriptor {
ToolDescriptor {
name: "email.send".into(),
description: "Sends an email outside the workspace. Requires \
human approval before it executes."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"to": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"},
},
"required": ["to", "subject", "body"],
}),
}
}
fn effects(&self) -> &'static [Effect] {
&[Effect::SendsExternally]
}
fn preview(&self, input: &Value) -> Value {
json!({
"summary": format!(
"Send email to {}",
input["to"].as_str().unwrap_or("(missing recipient)")
),
"to": input["to"],
"subject": input["subject"],
"body": input["body"],
})
}
async fn execute(&self, ctx: &ToolContext, input: Value) -> Result<Value, String> {
let to = input["to"].as_str().ok_or("missing 'to'")?;
let subject = input["subject"].as_str().ok_or("missing 'subject'")?;
let body = input["body"].as_str().ok_or("missing 'body'")?;
let id = Uuid::now_v7();
sqlx::query!(
"INSERT INTO outbox (id, workspace_id, agent_id, recipient, subject, body)
VALUES ($1, $2, $3, $4, $5, $6)",
id,
ctx.workspace_id.as_uuid(),
ctx.agent_id.as_uuid(),
to,
subject,
body,
)
.execute(&ctx.pool)
.await
.map_err(|e| e.to_string())?;
Ok(json!({ "queued": true, "outbox_id": id.to_string() }))
}
}
pub struct ToolRegistry { pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn Tool>>, tools: HashMap<String, Arc<dyn Tool>>,
} }
@@ -45,6 +130,7 @@ impl Default for ToolRegistry {
tools: HashMap::new(), tools: HashMap::new(),
}; };
registry.register(Arc::new(ClockNow)); registry.register(Arc::new(ClockNow));
registry.register(Arc::new(EmailSend));
registry registry
} }
} }
@@ -60,9 +146,28 @@ impl ToolRegistry {
all all
} }
pub async fn execute(&self, name: &str, input: Value) -> Result<Value, String> { /// Declared effects of a tool; unknown tools have none (they cannot
/// execute anything — `execute` fails for them).
pub fn effects_of(&self, name: &str) -> &'static [Effect] {
self.tools.get(name).map(|t| t.effects()).unwrap_or(&[])
}
/// The approval-card preview for a tool input.
pub fn preview_of(&self, name: &str, input: &Value) -> Value {
self.tools
.get(name)
.map(|t| t.preview(input))
.unwrap_or_else(|| input.clone())
}
pub async fn execute(
&self,
ctx: &ToolContext,
name: &str,
input: Value,
) -> Result<Value, String> {
match self.tools.get(name) { match self.tools.get(name) {
Some(tool) => tool.execute(input).await, Some(tool) => tool.execute(ctx, input).await,
None => Err(format!("unknown tool: {name}")), None => Err(format!("unknown tool: {name}")),
} }
} }
+342
View File
@@ -0,0 +1,342 @@
//! The acceptance-blocking §15 chain at the runtime level: a gated tool
//! call is intercepted, previewed, queued, blocks the run, executes only
//! on approval (single-use grant), and is fully audited.
use std::sync::Arc;
use std::time::Duration;
use tc_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, RunState, User, UserId, Workspace, WorkspaceId,
};
use tc_llm::ScriptedProvider;
use tc_runtime::{RunEventBody, Runtime, RuntimeConfig};
use tc_safety::{approvals, ApprovalStatus, Decision};
const SCENARIOS: &str = r#"
[[scenario]]
marker = "[[scenario:gated-email]]"
[[scenario.turns]]
events = [
{ type = "text", text = "I'll send that email." },
{ type = "tool_use", name = "email.send", input = { to = "[email protected]", subject = "Q2 numbers", body = "Revenue is up 14%." } },
]
[[scenario.turns]]
events = [
{ type = "text", text = " The email step is finished." },
]
"#;
struct Seed {
workspace: Workspace,
owner: User,
agent: Agent,
}
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();
Seed {
workspace,
owner,
agent,
}
}
fn runtime(pool: sqlx::PgPool) -> Runtime {
Runtime::new(
pool,
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig {
model: "scripted".into(),
max_tokens: 1024,
},
)
}
async fn drain_until_suspended(
mut rx: tokio::sync::broadcast::Receiver<tc_runtime::RunEventEnvelope>,
) -> Vec<tc_runtime::RunEventEnvelope> {
let mut events = Vec::new();
while let Ok(envelope) = rx.recv().await {
let done = matches!(
envelope.event,
RunEventBody::RunSuspended { .. }
| RunEventBody::RunCompleted { .. }
| RunEventBody::Error { .. }
);
events.push(envelope);
if done {
break;
}
}
events
}
async fn outbox_count(pool: &sqlx::PgPool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT count(*) FROM outbox")
.fetch_one(pool)
.await
.unwrap()
}
/// Waits until the run reaches a terminal state, reading the journal (the
/// resumed run streams on a new channel; the journal is the durable feed).
async fn wait_terminal(pool: &sqlx::PgPool, run_id: uuid::Uuid) -> RunState {
for _ in 0..100 {
let run = tc_db::repo::runs::get(pool, run_id).await.unwrap();
if run.state != RunState::Running && run.state != RunState::AwaitingApproval {
return run.state;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
panic!("run never reached a terminal state");
}
async fn start_gated_run(
pool: &sqlx::PgPool,
seed: &Seed,
rt: &Runtime,
) -> (uuid::Uuid, tc_safety::Approval) {
let session = tc_db::repo::sessions::create(pool, seed.agent.id, seed.workspace.id, "Chat")
.await
.unwrap();
let started = rt
.send_message(session.id, "send it [[scenario:gated-email]]")
.await
.unwrap();
let events = drain_until_suspended(started.events).await;
// Intercepted and previewed: the approval_required event carries the
// exact human-facing preview.
let preview = events
.iter()
.find_map(|e| match &e.event {
RunEventBody::ApprovalRequired { preview, .. } => Some(preview.clone()),
_ => None,
})
.expect("approval_required event");
assert_eq!(preview["summary"], "Send email to [email protected]");
assert_eq!(preview["body"], "Revenue is up 14%.");
assert!(matches!(
events.last().unwrap().event,
RunEventBody::RunSuspended { .. }
));
// Blocked: nothing executed, run suspended, approval queued.
assert_eq!(outbox_count(pool).await, 0);
let run = tc_db::repo::runs::get(pool, started.run_id).await.unwrap();
assert_eq!(
run.state,
RunState::AwaitingApproval,
"run error: {:?}",
run.error
);
let pending = approvals::list_pending(pool, seed.workspace.id)
.await
.unwrap();
assert_eq!(pending.len(), 1);
(started.run_id, pending.into_iter().next().unwrap())
}
#[tokio::test]
async fn gated_call_blocks_then_executes_only_after_approval() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let rt = runtime(pool.clone());
let (run_id, approval) = start_gated_run(&pool, &seed, &rt).await;
// Approve through the real decision path; the sweeper resumes the run.
rt.spawn_resume_sweeper(Duration::from_millis(50));
approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve)
.await
.unwrap();
assert_eq!(wait_terminal(&pool, run_id).await, RunState::Completed);
// Approve-only execution: exactly one outbox row, with the approved payload.
assert_eq!(outbox_count(&pool).await, 1);
let recipient = sqlx::query_scalar::<_, String>("SELECT recipient FROM outbox LIMIT 1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(recipient, "[email protected]");
// Audited: the decision is in the audit log.
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 journal contains the full chain for replay.
let journal = tc_db::repo::run_events::list_after(&pool, run_id, 0)
.await
.unwrap();
let kinds: Vec<&str> = journal.iter().map(|e| e.event_type.as_str()).collect();
assert!(kinds.contains(&"approval_required"));
assert!(kinds.contains(&"run_suspended"));
assert!(kinds.contains(&"step_finished"));
assert_eq!(*kinds.last().unwrap(), "run_completed");
// Sequence numbers are continuous across the suspension.
let seqs: Vec<i64> = journal.iter().map(|e| e.seq).collect();
assert_eq!(seqs, (1..=seqs.len() as i64).collect::<Vec<_>>());
// The step trace on the reply records the approved execution.
let session_id = tc_db::repo::runs::get(&pool, run_id)
.await
.unwrap()
.session_id;
let history = tc_db::repo::messages::history(&pool, session_id)
.await
.unwrap();
let reply = history.last().unwrap();
assert_eq!(reply.steps.len(), 1);
assert_eq!(reply.steps[0].status, tc_domain::StepStatus::Ok);
let text = reply.message.content["text"].as_str().unwrap();
assert!(text.contains("The email step is finished."), "got: {text}");
}
#[tokio::test]
async fn rejection_executes_nothing_and_informs_the_model() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let rt = runtime(pool.clone());
let (run_id, approval) = start_gated_run(&pool, &seed, &rt).await;
approvals::decide(&pool, approval.id, seed.owner.id, Decision::Reject)
.await
.unwrap();
rt.resume_run(tc_safety::ResumeReady {
run_id,
approval_id: approval.id,
approved: false,
})
.await
.unwrap();
assert_eq!(wait_terminal(&pool, run_id).await, RunState::Completed);
// Nothing executed, ever.
assert_eq!(outbox_count(&pool).await, 0);
// The model saw the structured rejection and continued in-band.
let session_id = tc_db::repo::runs::get(&pool, run_id)
.await
.unwrap()
.session_id;
let history = tc_db::repo::messages::history(&pool, session_id)
.await
.unwrap();
let reply = history.last().unwrap();
assert_eq!(reply.steps.len(), 1);
assert_eq!(reply.steps[0].status, tc_domain::StepStatus::Error);
assert!(reply.steps[0].output.as_ref().unwrap()["rejected"]
.as_str()
.unwrap()
.contains("rejected"));
}
#[tokio::test]
async fn a_spent_grant_cannot_execute_again() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let rt = runtime(pool.clone());
let (run_id, approval) = start_gated_run(&pool, &seed, &rt).await;
approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve)
.await
.unwrap();
// An attacker (or crashed half-resume) already consumed the grant.
tc_safety::grants::consume(&pool, approval.id)
.await
.unwrap();
rt.resume_run(tc_safety::ResumeReady {
run_id,
approval_id: approval.id,
approved: true,
})
.await
.unwrap();
assert_eq!(wait_terminal(&pool, run_id).await, RunState::Completed);
// The gated action did NOT run a second time — no outbox row at all,
// because the only consumption happened outside the executor.
assert_eq!(outbox_count(&pool).await, 0);
let approval_after = approvals::get(&pool, approval.id).await.unwrap();
assert_eq!(approval_after.status, ApprovalStatus::Approved);
}
#[tokio::test]
async fn ungated_tools_run_without_any_approval_rows() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let scenarios = r#"
[[scenario]]
marker = "[[scenario:clock]]"
[[scenario.turns]]
events = [ { type = "tool_use", name = "clock.now", input = {} } ]
[[scenario.turns]]
events = [ { type = "text", text = "Done." } ]
"#;
let rt = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml(scenarios).unwrap()),
RuntimeConfig {
model: "scripted".into(),
max_tokens: 1024,
},
);
let session = tc_db::repo::sessions::create(&pool, seed.agent.id, seed.workspace.id, "Chat")
.await
.unwrap();
let started = rt
.send_message(session.id, "time [[scenario:clock]]")
.await
.unwrap();
drain_until_suspended(started.events).await;
assert_eq!(
wait_terminal(&pool, started.run_id).await,
RunState::Completed
);
let pending = approvals::list_pending(&pool, seed.workspace.id)
.await
.unwrap();
assert!(pending.is_empty());
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "tc-safety"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
tc-db = { path = "../tc-db" }
tc-domain = { path = "../tc-domain" }
thiserror = { workspace = true }
time = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
tc-testkit = { path = "../tc-testkit" }
tokio = { workspace = true }
[lints]
workspace = true
+229
View File
@@ -0,0 +1,229 @@
use sqlx::PgPool;
use tc_domain::{AgentId, UserId, WorkspaceId};
use uuid::Uuid;
use crate::{Approval, ApprovalStatus, Decision, NewApproval, ResumeReady, SafetyError};
#[allow(clippy::too_many_arguments)]
fn map_row(
id: Uuid,
workspace_id: Uuid,
run_id: Uuid,
session_key: String,
action_type: String,
category: String,
payload: serde_json::Value,
preview: serde_json::Value,
requested_by_agent: Uuid,
taint_sources: Vec<String>,
status: String,
decided_by: Option<Uuid>,
created_at: time::OffsetDateTime,
) -> Approval {
Approval {
id,
workspace_id: WorkspaceId::from(workspace_id),
run_id,
session_key,
action_type,
category: category.parse().expect("category CHECK constraint"),
payload,
preview,
requested_by_agent: AgentId::from(requested_by_agent),
taint_sources,
status: status.parse().expect("status CHECK constraint"),
decided_by: decided_by.map(UserId::from),
created_at,
}
}
macro_rules! approval_from {
($row:expr) => {
map_row(
$row.id,
$row.workspace_id,
$row.run_id,
$row.session_key,
$row.action_type,
$row.category,
$row.payload,
$row.preview,
$row.requested_by_agent,
$row.taint_sources,
$row.status,
$row.decided_by,
$row.created_at,
)
};
}
pub async fn create(pool: &PgPool, new: NewApproval) -> Result<Approval, SafetyError> {
let id = Uuid::now_v7();
let row = sqlx::query!(
r#"INSERT INTO approvals
(id, workspace_id, run_id, session_key, action_type, category,
payload, preview, requested_by_agent, taint_sources, status,
expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', $11)
RETURNING id, workspace_id, run_id, session_key, action_type,
category, payload, preview, requested_by_agent,
taint_sources, status, decided_by, created_at"#,
id,
new.workspace_id.as_uuid(),
new.run_id,
new.session_key,
new.action_type,
new.category.as_str(),
new.payload,
new.preview,
new.requested_by_agent.as_uuid(),
&new.taint_sources,
new.expires_at,
)
.fetch_one(pool)
.await?;
Ok(approval_from!(row))
}
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Approval, SafetyError> {
let row = sqlx::query!(
r#"SELECT id, workspace_id, run_id, session_key, action_type,
category, payload, preview, requested_by_agent,
taint_sources, status, decided_by, created_at
FROM approvals WHERE id = $1"#,
id,
)
.fetch_optional(pool)
.await?
.ok_or(SafetyError::NotFound)?;
Ok(approval_from!(row))
}
/// The approval queue (§10), oldest first so reviews happen in order.
pub async fn list_pending(
pool: &PgPool,
workspace_id: WorkspaceId,
) -> Result<Vec<Approval>, SafetyError> {
let rows = sqlx::query!(
r#"SELECT id, workspace_id, run_id, session_key, action_type,
category, payload, preview, requested_by_agent,
taint_sources, status, decided_by, created_at
FROM approvals
WHERE workspace_id = $1 AND status = 'pending'
ORDER BY created_at"#,
workspace_id.as_uuid(),
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|row| approval_from!(row)).collect())
}
/// Idempotent decision: a compare-and-swap from `pending`, the audit row,
/// and (on approval) the single-use grant — one transaction. Losing the
/// race surfaces as `AlreadyDecided`, never a double execution.
pub async fn decide(
pool: &PgPool,
id: Uuid,
decided_by: UserId,
decision: Decision,
) -> Result<Approval, SafetyError> {
let status = match decision {
Decision::Approve => ApprovalStatus::Approved,
Decision::Reject => ApprovalStatus::Rejected,
};
let mut tx = pool.begin().await?;
let row = sqlx::query!(
r#"UPDATE approvals
SET status = $2, decided_by = $3, decided_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, workspace_id, run_id, session_key, action_type,
category, payload, preview, requested_by_agent,
taint_sources, status, decided_by, created_at"#,
id,
status.as_str(),
decided_by.as_uuid(),
)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
// Distinguish missing from already-decided for honest API errors.
let exists = sqlx::query_scalar!("SELECT 1 AS x FROM approvals WHERE id = $1", id)
.fetch_optional(pool)
.await?
.is_some();
return Err(if exists {
SafetyError::AlreadyDecided
} else {
SafetyError::NotFound
});
};
let approval = approval_from!(row);
sqlx::query!(
"INSERT INTO audit_log
(workspace_id, actor_kind, actor_id, event_type, subject_type,
subject_id, detail)
VALUES ($1, 'user', $2, $3, 'approval', $4, $5)",
approval.workspace_id.as_uuid(),
decided_by.as_uuid(),
match decision {
Decision::Approve => "approval.approved",
Decision::Reject => "approval.rejected",
},
approval.id.to_string(),
serde_json::json!({
"action_type": approval.action_type,
"category": approval.category,
}),
)
.execute(&mut *tx)
.await?;
if decision == Decision::Approve {
sqlx::query!(
"INSERT INTO execution_grants (id, approval_id, nonce)
VALUES ($1, $2, $3)",
Uuid::now_v7(),
approval.id,
Uuid::now_v7().to_string(),
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(approval)
}
/// Expires overdue pending approvals; their runs fail on resume sweep.
pub async fn sweep_expired(pool: &PgPool) -> Result<u64, SafetyError> {
let result = sqlx::query!(
"UPDATE approvals SET status = 'expired'
WHERE status = 'pending' AND expires_at IS NOT NULL AND expires_at < now()",
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
/// Decided approvals whose suspended runs still await resumption — the
/// durable work queue for the resume sweeper.
pub async fn decided_unresumed(pool: &PgPool) -> Result<Vec<ResumeReady>, SafetyError> {
let rows = sqlx::query!(
r#"SELECT a.run_id, a.id AS approval_id, a.status
FROM approvals a
JOIN agent_runs r ON r.id = a.run_id
WHERE a.status IN ('approved', 'rejected')
AND r.state = 'awaiting_approval'
ORDER BY a.decided_at"#,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| ResumeReady {
run_id: row.run_id,
approval_id: row.approval_id,
approved: row.status == "approved",
})
.collect())
}
+47
View File
@@ -0,0 +1,47 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::SafetyError;
/// Suspends a run: persists the full serialized loop state and flips the
/// run to `awaiting_approval` atomically.
pub async fn suspend(
pool: &PgPool,
run_id: Uuid,
state: &serde_json::Value,
) -> Result<(), SafetyError> {
let result = sqlx::query!(
"UPDATE agent_runs
SET checkpoint = $2, state = 'awaiting_approval', updated_at = now()
WHERE id = $1",
run_id,
state,
)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(SafetyError::NotFound);
}
Ok(())
}
pub async fn load(pool: &PgPool, run_id: Uuid) -> Result<serde_json::Value, SafetyError> {
let row = sqlx::query!("SELECT checkpoint FROM agent_runs WHERE id = $1", run_id)
.fetch_optional(pool)
.await?
.ok_or(SafetyError::NotFound)?;
row.checkpoint.ok_or(SafetyError::NotFound)
}
/// Claims a suspended run for resumption. The CAS from `awaiting_approval`
/// to `running` ensures exactly one resumer proceeds.
pub async fn claim_resume(pool: &PgPool, run_id: Uuid) -> Result<bool, SafetyError> {
let result = sqlx::query!(
"UPDATE agent_runs SET state = 'running', updated_at = now()
WHERE id = $1 AND state = 'awaiting_approval'",
run_id,
)
.execute(pool)
.await?;
Ok(result.rows_affected() == 1)
}
+23
View File
@@ -0,0 +1,23 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::SafetyError;
/// Consumes the single-use execution grant for an approval. The
/// compare-and-swap guarantees a gated action can execute at most once,
/// even against a compromised or racing caller (§15 defense in depth).
pub async fn consume(pool: &PgPool, approval_id: Uuid) -> Result<(), SafetyError> {
let row = sqlx::query!(
"UPDATE execution_grants
SET consumed = true, consumed_at = now()
WHERE approval_id = $1 AND consumed = false
RETURNING id",
approval_id,
)
.fetch_optional(pool)
.await?;
if row.is_none() {
return Err(SafetyError::GrantUnavailable);
}
Ok(())
}
+111
View File
@@ -0,0 +1,111 @@
//! The approval state machine (spec §15, acceptance-blocking).
//!
//! A gated tool call becomes a pending approval with the exact payload and
//! rendered preview. Decisions are idempotent compare-and-swaps audited in
//! the same transaction; approval mints a single-use execution grant the
//! executor must consume before the action runs. Suspended runs checkpoint
//! their full state and are claimed for resume exactly once.
pub mod approvals;
pub mod checkpoint;
pub mod grants;
use serde::{Deserialize, Serialize};
use tc_domain::{AgentId, GatedCategory, UserId, WorkspaceId};
use time::OffsetDateTime;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum SafetyError {
#[error("approval not found")]
NotFound,
#[error("approval already decided")]
AlreadyDecided,
#[error("no consumable grant for this approval")]
GrantUnavailable,
#[error(transparent)]
Db(#[from] sqlx::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalStatus {
Pending,
Approved,
Rejected,
Expired,
}
impl ApprovalStatus {
pub fn as_str(&self) -> &'static str {
match self {
ApprovalStatus::Pending => "pending",
ApprovalStatus::Approved => "approved",
ApprovalStatus::Rejected => "rejected",
ApprovalStatus::Expired => "expired",
}
}
}
impl std::str::FromStr for ApprovalStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"pending" => Ok(ApprovalStatus::Pending),
"approved" => Ok(ApprovalStatus::Approved),
"rejected" => Ok(ApprovalStatus::Rejected),
"expired" => Ok(ApprovalStatus::Expired),
other => Err(format!("unknown approval status: {other}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Approve,
Reject,
}
/// Input for a new pending approval.
#[derive(Debug, Clone)]
pub struct NewApproval {
pub workspace_id: WorkspaceId,
pub run_id: Uuid,
pub session_key: String,
pub action_type: String,
pub category: GatedCategory,
/// The exact tool input that will execute on approval.
pub payload: serde_json::Value,
/// The exact rendering shown to the human (§10 approval card).
pub preview: serde_json::Value,
pub requested_by_agent: AgentId,
pub taint_sources: Vec<String>,
pub expires_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Approval {
pub id: Uuid,
pub workspace_id: WorkspaceId,
pub run_id: Uuid,
pub session_key: String,
pub action_type: String,
pub category: GatedCategory,
pub payload: serde_json::Value,
pub preview: serde_json::Value,
pub requested_by_agent: AgentId,
pub taint_sources: Vec<String>,
pub status: ApprovalStatus,
pub decided_by: Option<UserId>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
/// A decided approval whose suspended run has not been resumed yet.
#[derive(Debug, Clone, Copy)]
pub struct ResumeReady {
pub run_id: Uuid,
pub approval_id: Uuid,
pub approved: bool,
}
+212
View File
@@ -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);
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "tc-tools"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
tc-domain = { path = "../tc-domain" }
[dev-dependencies]
proptest = { workspace = true }
[lints]
workspace = true
+178
View File
@@ -0,0 +1,178 @@
//! The gate policy (spec §15, acceptance-blocking).
//!
//! Tools declare *effects*; the policy maps effects to the six gated
//! categories. Classification is deny-by-default: any externally-visible
//! effect without a more specific category gates as an outbound message,
//! and input tainted by untrusted sources gates every external effect.
//! This module is pure — no I/O — so its invariants are property-tested.
use serde::{Deserialize, Serialize};
use tc_domain::GatedCategory;
/// What a tool can do, declared statically by the tool itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Effect {
/// Reads data inside the workspace boundary.
ReadsWorkspaceData,
/// Writes data inside the workspace boundary.
WritesWorkspaceData,
/// Contacts systems outside the workspace without sending user content
/// (e.g. fetching a public page).
ReachesExternally,
/// Sends messages/emails/posts outside the workspace.
SendsExternally,
/// Exposes keys, secrets, or credentials.
SharesSecrets,
/// Changes access, permissions, or sharing.
ChangesAccess,
/// Financial transactions and credit purchases.
MovesMoney,
/// Deletes files or records.
DeletesData,
/// Grants or extends external-infrastructure access.
GrantsInfraAccess,
}
impl Effect {
/// Effects visible outside the workspace boundary.
pub fn is_external(&self) -> bool {
!matches!(
self,
Effect::ReadsWorkspaceData | Effect::WritesWorkspaceData
)
}
/// The §15 category this effect always gates as, if any.
fn gated_category(&self) -> Option<GatedCategory> {
match self {
Effect::SendsExternally => Some(GatedCategory::OutboundMessage),
Effect::SharesSecrets => Some(GatedCategory::SecretSharing),
Effect::ChangesAccess => Some(GatedCategory::AccessChange),
Effect::MovesMoney => Some(GatedCategory::FinancialTransaction),
Effect::DeletesData => Some(GatedCategory::FileDeletion),
Effect::GrantsInfraAccess => Some(GatedCategory::InfraAccessGrant),
Effect::ReadsWorkspaceData
| Effect::WritesWorkspaceData
| Effect::ReachesExternally => None,
}
}
/// Severity order for picking the headline category of a multi-effect
/// tool (most consequential wins).
fn severity(&self) -> u8 {
match self {
Effect::ReadsWorkspaceData => 0,
Effect::WritesWorkspaceData => 1,
Effect::ReachesExternally => 2,
Effect::SendsExternally => 3,
Effect::DeletesData => 4,
Effect::ChangesAccess => 5,
Effect::SharesSecrets => 6,
Effect::GrantsInfraAccess => 7,
Effect::MovesMoney => 8,
}
}
}
/// Where a piece of content came from. Anything here marks the content as
/// untrusted-by-default (§15): data, never instructions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaintSource {
Web,
Email,
InterAgent,
ToolResult,
}
impl TaintSource {
pub fn as_str(&self) -> &'static str {
match self {
TaintSource::Web => "web",
TaintSource::Email => "email",
TaintSource::InterAgent => "inter_agent",
TaintSource::ToolResult => "tool_result",
}
}
}
/// The set of untrusted sources that influenced a tool input.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct TaintSet {
sources: Vec<TaintSource>,
}
impl TaintSet {
pub fn clean() -> TaintSet {
TaintSet::default()
}
pub fn from_sources(sources: &[TaintSource]) -> TaintSet {
let mut unique: Vec<TaintSource> = Vec::new();
for source in sources {
if !unique.contains(source) {
unique.push(*source);
}
}
TaintSet { sources: unique }
}
pub fn is_clean(&self) -> bool {
self.sources.is_empty()
}
pub fn sources(&self) -> &[TaintSource] {
&self.sources
}
/// Storage form for `steps.taint` / `approvals.taint_sources`.
pub fn as_strings(&self) -> Vec<String> {
self.sources.iter().map(|s| s.as_str().to_owned()).collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateDecision {
Allow,
RequireApproval(GatedCategory),
}
/// Pure classifier from declared effects + input taint to a decision.
#[derive(Debug, Default)]
pub struct GatePolicy;
impl GatePolicy {
pub fn classify(&self, effects: &[Effect], taint: &TaintSet) -> GateDecision {
let headline = effects.iter().max_by_key(|e| e.severity()).copied();
let Some(headline) = headline else {
return GateDecision::Allow;
};
if let Some(category) = effects
.iter()
.filter_map(Effect::gated_category)
.max_by_key(|c| {
effects
.iter()
.filter(|e| e.gated_category() == Some(*c))
.map(|e| e.severity())
.max()
.unwrap_or(0)
})
{
return GateDecision::RequireApproval(category);
}
// No always-gated effect. External reach gates by default — and
// unconditionally when the input carries untrusted taint (§15).
if headline.is_external() {
return GateDecision::RequireApproval(GatedCategory::OutboundMessage);
}
if !taint.is_clean() && effects.iter().any(Effect::is_external) {
return GateDecision::RequireApproval(GatedCategory::OutboundMessage);
}
GateDecision::Allow
}
}
+147
View File
@@ -0,0 +1,147 @@
use tc_domain::GatedCategory;
use tc_tools::{Effect, GateDecision, GatePolicy, TaintSet, TaintSource};
fn policy() -> GatePolicy {
GatePolicy
}
#[test]
fn effect_free_tools_are_allowed() {
let decision = policy().classify(&[], &TaintSet::clean());
assert_eq!(decision, GateDecision::Allow);
}
#[test]
fn read_only_effects_are_allowed() {
let decision = policy().classify(&[Effect::ReadsWorkspaceData], &TaintSet::clean());
assert_eq!(decision, GateDecision::Allow);
}
#[test]
fn every_spec_15_category_has_a_triggering_effect() {
// The six gated categories (§15) and the effect that triggers each.
let cases = [
(Effect::SendsExternally, GatedCategory::OutboundMessage),
(Effect::SharesSecrets, GatedCategory::SecretSharing),
(Effect::ChangesAccess, GatedCategory::AccessChange),
(Effect::MovesMoney, GatedCategory::FinancialTransaction),
(Effect::DeletesData, GatedCategory::FileDeletion),
(Effect::GrantsInfraAccess, GatedCategory::InfraAccessGrant),
];
for (effect, category) in cases {
let decision = policy().classify(&[effect], &TaintSet::clean());
assert_eq!(
decision,
GateDecision::RequireApproval(category),
"{effect:?} must gate as {category:?}"
);
}
}
#[test]
fn the_most_severe_effect_wins_for_multi_effect_tools() {
let decision = policy().classify(
&[Effect::ReadsWorkspaceData, Effect::MovesMoney],
&TaintSet::clean(),
);
assert_eq!(
decision,
GateDecision::RequireApproval(GatedCategory::FinancialTransaction)
);
}
#[test]
fn undeclared_external_reach_is_gated_by_default() {
// A tool that reaches outside the workspace but declares nothing
// specific is still gated (§15 untrusted-by-default).
let decision = policy().classify(&[Effect::ReachesExternally], &TaintSet::clean());
assert_eq!(
decision,
GateDecision::RequireApproval(GatedCategory::OutboundMessage)
);
}
#[test]
fn tainted_input_gates_even_benign_external_tools() {
let taint = TaintSet::from_sources(&[TaintSource::Web]);
let decision = policy().classify(&[Effect::ReachesExternally], &taint);
assert!(matches!(decision, GateDecision::RequireApproval(_)));
}
#[test]
fn tainted_input_does_not_gate_purely_internal_reads() {
// Untrusted content is data, not instructions; reading workspace data
// with tainted input has no external effect to protect.
let taint = TaintSet::from_sources(&[TaintSource::InterAgent]);
let decision = policy().classify(&[Effect::ReadsWorkspaceData], &taint);
assert_eq!(decision, GateDecision::Allow);
}
mod properties {
use super::*;
use proptest::prelude::*;
fn arb_effect() -> impl Strategy<Value = Effect> {
prop_oneof![
Just(Effect::ReadsWorkspaceData),
Just(Effect::WritesWorkspaceData),
Just(Effect::ReachesExternally),
Just(Effect::SendsExternally),
Just(Effect::SharesSecrets),
Just(Effect::ChangesAccess),
Just(Effect::MovesMoney),
Just(Effect::DeletesData),
Just(Effect::GrantsInfraAccess),
]
}
fn arb_taint() -> impl Strategy<Value = TaintSet> {
proptest::collection::vec(
prop_oneof![
Just(TaintSource::Web),
Just(TaintSource::Email),
Just(TaintSource::InterAgent),
Just(TaintSource::ToolResult),
],
0..4,
)
.prop_map(|sources| TaintSet::from_sources(&sources))
}
proptest! {
/// §15 invariant: input carrying untrusted taint combined with ANY
/// externally-visible effect is never auto-allowed.
#[test]
fn tainted_external_is_never_allowed(
effects in proptest::collection::vec(arb_effect(), 1..4),
taint in arb_taint(),
) {
let has_external = effects.iter().any(|e| e.is_external());
let decision = GatePolicy.classify(&effects, &taint);
if !taint.is_clean() && has_external {
prop_assert!(
matches!(decision, GateDecision::RequireApproval(_)),
"tainted external effects must require approval, got {decision:?}"
);
}
}
/// Gated effects require approval regardless of taint.
#[test]
fn gated_effects_always_require_approval(
taint in arb_taint(),
) {
for effect in [
Effect::SendsExternally,
Effect::SharesSecrets,
Effect::ChangesAccess,
Effect::MovesMoney,
Effect::DeletesData,
Effect::GrantsInfraAccess,
] {
let decision = GatePolicy.classify(&[effect], &taint);
prop_assert!(matches!(decision, GateDecision::RequireApproval(_)));
}
}
}
}
+14
View File
@@ -23,3 +23,17 @@ events = [
events = [ events = [
{ type = "text", text = "Done — I checked the current time for you." }, { type = "text", text = "Done — I checked the current time for you." },
] ]
[[scenario]]
marker = "[[scenario:gated-email]]"
[[scenario.turns]]
events = [
{ type = "text", text = "I'll send that email once you approve it." },
{ type = "tool_use", name = "email.send", input = { to = "[email protected]", subject = "Q2 numbers", body = "Revenue is up 14% quarter over quarter." } },
]
[[scenario.turns]]
events = [
{ type = "text", text = " The email step is finished." },
]
+3
View File
@@ -6,6 +6,9 @@ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
timeout: 30_000, timeout: 30_000,
fullyParallel: false, fullyParallel: false,
// One worker: journeys share one backend + database; parallel spec files
// would interleave sessions and approval queues across tests.
workers: 1,
retries: process.env.CI ? 1 : 0, retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? "github" : "list", reporter: process.env.CI ? "github" : "list",
use: { use: {
@@ -0,0 +1,20 @@
import { ApprovalQueue } from "@/components/safety/ApprovalQueue";
import { fetchPendingApprovals } from "@/lib/api/approvals";
// The approvals queue (§10): every gated action waiting on a human.
export default async function ApprovalsPage() {
const approvals = await fetchPendingApprovals();
return (
<section className="mx-auto max-w-3xl px-8 py-12">
<h1 className="text-2xl font-semibold tracking-tight">Approvals</h1>
<p className="pt-1 text-sm text-muted-foreground">
{approvals.length === 0
? "All clear — no actions awaiting review."
: `${approvals.length} ${
approvals.length === 1 ? "action awaits" : "actions await"
} your review. Nothing runs until you decide.`}
</p>
<ApprovalQueue approvals={approvals} />
</section>
);
}
@@ -33,7 +33,7 @@ export function ChatWorkspace({
sessions, sessions,
}: ChatWorkspaceProps) { }: ChatWorkspaceProps) {
const router = useRouter(); const router = useRouter();
const { state, send } = useChat(agent.id, sessionKey, initialMessages); const { state, send, decide } = useChat(agent.id, sessionKey, initialMessages);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [{ sessions: showSessions }] = useQueryStates(panelParsers, { const [{ sessions: showSessions }] = useQueryStates(panelParsers, {
shallow: true, shallow: true,
@@ -74,7 +74,7 @@ export function ChatWorkspace({
{state.messages.length === 0 ? ( {state.messages.length === 0 ? (
<WelcomeState agent={agent} onPick={setDraft} /> <WelcomeState agent={agent} onPick={setDraft} />
) : ( ) : (
<MessageList messages={state.messages} agent={agent} /> <MessageList messages={state.messages} agent={agent} onDecide={decide} />
)} )}
<Composer <Composer
agentName={agent.name} agentName={agent.name}
+27 -2
View File
@@ -5,8 +5,11 @@ import { useEffect, useRef } from "react";
import type { UiMessage } from "@/lib/gateway/transcript"; import type { UiMessage } from "@/lib/gateway/transcript";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { ApprovalCard } from "@/components/safety/ApprovalCard";
import { StepTrace } from "./steps/StepTrace"; import { StepTrace } from "./steps/StepTrace";
type DecideFn = (approvalId: string, decision: "approve" | "reject") => void;
function UserBubble({ message }: { message: UiMessage }) { function UserBubble({ message }: { message: UiMessage }) {
return ( return (
<div className="flex justify-end"> <div className="flex justify-end">
@@ -21,7 +24,15 @@ function UserBubble({ message }: { message: UiMessage }) {
); );
} }
function AgentMessage({ message, agent }: { message: UiMessage; agent: Agent }) { function AgentMessage({
message,
agent,
onDecide,
}: {
message: UiMessage;
agent: Agent;
onDecide: DecideFn;
}) {
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<Avatar name={agent.name} accent={agent.accent} size="sm" /> <Avatar name={agent.name} accent={agent.accent} size="sm" />
@@ -40,6 +51,13 @@ function AgentMessage({ message, agent }: { message: UiMessage; agent: Agent })
Something went wrong with this reply. Something went wrong with this reply.
</p> </p>
)} )}
{message.status === "suspended" && message.pendingApproval && (
<ApprovalCard
approval={message.pendingApproval}
agentName={agent.name}
onDecide={onDecide}
/>
)}
<StepTrace steps={message.steps} /> <StepTrace steps={message.steps} />
</div> </div>
</div> </div>
@@ -50,9 +68,11 @@ function AgentMessage({ message, agent }: { message: UiMessage; agent: Agent })
export function MessageList({ export function MessageList({
messages, messages,
agent, agent,
onDecide,
}: { }: {
messages: UiMessage[]; messages: UiMessage[];
agent: Agent; agent: Agent;
onDecide: DecideFn;
}) { }) {
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const tailText = messages.at(-1)?.text; const tailText = messages.at(-1)?.text;
@@ -70,7 +90,12 @@ export function MessageList({
message.role === "user" ? ( message.role === "user" ? (
<UserBubble key={message.id} message={message} /> <UserBubble key={message.id} message={message} />
) : ( ) : (
<AgentMessage key={message.id} message={message} agent={agent} /> <AgentMessage
key={message.id}
message={message}
agent={agent}
onDecide={onDecide}
/>
), ),
)} )}
<div ref={bottomRef} /> <div ref={bottomRef} />
@@ -0,0 +1,69 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ApprovalCard } from "./ApprovalCard";
const approval = {
id: "ap-1",
category: "outbound_message",
actionType: "email.send",
preview: {
summary: "Send email to [email protected]",
to: "[email protected]",
subject: "Q2 numbers",
body: "Revenue is up 14%.",
},
};
describe("ApprovalCard", () => {
it("shows the summary, category, and the exact payload preview", () => {
render(
<ApprovalCard approval={approval} agentName="Scout" onDecide={vi.fn()} />,
);
expect(
screen.getByRole("region", { name: "Review and approve" }),
).toBeInTheDocument();
// The headline names the action; the preview repeats the exact payload.
expect(screen.getByText(/wants to:/)).toHaveTextContent(
"Send email to [email protected]",
);
expect(screen.getByText(/Outbound message/)).toBeInTheDocument();
// The preview block carries the exact payload that will execute.
expect(screen.getByText(/Revenue is up 14%\./)).toBeInTheDocument();
});
it("approve and reject report the decision and disable the buttons", async () => {
const user = userEvent.setup();
const onDecide = vi.fn();
render(
<ApprovalCard approval={approval} agentName="Scout" onDecide={onDecide} />,
);
await user.click(screen.getByRole("button", { name: "Approve" }));
expect(onDecide).toHaveBeenCalledWith("ap-1", "approve");
expect(screen.getByRole("button", { name: "Approve" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Reject" })).toBeDisabled();
});
it("reject reports reject", async () => {
const user = userEvent.setup();
const onDecide = vi.fn();
render(
<ApprovalCard approval={approval} agentName="Scout" onDecide={onDecide} />,
);
await user.click(screen.getByRole("button", { name: "Reject" }));
expect(onDecide).toHaveBeenCalledWith("ap-1", "reject");
});
it("falls back to the action type when the preview has no summary", () => {
render(
<ApprovalCard
approval={{ ...approval, preview: { raw: true } }}
agentName="Scout"
onDecide={vi.fn()}
/>,
);
expect(screen.getByText(/wants to:/)).toHaveTextContent("email.send");
});
});
@@ -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>
);
}
@@ -0,0 +1,43 @@
"use client";
import { useRouter } from "next/navigation";
import type { Approval } from "@/lib/api/approvals";
import { ApprovalCard } from "./ApprovalCard";
/** The standalone review queue (§10). Decisions here resume the suspended
* runs server-side; open the session to watch the continuation. */
export function ApprovalQueue({ approvals }: { approvals: Approval[] }) {
const router = useRouter();
async function decide(approvalId: string, decision: "approve" | "reject") {
await fetch(`/api/approvals/${approvalId}/${decision}`, { method: "POST" });
router.refresh();
}
if (approvals.length === 0) {
return (
<p className="pt-8 text-sm text-muted-foreground">
Nothing waiting for review.
</p>
);
}
return (
<ul className="flex flex-col gap-4 pt-6">
{approvals.map((approval) => (
<li key={approval.id}>
<ApprovalCard
approval={{
id: approval.id,
category: approval.category,
actionType: approval.action_type,
preview: approval.preview,
}}
agentName="A claw"
onDecide={decide}
/>
</li>
))}
</ul>
);
}
@@ -7,6 +7,7 @@ import { GlobalNavItem } from "./GlobalNavItem";
/** Global nav entries that exist in the current phase. Skills and Apps join /** Global nav entries that exist in the current phase. Skills and Apps join
* the rail when their pages land (spec §17 P1/P5). */ * the rail when their pages land (spec §17 P1/P5). */
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/approvals", label: "Approvals" },
{ href: "/team", label: "Team" }, { href: "/team", label: "Team" },
{ href: "/credits", label: "Credits" }, { href: "/credits", label: "Credits" },
]; ];
+24
View File
@@ -0,0 +1,24 @@
import { z } from "zod";
import { apiFetch } from "./http";
export const ApprovalSchema = z.object({
id: z.string().uuid(),
workspace_id: z.string().uuid(),
run_id: z.string().uuid(),
session_key: z.string(),
action_type: z.string(),
category: z.string(),
payload: z.unknown(),
preview: z.unknown(),
requested_by_agent: z.string().uuid(),
taint_sources: z.array(z.string()),
status: z.enum(["pending", "approved", "rejected", "expired"]),
decided_by: z.string().uuid().nullable(),
created_at: z.string(),
});
export type Approval = z.infer<typeof ApprovalSchema>;
export function fetchPendingApprovals(): Promise<Approval[]> {
return apiFetch(z.array(ApprovalSchema), "/api/approvals");
}
+8
View File
@@ -18,6 +18,14 @@ export const GatewayEventSchema = z.discriminatedUnion("type", [
status: z.enum(["ok", "error"]), status: z.enum(["ok", "error"]),
output: z.unknown(), output: z.unknown(),
}), }),
z.object({
type: z.literal("approval_required"),
approval_id: z.string(),
category: z.string(),
action_type: z.string(),
preview: z.unknown(),
}),
z.object({ type: z.literal("run_suspended"), approval_id: z.string() }),
z.object({ type: z.literal("run_completed"), message_id: z.string() }), z.object({ type: z.literal("run_completed"), message_id: z.string() }),
z.object({ type: z.literal("error"), message: z.string() }), z.object({ type: z.literal("error"), message: z.string() }),
]); ]);
@@ -131,6 +131,65 @@ describe("transcriptReducer", () => {
expect(state.messages.at(-1)!.text).toBe("x"); expect(state.messages.at(-1)!.text).toBe("x");
}); });
it("approval flow: suspends with the card, resumes on decision", () => {
let state = transcriptReducer(initialTranscript(), {
kind: "send",
text: "send the email",
});
state = apply(
state,
{ seq: 1, event: { type: "run_started", run_id: "r1" } },
{ seq: 2, event: { type: "text_delta", delta: "I'll send it." } },
{
seq: 3,
event: {
type: "approval_required",
approval_id: "ap-1",
category: "outbound_message",
action_type: "email.send",
preview: { summary: "Send email to [email protected]" },
},
},
{ seq: 4, event: { type: "run_suspended", approval_id: "ap-1" } },
);
const reply = state.messages.at(-1)!;
expect(reply.status).toBe("suspended");
expect(reply.pendingApproval).toMatchObject({
id: "ap-1",
category: "outbound_message",
actionType: "email.send",
});
// Composer stays blocked while suspended.
expect(state.streaming).toBe(true);
// Human decides; the continuation streams and completes.
state = transcriptReducer(state, { kind: "decided" });
expect(state.messages.at(-1)!.status).toBe("streaming");
expect(state.messages.at(-1)!.pendingApproval).toBeUndefined();
state = apply(
state,
{
seq: 5,
event: { type: "step_started", step_seq: 1, tool: "email.send", input: {} },
},
{
seq: 6,
event: {
type: "step_finished",
step_seq: 1,
status: "ok",
output: { queued: true },
},
},
{ seq: 7, event: { type: "run_completed", message_id: "m9" } },
);
const done = state.messages.at(-1)!;
expect(done.status).toBe("complete");
expect(done.steps).toHaveLength(1);
expect(state.streaming).toBe(false);
});
it("stream errors mark the reply failed", () => { it("stream errors mark the reply failed", () => {
let state = transcriptReducer(initialTranscript(), { let state = transcriptReducer(initialTranscript(), {
kind: "send", kind: "send",
+36 -1
View File
@@ -12,12 +12,21 @@ export interface UiStep {
status: "running" | "ok" | "error"; status: "running" | "ok" | "error";
} }
/** A gated action awaiting review, rendered as the §10 approval card. */
export interface UiPendingApproval {
id: string;
category: string;
actionType: string;
preview: unknown;
}
export interface UiMessage { export interface UiMessage {
id: string; id: string;
role: "user" | "agent"; role: "user" | "agent";
text: string; text: string;
steps: UiStep[]; steps: UiStep[];
status: "sending" | "streaming" | "complete" | "error"; status: "sending" | "streaming" | "suspended" | "complete" | "error";
pendingApproval?: UiPendingApproval;
} }
export interface TranscriptState { export interface TranscriptState {
@@ -31,6 +40,8 @@ export type TranscriptAction =
| { kind: "history"; messages: UiMessage[] } | { kind: "history"; messages: UiMessage[] }
| { kind: "send"; text: string } | { kind: "send"; text: string }
| { kind: "gateway"; envelope: GatewayEnvelope } | { kind: "gateway"; envelope: GatewayEnvelope }
/** A human decided the pending approval; the continuation will stream. */
| { kind: "decided" }
| { kind: "transport_error"; message: string }; | { kind: "transport_error"; message: string };
export function initialTranscript(): TranscriptState { export function initialTranscript(): TranscriptState {
@@ -95,6 +106,13 @@ export function transcriptReducer(
case "transport_error": case "transport_error":
return failPending(state); return failPending(state);
case "decided":
return updateReply(state, (reply) => ({
...reply,
status: "streaming",
pendingApproval: undefined,
}));
case "gateway": { case "gateway": {
const { seq, event } = action.envelope; const { seq, event } = action.envelope;
if (seq <= state.lastSeq) { if (seq <= state.lastSeq) {
@@ -137,12 +155,29 @@ export function transcriptReducer(
: step, : step,
), ),
})); }));
case "approval_required":
return updateReply(next, (reply) => ({
...reply,
pendingApproval: {
id: event.approval_id,
category: event.category,
actionType: event.action_type,
preview: event.preview,
},
}));
case "run_suspended":
// Blocked until a human decides; the composer stays disabled.
return updateReply(next, (reply) => ({
...reply,
status: "suspended",
}));
case "run_completed": case "run_completed":
return { return {
...updateReply(next, (reply) => ({ ...updateReply(next, (reply) => ({
...reply, ...reply,
id: event.message_id, id: event.message_id,
status: "complete", status: "complete",
pendingApproval: undefined,
})), })),
streaming: false, streaming: false,
}; };
+46 -10
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
// Client chat engine: optimistic sends through the same-origin gateway // Client chat engine: optimistic sends through the same-origin gateway
// proxy, incremental SSE parsing, reducer-owned streaming state. // proxy, incremental SSE parsing, reducer-owned streaming state, and the
// approval decide-then-resume flow (§15).
import { useCallback, useReducer, useRef } from "react"; import { useCallback, useReducer, useRef } from "react";
@@ -10,6 +11,7 @@ import { createSseParser } from "./stream-parser";
import { import {
initialTranscript, initialTranscript,
transcriptReducer, transcriptReducer,
type TranscriptAction,
type TranscriptState, type TranscriptState,
type UiMessage, type UiMessage,
} from "./transcript"; } from "./transcript";
@@ -17,6 +19,8 @@ import {
export interface ChatHandle { export interface ChatHandle {
state: TranscriptState; state: TranscriptState;
send: (text: string) => Promise<void>; send: (text: string) => Promise<void>;
/** Decide a pending approval, then stream the run's continuation. */
decide: (approvalId: string, decision: "approve" | "reject") => Promise<void>;
} }
export function useChat( export function useChat(
@@ -33,26 +37,30 @@ export function useChat(
}), }),
); );
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
// The reducer owns lastSeq; mirror it for use inside async callbacks.
const lastSeqRef = useRef(0);
lastSeqRef.current = state.lastSeq;
const send = useCallback( const readStream = useCallback(
async (text: string) => { async (
body: Record<string, unknown>,
emit: (action: TranscriptAction) => void,
) => {
abortRef.current?.abort(); abortRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
dispatch({ kind: "send", text });
try { try {
const response = await fetch( const response = await fetch(
`/api/gateway?clawId=${encodeURIComponent(clawId)}`, `/api/gateway?clawId=${encodeURIComponent(clawId)}`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionKey, message: text }), body: JSON.stringify({ sessionKey, ...body }),
signal: controller.signal, signal: controller.signal,
}, },
); );
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
dispatch({ emit({
kind: "transport_error", kind: "transport_error",
message: `gateway returned ${response.status}`, message: `gateway returned ${response.status}`,
}); });
@@ -64,7 +72,7 @@ export function useChat(
const { done, value } = await reader.read(); const { done, value } = await reader.read();
const frames = done ? parser.flush() : parser.push(value); const frames = done ? parser.flush() : parser.push(value);
for (const frame of frames) { for (const frame of frames) {
dispatch({ emit({
kind: "gateway", kind: "gateway",
envelope: parseEnvelope(frame.id, frame.data), envelope: parseEnvelope(frame.id, frame.data),
}); });
@@ -75,12 +83,40 @@ export function useChat(
} }
} catch (error) { } catch (error) {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
dispatch({ kind: "transport_error", message: String(error) }); emit({ kind: "transport_error", message: String(error) });
} }
} }
}, },
[clawId, sessionKey], [clawId, sessionKey],
); );
return { state, send }; const send = useCallback(
async (text: string) => {
dispatch({ kind: "send", text });
await readStream({ message: text }, dispatch);
},
[readStream],
);
const decide = useCallback(
async (approvalId: string, decision: "approve" | "reject") => {
const response = await fetch(`/api/approvals/${approvalId}/${decision}`, {
method: "POST",
});
if (!response.ok) {
dispatch({
kind: "transport_error",
message: `decision returned ${response.status}`,
});
return;
}
dispatch({ kind: "decided" });
// Re-attach from where the suspended stream stopped; the journal
// replay plus live tail carries the continuation.
await readStream({ resumeFrom: lastSeqRef.current }, dispatch);
},
[readStream],
);
return { state, send, decide };
} }
+12 -4
View File
@@ -20,6 +20,14 @@ async function openScout(page: Page) {
await expect(page).toHaveURL(/\/claws\/.+\/chat\//); await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
} }
/** Clicks "New" and waits for the navigation: the previous session may show
* the same welcome heading, so the URL change is the reliable signal. */
async function newSession(page: Page) {
const before = page.url();
await page.getByRole("button", { name: "New" }).click();
await page.waitForURL((url) => url.toString() !== before);
}
async function sendMessage(page: Page, text: string) { async function sendMessage(page: Page, text: string) {
const box = page.getByLabel("Message Scout"); const box = page.getByLabel("Message Scout");
await box.fill(text); await box.fill(text);
@@ -29,7 +37,7 @@ async function sendMessage(page: Page, text: string) {
test("welcome state appears for a fresh session", async ({ page }) => { test("welcome state appears for a fresh session", async ({ page }) => {
await signIn(page); await signIn(page);
await openScout(page); await openScout(page);
await page.getByRole("button", { name: "New" }).click(); await newSession(page);
await expect( await expect(
page.getByRole("heading", { name: /Hi, I'm Scout/ }), page.getByRole("heading", { name: /Hi, I'm Scout/ }),
).toBeVisible(); ).toBeVisible();
@@ -51,7 +59,7 @@ test("a message streams back a scripted reply", async ({ page }) => {
test("tool runs render a step trace that survives reload", async ({ page }) => { test("tool runs render a step trace that survives reload", async ({ page }) => {
await signIn(page); await signIn(page);
await openScout(page); await openScout(page);
await page.getByRole("button", { name: "New" }).click(); await newSession(page);
await expect( await expect(
page.getByRole("heading", { name: /Hi, I'm Scout/ }), page.getByRole("heading", { name: /Hi, I'm Scout/ }),
).toBeVisible(); ).toBeVisible();
@@ -77,7 +85,7 @@ test("multiple sessions hold separate transcripts", async ({ page }) => {
await openScout(page); await openScout(page);
// First session. // First session.
await page.getByRole("button", { name: "New" }).click(); await newSession(page);
await expect( await expect(
page.getByRole("heading", { name: /Hi, I'm Scout/ }), page.getByRole("heading", { name: /Hi, I'm Scout/ }),
).toBeVisible(); ).toBeVisible();
@@ -85,7 +93,7 @@ test("multiple sessions hold separate transcripts", async ({ page }) => {
await expect(page.getByText("I received: first session message")).toBeVisible(); await expect(page.getByText("I received: first session message")).toBeVisible();
// Second session. // Second session.
await page.getByRole("button", { name: "New" }).click(); await newSession(page);
await expect( await expect(
page.getByRole("heading", { name: /Hi, I'm Scout/ }), page.getByRole("heading", { name: /Hi, I'm Scout/ }),
).toBeVisible(); ).toBeVisible();
+110
View File
@@ -0,0 +1,110 @@
import { expect, test, type Page } from "@playwright/test";
// P2 BLOCKING exit criterion (spec §15/§17): a sensitive call is
// intercepted → previewed → queued → blocks → executes ONLY on approval →
// is audited. Runs against the real backend with the scripted provider.
const OWNER_EMAIL = "[email protected]";
const OWNER_PASSWORD = "e2e-password";
const GATED_PROMPT = "email the CEO [[scenario:gated-email]]";
async function signIn(page: Page) {
await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "TeamClaw" })).toBeVisible();
}
async function openFreshScoutSession(page: Page) {
await page.getByRole("link", { name: /Scout/ }).click();
await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
// Wait for the navigation to the NEW session: the previous (possibly
// empty) session shows the same welcome heading, so the URL is the only
// reliable signal that the fresh session is mounted.
const before = page.url();
await page.getByRole("button", { name: "New" }).click();
await page.waitForURL((url) => url.toString() !== before);
await expect(
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
).toBeVisible();
}
async function triggerGatedEmail(page: Page) {
const box = page.getByLabel("Message Scout");
await box.fill(GATED_PROMPT);
await box.press("Enter");
// Intercepted + previewed: the approval card shows the EXACT payload.
const card = page.getByRole("region", { name: "Review and approve" });
await expect(card).toBeVisible();
await expect(card.getByText(/wants to:/)).toContainText(
"Send email to [email protected]",
);
await expect(card).toContainText("Revenue is up 14% quarter over quarter.");
return card;
}
test("gated email blocks, previews, and executes only after approval", async ({
page,
}) => {
await signIn(page);
await openFreshScoutSession(page);
const card = await triggerGatedEmail(page);
// Blocked: the composer is disabled while the run is suspended.
await expect(page.getByRole("button", { name: "Send" })).toBeDisabled();
await card.getByRole("button", { name: "Approve" }).click();
// The continuation streams the approved execution.
await expect(page.getByText(/The email step is finished/)).toBeVisible();
const trace = page.getByRole("button", { name: /1 step/ });
await trace.click();
await expect(page.getByText(/✓ email\.send/)).toBeVisible();
await expect(page.getByText(/"queued": ?true/)).toBeVisible();
// The transcript and trace replay identically after reload (journal).
await page.reload();
await expect(page.getByText(/The email step is finished/)).toBeVisible();
await page.getByRole("button", { name: /1 step/ }).click();
await expect(page.getByText(/✓ email\.send/)).toBeVisible();
});
test("rejection executes nothing and the agent continues in-band", async ({
page,
}) => {
await signIn(page);
await openFreshScoutSession(page);
const card = await triggerGatedEmail(page);
await card.getByRole("button", { name: "Reject" }).click();
// The run completes; the step trace records the refusal, not an execution.
await expect(page.getByText(/The email step is finished/)).toBeVisible();
await page.getByRole("button", { name: /1 step/ }).click();
await expect(page.getByText(/✗ email\.send/)).toBeVisible();
await expect(page.getByText(/rejected/)).toBeVisible();
});
test("the approvals queue lists pending actions and decides them", async ({
page,
}) => {
await signIn(page);
await openFreshScoutSession(page);
await triggerGatedEmail(page);
// Queued: the global approvals page shows the same exact preview.
// Earlier sessions may have left pending approvals; decide them all.
await page.getByRole("link", { name: "Approvals" }).click();
await expect(page.getByText(/awaits? your review/)).toBeVisible();
const cards = page.getByRole("region", { name: "Review and approve" });
await expect(cards.first()).toContainText("Send email to [email protected]");
const total = await cards.count();
for (let remaining = total; remaining > 0; remaining--) {
await cards.first().getByRole("button", { name: "Approve" }).click();
// Wait for the refresh to apply before touching the next card.
await expect(cards).toHaveCount(remaining - 1);
}
await expect(page.getByText(/All clear/)).toBeVisible();
});
+15
View File
@@ -0,0 +1,15 @@
-- Outbound message queue: the real effect of the gated email.send tool.
-- Rows land here ONLY after human approval (§15); the delivery transport
-- (SMTP / Slack / etc.) drains the queue in P4.
CREATE TABLE outbox (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id),
agent_id UUID NOT NULL REFERENCES agents (id),
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'sent', 'failed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX outbox_workspace_idx ON outbox (workspace_id, created_at DESC);