P2 complete: Docker sandbox with kernel assertions + secret broker

tc-sandbox:
- SandboxSpec/SandboxDriver + DockerDriver (bollard): uid 10001, cap-drop
  ALL, no-new-privileges, embedded seccomp deny profile (unshare/ptrace/
  bpf/keyctl/mount/...), read-only rootfs with tmpfs /tmp + /home/agent,
  network=none, mem/cpu/pids limits
- agent-base image: non-root, all setuid binaries stripped
- 6 kernel-level assertion tests probing from INSIDE real containers:
  uid + CapEff==0, rootfs read-only, seccomp EPERM on unshare, zero
  traffic-carrying interfaces + failed egress connect, no setuid +
  NoNewPrivs=1, lifecycle

tc-secrets:
- ChaCha20-Poly1305 envelope encryption under a FileKey (generated 0600,
  AEAD tamper detection tested); secrets table ciphertext-at-rest
- teamclaw-broker daemon: length-prefixed JSON over a unix socket; no
  protocol operation ever returns plaintext; InvokeHttp independently
  consumes the single-use execution grant against Postgres BEFORE touching
  any credential, then performs the call itself with the secret injected
- Tests over the real socket + real Postgres + a real local HTTP receiver:
  encrypted at rest, pending approval refused, approved call carries the
  bearer token exactly once, grant replay refused, non-http URLs rejected

116 Rust + 61 frontend tests + 14 E2E journeys green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 05:07:46 -05:00
co-authored by Claude Fable 5
parent de38449b41
commit ea5162ac65
19 changed files with 1473 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
//! Broker tests against the real wire: a real unix socket, real Postgres,
//! real crypto, and a real local HTTP receiver standing in for the external
//! service (its base URL is configuration — the same mechanism air-gapped
//! installs use).
use std::sync::Arc;
use axum::extract::State;
use axum::routing::post;
use serde_json::json;
use tc_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, GatedCategory, Role, User, UserId, Workspace,
WorkspaceId,
};
use tc_safety::{approvals, Decision, NewApproval};
use tc_secrets::{BrokerClient, BrokerError, BrokerServer, FileKey};
use tokio::sync::Mutex;
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,
}
}
/// A real HTTP receiver that records the Authorization headers it sees.
async fn spawn_receiver() -> (String, Arc<Mutex<Vec<String>>>) {
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let state = seen.clone();
let app =
axum::Router::new()
.route(
"/hook",
post(
|State(seen): State<Arc<Mutex<Vec<String>>>>,
headers: axum::http::HeaderMap| async move {
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned();
seen.lock().await.push(auth);
axum::Json(json!({"received": true}))
},
),
)
.with_state(state);
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();
});
(format!("http://{addr}"), seen)
}
async fn broker_pair(pool: sqlx::PgPool, dir: &std::path::Path) -> BrokerClient {
let key_path = dir.join("broker.key");
FileKey::generate(&key_path).unwrap();
let key = FileKey::load(&key_path).unwrap();
// Unix socket paths are limited to ~104 bytes on macOS; keep it short.
// Use the RANDOM tail of the uuid — the v7 prefix is a timestamp and
// collides across tests started in the same millisecond.
let short = uuid::Uuid::now_v7().simple().to_string();
let socket = std::path::PathBuf::from(format!("/tmp/tcb-{}.sock", &short[short.len() - 12..]));
let server = BrokerServer::new(pool, key, socket.clone());
tokio::spawn(async move {
server.serve().await.unwrap();
});
// The socket appears asynchronously.
for _ in 0..50 {
if socket.exists() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
BrokerClient::connect(&socket).await.unwrap()
}
async fn approved_approval(pool: &sqlx::PgPool, seed: &Seed) -> uuid::Uuid {
let approval = approvals::create(
pool,
NewApproval {
workspace_id: seed.workspace.id,
run_id: seed.run_id,
session_key: "agent:x-claw-0:session:y:z".into(),
action_type: "http.request".into(),
category: GatedCategory::OutboundMessage,
payload: json!({}),
preview: json!({}),
requested_by_agent: seed.agent.id,
taint_sources: vec![],
expires_at: None,
},
)
.await
.unwrap();
approvals::decide(pool, approval.id, seed.owner.id, Decision::Approve)
.await
.unwrap();
approval.id
}
#[tokio::test]
async fn secrets_are_encrypted_at_rest_and_never_returned() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let dir = std::env::temp_dir().join(format!("tc-broker-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let mut client = broker_pair(pool.clone(), &dir).await;
let secret_id = client
.store_secret(seed.workspace.id, "api_key", "sk-super-secret-token")
.await
.unwrap();
// At rest: ciphertext only, never the plaintext bytes.
let ciphertext: Vec<u8> = sqlx::query_scalar("SELECT ciphertext FROM secrets WHERE id = $1")
.bind(secret_id)
.fetch_one(&pool)
.await
.unwrap();
let raw = String::from_utf8_lossy(&ciphertext);
assert!(!raw.contains("sk-super-secret-token"));
// No protocol operation returns plaintext — metadata only.
let kind = client.secret_kind(secret_id).await.unwrap();
assert_eq!(kind, "api_key");
}
#[tokio::test]
async fn capability_requires_an_unconsumed_grant() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let dir = std::env::temp_dir().join(format!("tc-broker-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let mut client = broker_pair(pool.clone(), &dir).await;
let (receiver_url, seen) = spawn_receiver().await;
let secret_id = client
.store_secret(seed.workspace.id, "api_key", "sk-live-12345")
.await
.unwrap();
// Pending approval (no grant yet): the broker refuses outright.
let pending = approvals::create(
&pool,
NewApproval {
workspace_id: seed.workspace.id,
run_id: seed.run_id,
session_key: "k".into(),
action_type: "http.request".into(),
category: GatedCategory::OutboundMessage,
payload: json!({}),
preview: json!({}),
requested_by_agent: seed.agent.id,
taint_sources: vec![],
expires_at: None,
},
)
.await
.unwrap();
let refused = client
.invoke_http(pending.id, secret_id, &format!("{receiver_url}/hook"))
.await;
assert!(matches!(refused, Err(BrokerError::GrantRefused)));
assert!(seen.lock().await.is_empty(), "nothing may execute");
// Approved: the call executes WITH the credential injected; the
// credential itself never crosses back over the socket.
let approval_id = approved_approval(&pool, &seed).await;
let status = client
.invoke_http(approval_id, secret_id, &format!("{receiver_url}/hook"))
.await
.unwrap();
assert_eq!(status, 200);
let observed = seen.lock().await;
assert_eq!(observed.as_slice(), ["Bearer sk-live-12345"]);
drop(observed);
// The grant is single-use: replay refused, no second call.
let replay = client
.invoke_http(approval_id, secret_id, &format!("{receiver_url}/hook"))
.await;
assert!(matches!(replay, Err(BrokerError::GrantRefused)));
assert_eq!(seen.lock().await.len(), 1);
}
#[tokio::test]
async fn non_http_urls_are_rejected() {
let pool = tc_testkit::test_pool().await;
let seed = seeded(&pool).await;
let dir = std::env::temp_dir().join(format!("tc-broker-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let mut client = broker_pair(pool.clone(), &dir).await;
let secret_id = client
.store_secret(seed.workspace.id, "api_key", "sk-x")
.await
.unwrap();
let approval_id = approved_approval(&pool, &seed).await;
let refused = client
.invoke_http(approval_id, secret_id, "file:///etc/passwd")
.await;
assert!(matches!(refused, Err(BrokerError::Invalid(_))));
}
#[test]
fn key_files_round_trip_and_reject_corruption() {
let dir = std::env::temp_dir().join(format!("tc-key-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("broker.key");
FileKey::generate(&path).unwrap();
let key = FileKey::load(&path).unwrap();
let sealed = key.seal(b"attack at dawn").unwrap();
assert_ne!(sealed.ciphertext, b"attack at dawn");
let opened = key.open(&sealed).unwrap();
assert_eq!(opened, b"attack at dawn");
// Bit-flip is detected (AEAD), not silently decrypted.
let mut tampered = sealed;
tampered.ciphertext[0] ^= 0xff;
assert!(key.open(&tampered).is_err());
}