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:
co-authored by
Claude Fable 5
parent
de38449b41
commit
ea5162ac65
Generated
+125
@@ -2,6 +2,16 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aead"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -374,6 +384,30 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20poly1305"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"chacha20",
|
||||||
|
"cipher",
|
||||||
|
"poly1305",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -386,6 +420,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -463,6 +508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1262,6 +1308,15 @@ version = "0.1.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
|
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -1533,6 +1588,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -1701,6 +1762,17 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "poly1305"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||||
|
dependencies = [
|
||||||
|
"cpufeatures",
|
||||||
|
"opaque-debug",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -2890,6 +2962,40 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tc-sandbox"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bollard",
|
||||||
|
"futures",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tc-secrets"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"chacha20poly1305",
|
||||||
|
"hex",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"tc-db",
|
||||||
|
"tc-domain",
|
||||||
|
"tc-safety",
|
||||||
|
"tc-testkit",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tc-testkit"
|
name = "tc-testkit"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2911,6 +3017,15 @@ dependencies = [
|
|||||||
"tc-domain",
|
"tc-domain",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "teamclaw-broker"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"tc-db",
|
||||||
|
"tc-secrets",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "teamclaw-server"
|
name = "teamclaw-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3347,6 +3462,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "universal-hash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ members = [
|
|||||||
"crates/tc-runtime",
|
"crates/tc-runtime",
|
||||||
"crates/tc-tools",
|
"crates/tc-tools",
|
||||||
"crates/tc-safety",
|
"crates/tc-safety",
|
||||||
|
"crates/tc-sandbox",
|
||||||
|
"crates/tc-secrets",
|
||||||
"crates/tc-testkit",
|
"crates/tc-testkit",
|
||||||
"crates/tc-auth",
|
"crates/tc-auth",
|
||||||
"crates/tc-api",
|
"crates/tc-api",
|
||||||
"crates/bins/teamclaw-server",
|
"crates/bins/teamclaw-server",
|
||||||
|
"crates/bins/teamclaw-broker",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "teamclaw-broker"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tc-db = { path = "../../tc-db" }
|
||||||
|
tc-secrets = { path = "../../tc-secrets" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! The secret broker daemon (spec §15). Runs as its own process; only the
|
||||||
|
//! server process can reach its socket — agent sandboxes have no route to
|
||||||
|
//! it by construction.
|
||||||
|
//!
|
||||||
|
//! Configuration (environment):
|
||||||
|
//! - `TEAMCLAW_DATABASE__URL` Postgres connection string (required)
|
||||||
|
//! - `TEAMCLAW_BROKER_SOCKET` unix socket path (default /tmp/teamclaw-broker.sock)
|
||||||
|
//! - `TEAMCLAW_BROKER_KEY_FILE` master key file; generated on first boot
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use tc_secrets::{BrokerServer, FileKey};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> ExitCode {
|
||||||
|
match run().await {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(message) => {
|
||||||
|
eprintln!("teamclaw-broker: {message}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run() -> Result<(), String> {
|
||||||
|
let database_url = std::env::var("TEAMCLAW_DATABASE__URL")
|
||||||
|
.map_err(|_| "TEAMCLAW_DATABASE__URL is required")?;
|
||||||
|
let socket_path = PathBuf::from(
|
||||||
|
std::env::var("TEAMCLAW_BROKER_SOCKET")
|
||||||
|
.unwrap_or_else(|_| "/tmp/teamclaw-broker.sock".into()),
|
||||||
|
);
|
||||||
|
let key_path = PathBuf::from(
|
||||||
|
std::env::var("TEAMCLAW_BROKER_KEY_FILE")
|
||||||
|
.unwrap_or_else(|_| "/etc/teamclaw/broker.key".into()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if !key_path.exists() {
|
||||||
|
FileKey::generate(&key_path).map_err(|e| format!("key generation failed: {e}"))?;
|
||||||
|
println!(
|
||||||
|
"teamclaw-broker: generated master key at {} — BACK IT UP; \
|
||||||
|
secrets are unrecoverable without it",
|
||||||
|
key_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let key = FileKey::load(&key_path).map_err(|e| format!("key load failed: {e}"))?;
|
||||||
|
|
||||||
|
let pool = tc_db::connect(&database_url, 5)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("database connection failed: {e}"))?;
|
||||||
|
|
||||||
|
println!("teamclaw-broker listening on {}", socket_path.display());
|
||||||
|
BrokerServer::new(pool, key, socket_path)
|
||||||
|
.serve()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("broker failed: {e}"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "tc-sandbox"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = "0.1"
|
||||||
|
bollard = "0.19"
|
||||||
|
futures = "0.3"
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//! Docker implementation of the sandbox driver (bollard). Serves local
|
||||||
|
//! development and the air-gapped compose target; podman works through the
|
||||||
|
//! same API via `DOCKER_HOST`.
|
||||||
|
|
||||||
|
use bollard::exec::{CreateExecOptions, StartExecResults};
|
||||||
|
use bollard::models::{ContainerCreateBody, HostConfig};
|
||||||
|
use bollard::query_parameters::{
|
||||||
|
CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions,
|
||||||
|
};
|
||||||
|
use bollard::Docker;
|
||||||
|
use futures::StreamExt;
|
||||||
|
|
||||||
|
use crate::spec::{ExecResult, SandboxHandle, SandboxSpec};
|
||||||
|
use crate::{SandboxDriver, SandboxError};
|
||||||
|
|
||||||
|
/// The seccomp deny profile, embedded so the Docker path needs no file
|
||||||
|
/// distribution (single source of truth: images/seccomp/agent-profile.json).
|
||||||
|
const SECCOMP_PROFILE: &str = include_str!("../../../images/seccomp/agent-profile.json");
|
||||||
|
|
||||||
|
pub struct DockerDriver {
|
||||||
|
docker: Docker,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DockerDriver {
|
||||||
|
pub fn connect() -> Result<DockerDriver, SandboxError> {
|
||||||
|
let docker = Docker::connect_with_local_defaults()
|
||||||
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
||||||
|
Ok(DockerDriver { docker })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a container by name if it exists (test/restart hygiene).
|
||||||
|
pub async fn destroy_by_name(&self, name: &str) -> Result<(), SandboxError> {
|
||||||
|
self.docker
|
||||||
|
.remove_container(
|
||||||
|
name,
|
||||||
|
Some(RemoveContainerOptions {
|
||||||
|
force: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SandboxError::Engine(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl SandboxDriver for DockerDriver {
|
||||||
|
async fn provision(&self, spec: &SandboxSpec) -> Result<SandboxHandle, SandboxError> {
|
||||||
|
// The §15 controls, enforced unconditionally:
|
||||||
|
let host_config = HostConfig {
|
||||||
|
cap_drop: Some(vec!["ALL".into()]),
|
||||||
|
security_opt: Some(vec![
|
||||||
|
"no-new-privileges:true".into(),
|
||||||
|
format!("seccomp={SECCOMP_PROFILE}"),
|
||||||
|
]),
|
||||||
|
readonly_rootfs: Some(true),
|
||||||
|
tmpfs: Some(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"/tmp".to_owned(),
|
||||||
|
"rw,noexec,nosuid,size=67108864".to_owned(),
|
||||||
|
),
|
||||||
|
// Writable workspace; everything else is read-only.
|
||||||
|
(
|
||||||
|
"/home/agent".to_owned(),
|
||||||
|
"rw,nosuid,size=268435456,uid=10001,gid=10001".to_owned(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
network_mode: Some("none".into()),
|
||||||
|
memory: Some(spec.memory_bytes),
|
||||||
|
nano_cpus: Some(spec.nano_cpus),
|
||||||
|
pids_limit: Some(spec.pids_limit),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let body = ContainerCreateBody {
|
||||||
|
image: Some(spec.image.clone()),
|
||||||
|
user: Some("10001:10001".into()),
|
||||||
|
cmd: Some(vec!["sleep".into(), "infinity".into()]),
|
||||||
|
host_config: Some(host_config),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = self
|
||||||
|
.docker
|
||||||
|
.create_container(
|
||||||
|
Some(CreateContainerOptions {
|
||||||
|
name: Some(spec.name.clone()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
||||||
|
self.docker
|
||||||
|
.start_container(&created.id, None::<StartContainerOptions>)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
||||||
|
Ok(SandboxHandle {
|
||||||
|
id: created.id,
|
||||||
|
name: spec.name.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec(&self, handle: &SandboxHandle, cmd: &[&str]) -> Result<ExecResult, SandboxError> {
|
||||||
|
let exec = self
|
||||||
|
.docker
|
||||||
|
.create_exec(
|
||||||
|
&handle.id,
|
||||||
|
CreateExecOptions {
|
||||||
|
cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
|
||||||
|
attach_stdout: Some(true),
|
||||||
|
attach_stderr: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut stdout = String::new();
|
||||||
|
let mut stderr = String::new();
|
||||||
|
match self
|
||||||
|
.docker
|
||||||
|
.start_exec(&exec.id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SandboxError::Engine(e.to_string()))?
|
||||||
|
{
|
||||||
|
StartExecResults::Attached { mut output, .. } => {
|
||||||
|
while let Some(chunk) = output.next().await {
|
||||||
|
match chunk.map_err(|e| SandboxError::Engine(e.to_string()))? {
|
||||||
|
bollard::container::LogOutput::StdOut { message } => {
|
||||||
|
stdout.push_str(&String::from_utf8_lossy(&message));
|
||||||
|
}
|
||||||
|
bollard::container::LogOutput::StdErr { message } => {
|
||||||
|
stderr.push_str(&String::from_utf8_lossy(&message));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StartExecResults::Detached => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let inspect = self
|
||||||
|
.docker
|
||||||
|
.inspect_exec(&exec.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
||||||
|
Ok(ExecResult {
|
||||||
|
exit_code: inspect.exit_code.unwrap_or(-1),
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError> {
|
||||||
|
self.destroy_by_name(&handle.id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health(&self, handle: &SandboxHandle) -> Result<bool, SandboxError> {
|
||||||
|
match self
|
||||||
|
.docker
|
||||||
|
.inspect_container(&handle.id, None::<InspectContainerOptions>)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(info) => Ok(info.state.and_then(|s| s.running).unwrap_or(false)),
|
||||||
|
Err(bollard::errors::Error::DockerResponseServerError {
|
||||||
|
status_code: 404, ..
|
||||||
|
}) => Ok(false),
|
||||||
|
Err(e) => Err(SandboxError::Engine(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//! Per-agent sandbox orchestration (spec §15): containers with no root, no
|
||||||
|
//! capabilities, a seccomp deny profile, read-only rootfs, and no network.
|
||||||
|
//! One `SandboxDriver` trait; the Docker implementation serves dev and the
|
||||||
|
//! air-gapped compose target (the Kubernetes driver lands in P3).
|
||||||
|
|
||||||
|
mod docker;
|
||||||
|
mod spec;
|
||||||
|
|
||||||
|
pub use docker::DockerDriver;
|
||||||
|
pub use spec::{ExecResult, SandboxHandle, SandboxSpec};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SandboxError {
|
||||||
|
#[error("container engine error: {0}")]
|
||||||
|
Engine(String),
|
||||||
|
#[error("sandbox not found")]
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait SandboxDriver: Send + Sync {
|
||||||
|
/// Creates and starts a hardened sandbox container.
|
||||||
|
async fn provision(&self, spec: &SandboxSpec) -> Result<SandboxHandle, SandboxError>;
|
||||||
|
/// Runs a command inside the sandbox (orchestrator-initiated only; the
|
||||||
|
/// sandbox can initiate nothing outbound).
|
||||||
|
async fn exec(&self, handle: &SandboxHandle, cmd: &[&str]) -> Result<ExecResult, SandboxError>;
|
||||||
|
/// Stops and removes the sandbox.
|
||||||
|
async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError>;
|
||||||
|
/// Whether the sandbox container is currently running.
|
||||||
|
async fn health(&self, handle: &SandboxHandle) -> Result<bool, SandboxError>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/// Hardening parameters for one agent sandbox. The non-negotiable controls
|
||||||
|
/// (uid 10001, cap-drop ALL, no-new-privileges, seccomp profile, read-only
|
||||||
|
/// rootfs, no network) are enforced by the driver and are not configurable
|
||||||
|
/// here by design — only resource limits vary per deployment.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SandboxSpec {
|
||||||
|
/// Unique container name, e.g. `teamclaw-sbx-{agent_id}`.
|
||||||
|
pub name: String,
|
||||||
|
/// Image reference; preloaded in air-gapped installs (never pulled).
|
||||||
|
pub image: String,
|
||||||
|
pub memory_bytes: i64,
|
||||||
|
pub nano_cpus: i64,
|
||||||
|
pub pids_limit: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SandboxHandle {
|
||||||
|
/// Container id assigned by the engine.
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ExecResult {
|
||||||
|
pub exit_code: i64,
|
||||||
|
pub stdout: String,
|
||||||
|
pub stderr: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! Kernel-level sandbox assertions (spec §15, acceptance-blocking): these
|
||||||
|
//! tests spawn REAL containers and probe the controls from inside —
|
||||||
|
//! asserting kernel behavior, not configuration strings.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use tc_sandbox::{DockerDriver, SandboxDriver, SandboxSpec};
|
||||||
|
|
||||||
|
const IMAGE: &str = "teamclaw/agent-base:dev";
|
||||||
|
|
||||||
|
/// Builds the agent image once if missing (subsequent runs hit the cache).
|
||||||
|
fn ensure_image() {
|
||||||
|
let exists = Command::new("docker")
|
||||||
|
.args(["image", "inspect", IMAGE])
|
||||||
|
.output()
|
||||||
|
.expect("docker available")
|
||||||
|
.status
|
||||||
|
.success();
|
||||||
|
if exists {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let root = env!("CARGO_MANIFEST_DIR");
|
||||||
|
let status = Command::new("docker")
|
||||||
|
.args([
|
||||||
|
"build",
|
||||||
|
"-t",
|
||||||
|
IMAGE,
|
||||||
|
"-f",
|
||||||
|
&format!("{root}/../../images/agent-base/Dockerfile"),
|
||||||
|
&format!("{root}/../../images/agent-base"),
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.expect("docker build runs");
|
||||||
|
assert!(status.success(), "agent-base image build failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn(name_suffix: &str) -> (DockerDriver, tc_sandbox::SandboxHandle) {
|
||||||
|
ensure_image();
|
||||||
|
let driver = DockerDriver::connect().expect("docker daemon reachable");
|
||||||
|
let spec = SandboxSpec {
|
||||||
|
name: format!("teamclaw-test-sbx-{name_suffix}-{}", std::process::id()),
|
||||||
|
image: IMAGE.into(),
|
||||||
|
memory_bytes: 256 * 1024 * 1024,
|
||||||
|
nano_cpus: 1_000_000_000,
|
||||||
|
pids_limit: 128,
|
||||||
|
};
|
||||||
|
// Clean leftovers from interrupted runs, then provision fresh.
|
||||||
|
let _ = driver.destroy_by_name(&spec.name).await;
|
||||||
|
let handle = driver.provision(&spec).await.expect("sandbox provisions");
|
||||||
|
(driver, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runs_as_uid_10001_with_no_capabilities() {
|
||||||
|
let (driver, handle) = spawn("uid").await;
|
||||||
|
|
||||||
|
let uid = driver.exec(&handle, &["id", "-u"]).await.unwrap();
|
||||||
|
assert_eq!(uid.stdout.trim(), "10001", "stderr: {}", uid.stderr);
|
||||||
|
|
||||||
|
// CapEff all-zero proves cap-drop ALL took effect in the kernel.
|
||||||
|
let caps = driver
|
||||||
|
.exec(&handle, &["grep", "CapEff", "/proc/self/status"])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let value = caps.stdout.split_whitespace().last().unwrap_or("");
|
||||||
|
assert_eq!(
|
||||||
|
u64::from_str_radix(value, 16).unwrap(),
|
||||||
|
0,
|
||||||
|
"effective capabilities must be empty, got {value}"
|
||||||
|
);
|
||||||
|
|
||||||
|
driver.destroy(&handle).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rootfs_is_read_only_with_writable_tmp() {
|
||||||
|
let (driver, handle) = spawn("rootfs").await;
|
||||||
|
|
||||||
|
let write_root = driver
|
||||||
|
.exec(&handle, &["touch", "/etc/owned"])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(write_root.exit_code, 0, "rootfs must reject writes");
|
||||||
|
assert!(
|
||||||
|
write_root.stderr.contains("Read-only file system"),
|
||||||
|
"got: {}",
|
||||||
|
write_root.stderr
|
||||||
|
);
|
||||||
|
|
||||||
|
let write_tmp = driver
|
||||||
|
.exec(&handle, &["touch", "/tmp/scratch"])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(write_tmp.exit_code, 0, "tmpfs /tmp must be writable");
|
||||||
|
|
||||||
|
let write_home = driver
|
||||||
|
.exec(&handle, &["touch", "/home/agent/file"])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(write_home.exit_code, 0, "workdir must be writable");
|
||||||
|
|
||||||
|
driver.destroy(&handle).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seccomp_denies_namespace_and_tracing_syscalls() {
|
||||||
|
let (driver, handle) = spawn("seccomp").await;
|
||||||
|
|
||||||
|
// unshare(2) is denied by the profile even though no capability is
|
||||||
|
// required for a user-namespace unshare.
|
||||||
|
let unshare = driver
|
||||||
|
.exec(&handle, &["unshare", "--user", "true"])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(unshare.exit_code, 0, "unshare must be denied");
|
||||||
|
|
||||||
|
driver.destroy(&handle).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sandbox_has_no_network_path_at_all() {
|
||||||
|
let (driver, handle) = spawn("egress").await;
|
||||||
|
|
||||||
|
// network=none: no traffic-carrying interface exists. The kernel
|
||||||
|
// auto-creates inert DOWN tunnel devices (tunl0, gre0, sit0, ...) in
|
||||||
|
// every namespace; what must be absent is any ethernet/veth device.
|
||||||
|
let interfaces = driver
|
||||||
|
.exec(&handle, &["cat", "/proc/net/dev"])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let carriers: Vec<&str> = interfaces
|
||||||
|
.stdout
|
||||||
|
.lines()
|
||||||
|
.skip(2)
|
||||||
|
.map(str::trim_start)
|
||||||
|
.filter(|line| {
|
||||||
|
line.starts_with("eth") || line.starts_with("en") || line.starts_with("veth")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(carriers.is_empty(), "unexpected interfaces: {carriers:?}");
|
||||||
|
|
||||||
|
// And an actual connect attempt goes nowhere.
|
||||||
|
let connect = driver
|
||||||
|
.exec(
|
||||||
|
&handle,
|
||||||
|
&[
|
||||||
|
"timeout",
|
||||||
|
"3",
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
"echo probe > /dev/tcp/1.1.1.1/443",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(connect.exit_code, 0, "egress connect must fail");
|
||||||
|
|
||||||
|
driver.destroy(&handle).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn no_setuid_binaries_exist_and_privileges_cannot_grow() {
|
||||||
|
let (driver, handle) = spawn("setuid").await;
|
||||||
|
|
||||||
|
let setuid = driver
|
||||||
|
.exec(
|
||||||
|
&handle,
|
||||||
|
&[
|
||||||
|
"find", "/usr", "/bin", "/sbin", "-perm", "/6000", "-type", "f",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(setuid.stdout.trim(), "", "setuid binaries found");
|
||||||
|
|
||||||
|
// NoNewPrivs flag is set on every process (no-new-privileges).
|
||||||
|
let nnp = driver
|
||||||
|
.exec(&handle, &["grep", "NoNewPrivs", "/proc/self/status"])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(nnp.stdout.trim().ends_with('1'), "got: {}", nnp.stdout);
|
||||||
|
|
||||||
|
driver.destroy(&handle).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn destroy_removes_the_container_and_health_reflects_it() {
|
||||||
|
let (driver, handle) = spawn("lifecycle").await;
|
||||||
|
assert!(driver.health(&handle).await.unwrap());
|
||||||
|
driver.destroy(&handle).await.unwrap();
|
||||||
|
assert!(!driver.health(&handle).await.unwrap());
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "tc-secrets"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = [
|
||||||
|
"json",
|
||||||
|
"rustls-tls",
|
||||||
|
] }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
tc-domain = { path = "../tc-domain" }
|
||||||
|
tc-safety = { path = "../tc-safety" }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
time = { workspace = true }
|
||||||
|
axum = "0.8"
|
||||||
|
tc-db = { path = "../tc-db" }
|
||||||
|
tc-testkit = { path = "../tc-testkit" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use tc_domain::WorkspaceId;
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::protocol::{read_frame, write_frame, Request, Response};
|
||||||
|
use crate::BrokerError;
|
||||||
|
|
||||||
|
/// Client side of the broker protocol, used by the server process only.
|
||||||
|
pub struct BrokerClient {
|
||||||
|
stream: UnixStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrokerClient {
|
||||||
|
pub async fn connect(socket_path: &Path) -> Result<BrokerClient, BrokerError> {
|
||||||
|
let stream = UnixStream::connect(socket_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
Ok(BrokerClient { stream })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn round_trip(&mut self, request: Request) -> Result<Response, BrokerError> {
|
||||||
|
write_frame(&mut self.stream, &request).await?;
|
||||||
|
let response: Response = read_frame(&mut self.stream).await?;
|
||||||
|
response.into_result()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn store_secret(
|
||||||
|
&mut self,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
kind: &str,
|
||||||
|
plaintext: &str,
|
||||||
|
) -> Result<Uuid, BrokerError> {
|
||||||
|
match self
|
||||||
|
.round_trip(Request::StoreSecret {
|
||||||
|
workspace_id: workspace_id.as_uuid(),
|
||||||
|
kind: kind.to_owned(),
|
||||||
|
plaintext: plaintext.to_owned(),
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Response::SecretStored { secret_id } => Ok(secret_id),
|
||||||
|
other => Err(BrokerError::Io(format!("unexpected response: {other:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn secret_kind(&mut self, secret_id: Uuid) -> Result<String, BrokerError> {
|
||||||
|
match self.round_trip(Request::SecretKind { secret_id }).await? {
|
||||||
|
Response::SecretKind { kind } => Ok(kind),
|
||||||
|
other => Err(BrokerError::Io(format!("unexpected response: {other:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invoke_http(
|
||||||
|
&mut self,
|
||||||
|
approval_id: Uuid,
|
||||||
|
secret_id: Uuid,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<u16, BrokerError> {
|
||||||
|
match self
|
||||||
|
.round_trip(Request::InvokeHttp {
|
||||||
|
approval_id,
|
||||||
|
secret_id,
|
||||||
|
url: url.to_owned(),
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Response::HttpDone { status } => Ok(status),
|
||||||
|
other => Err(BrokerError::Io(format!("unexpected response: {other:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//! Envelope encryption: ChaCha20-Poly1305 under a master key. Air-gapped
|
||||||
|
//! installs use a generated key file (backed up by the operator); a KMS
|
||||||
|
//! key source slots in behind the same seal/open surface for cloud.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
|
||||||
|
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||||
|
|
||||||
|
use crate::BrokerError;
|
||||||
|
|
||||||
|
/// A sealed (encrypted + authenticated) value.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Sealed {
|
||||||
|
pub ciphertext: Vec<u8>,
|
||||||
|
pub nonce: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Master key loaded from a hex-encoded 32-byte file, mode 0600.
|
||||||
|
pub struct FileKey {
|
||||||
|
cipher: ChaCha20Poly1305,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileKey {
|
||||||
|
/// Generates a fresh key file (done once by `teamclaw-admin init`).
|
||||||
|
pub fn generate(path: &Path) -> Result<(), BrokerError> {
|
||||||
|
let key = ChaCha20Poly1305::generate_key(&mut OsRng);
|
||||||
|
std::fs::write(path, hex::encode(key)).map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
||||||
|
.map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path) -> Result<FileKey, BrokerError> {
|
||||||
|
let encoded = std::fs::read_to_string(path).map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
let bytes = hex::decode(encoded.trim())
|
||||||
|
.map_err(|e| BrokerError::Crypto(format!("key file is not hex: {e}")))?;
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
return Err(BrokerError::Crypto(format!(
|
||||||
|
"key must be 32 bytes, found {}",
|
||||||
|
bytes.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(FileKey {
|
||||||
|
cipher: ChaCha20Poly1305::new(Key::from_slice(&bytes)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seal(&self, plaintext: &[u8]) -> Result<Sealed, BrokerError> {
|
||||||
|
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
|
||||||
|
let ciphertext = self
|
||||||
|
.cipher
|
||||||
|
.encrypt(&nonce, plaintext)
|
||||||
|
.map_err(|e| BrokerError::Crypto(e.to_string()))?;
|
||||||
|
Ok(Sealed {
|
||||||
|
ciphertext,
|
||||||
|
nonce: nonce.to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open(&self, sealed: &Sealed) -> Result<Vec<u8>, BrokerError> {
|
||||||
|
if sealed.nonce.len() != 12 {
|
||||||
|
return Err(BrokerError::Crypto("nonce must be 12 bytes".into()));
|
||||||
|
}
|
||||||
|
self.cipher
|
||||||
|
.decrypt(Nonce::from_slice(&sealed.nonce), sealed.ciphertext.as_ref())
|
||||||
|
.map_err(|_| BrokerError::Crypto("decryption failed (tampered or wrong key)".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//! The secret broker (spec §15): credentials live encrypted at rest and are
|
||||||
|
//! only ever used INSIDE the broker process — capability calls go in,
|
||||||
|
//! results come out, plaintext never crosses the socket. Gated capability
|
||||||
|
//! invocations independently consume the single-use execution grant against
|
||||||
|
//! Postgres before any credential is touched (defense in depth: a
|
||||||
|
//! compromised runtime cannot replay an approved action).
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
mod crypto;
|
||||||
|
mod protocol;
|
||||||
|
mod server;
|
||||||
|
mod store;
|
||||||
|
|
||||||
|
pub use client::BrokerClient;
|
||||||
|
pub use crypto::{FileKey, Sealed};
|
||||||
|
pub use server::BrokerServer;
|
||||||
|
pub use store::SecretStore;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BrokerError {
|
||||||
|
#[error("execution grant refused")]
|
||||||
|
GrantRefused,
|
||||||
|
#[error("invalid request: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
#[error("not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("crypto failure: {0}")]
|
||||||
|
Crypto(String),
|
||||||
|
#[error("io failure: {0}")]
|
||||||
|
Io(String),
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
//! Length-prefixed JSON over a unix socket: u32 big-endian frame length,
|
||||||
|
//! then the serialized request/response.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::BrokerError;
|
||||||
|
|
||||||
|
const MAX_FRAME: u32 = 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "op", rename_all = "snake_case")]
|
||||||
|
pub enum Request {
|
||||||
|
StoreSecret {
|
||||||
|
workspace_id: Uuid,
|
||||||
|
kind: String,
|
||||||
|
plaintext: String,
|
||||||
|
},
|
||||||
|
SecretKind {
|
||||||
|
secret_id: Uuid,
|
||||||
|
},
|
||||||
|
/// Performs an HTTP POST with the secret injected as a bearer token.
|
||||||
|
/// Requires consuming the approval's single-use execution grant.
|
||||||
|
InvokeHttp {
|
||||||
|
approval_id: Uuid,
|
||||||
|
secret_id: Uuid,
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "result", rename_all = "snake_case")]
|
||||||
|
pub enum Response {
|
||||||
|
SecretStored { secret_id: Uuid },
|
||||||
|
SecretKind { kind: String },
|
||||||
|
HttpDone { status: u16 },
|
||||||
|
Error { kind: ErrorKind, message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ErrorKind {
|
||||||
|
GrantRefused,
|
||||||
|
Invalid,
|
||||||
|
NotFound,
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Response {
|
||||||
|
pub fn from_error(err: &BrokerError) -> Response {
|
||||||
|
let (kind, message) = match err {
|
||||||
|
BrokerError::GrantRefused => (ErrorKind::GrantRefused, err.to_string()),
|
||||||
|
BrokerError::Invalid(m) => (ErrorKind::Invalid, m.clone()),
|
||||||
|
BrokerError::NotFound => (ErrorKind::NotFound, err.to_string()),
|
||||||
|
BrokerError::Crypto(m) | BrokerError::Io(m) => (ErrorKind::Internal, m.clone()),
|
||||||
|
};
|
||||||
|
Response::Error { kind, message }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_result(self) -> Result<Response, BrokerError> {
|
||||||
|
match self {
|
||||||
|
Response::Error { kind, message } => Err(match kind {
|
||||||
|
ErrorKind::GrantRefused => BrokerError::GrantRefused,
|
||||||
|
ErrorKind::Invalid => BrokerError::Invalid(message),
|
||||||
|
ErrorKind::NotFound => BrokerError::NotFound,
|
||||||
|
ErrorKind::Internal => BrokerError::Io(message),
|
||||||
|
}),
|
||||||
|
ok => Ok(ok),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_frame<W, T>(writer: &mut W, value: &T) -> Result<(), BrokerError>
|
||||||
|
where
|
||||||
|
W: AsyncWriteExt + Unpin,
|
||||||
|
T: Serialize,
|
||||||
|
{
|
||||||
|
let payload = serde_json::to_vec(value).map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
let len = u32::try_from(payload.len()).map_err(|_| BrokerError::Invalid("frame".into()))?;
|
||||||
|
if len > MAX_FRAME {
|
||||||
|
return Err(BrokerError::Invalid("frame too large".into()));
|
||||||
|
}
|
||||||
|
writer
|
||||||
|
.write_all(&len.to_be_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
writer
|
||||||
|
.write_all(&payload)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_frame<R, T>(reader: &mut R) -> Result<T, BrokerError>
|
||||||
|
where
|
||||||
|
R: AsyncReadExt + Unpin,
|
||||||
|
T: for<'de> Deserialize<'de>,
|
||||||
|
{
|
||||||
|
let mut len_bytes = [0u8; 4];
|
||||||
|
reader
|
||||||
|
.read_exact(&mut len_bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
let len = u32::from_be_bytes(len_bytes);
|
||||||
|
if len > MAX_FRAME {
|
||||||
|
return Err(BrokerError::Invalid("frame too large".into()));
|
||||||
|
}
|
||||||
|
let mut payload = vec![0u8; len as usize];
|
||||||
|
reader
|
||||||
|
.read_exact(&mut payload)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
serde_json::from_slice(&payload).map_err(|e| BrokerError::Io(e.to_string()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tc_domain::WorkspaceId;
|
||||||
|
use tokio::net::{UnixListener, UnixStream};
|
||||||
|
|
||||||
|
use crate::crypto::FileKey;
|
||||||
|
use crate::protocol::{read_frame, write_frame, Request, Response};
|
||||||
|
use crate::store::SecretStore;
|
||||||
|
use crate::BrokerError;
|
||||||
|
|
||||||
|
/// The broker daemon: listens on a unix socket reachable only by the
|
||||||
|
/// server process (never mounted into agent sandboxes).
|
||||||
|
pub struct BrokerServer {
|
||||||
|
pool: PgPool,
|
||||||
|
key: FileKey,
|
||||||
|
socket_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrokerServer {
|
||||||
|
pub fn new(pool: PgPool, key: FileKey, socket_path: PathBuf) -> BrokerServer {
|
||||||
|
BrokerServer {
|
||||||
|
pool,
|
||||||
|
key,
|
||||||
|
socket_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(self) -> Result<(), BrokerError> {
|
||||||
|
let _ = std::fs::remove_file(&self.socket_path);
|
||||||
|
let listener =
|
||||||
|
UnixListener::bind(&self.socket_path).map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
let server = std::sync::Arc::new(self);
|
||||||
|
loop {
|
||||||
|
let (stream, _) = listener
|
||||||
|
.accept()
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
let server = server.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = server.handle_connection(stream).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_connection(&self, mut stream: UnixStream) -> Result<(), BrokerError> {
|
||||||
|
loop {
|
||||||
|
let request: Request = match read_frame(&mut stream).await {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(_) => return Ok(()), // client hung up
|
||||||
|
};
|
||||||
|
let response = match self.handle(request).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => Response::from_error(&error),
|
||||||
|
};
|
||||||
|
write_frame(&mut stream, &response).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(&self, request: Request) -> Result<Response, BrokerError> {
|
||||||
|
let store = SecretStore {
|
||||||
|
pool: &self.pool,
|
||||||
|
key: &self.key,
|
||||||
|
};
|
||||||
|
match request {
|
||||||
|
Request::StoreSecret {
|
||||||
|
workspace_id,
|
||||||
|
kind,
|
||||||
|
plaintext,
|
||||||
|
} => {
|
||||||
|
let secret_id = store
|
||||||
|
.store(WorkspaceId::from(workspace_id), &kind, &plaintext)
|
||||||
|
.await?;
|
||||||
|
Ok(Response::SecretStored { secret_id })
|
||||||
|
}
|
||||||
|
Request::SecretKind { secret_id } => Ok(Response::SecretKind {
|
||||||
|
kind: store.kind(secret_id).await?,
|
||||||
|
}),
|
||||||
|
Request::InvokeHttp {
|
||||||
|
approval_id,
|
||||||
|
secret_id,
|
||||||
|
url,
|
||||||
|
} => {
|
||||||
|
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||||
|
return Err(BrokerError::Invalid(format!(
|
||||||
|
"capability urls must be http(s), got {url}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// Independent grant verification BEFORE any credential is
|
||||||
|
// touched: the broker does not trust its caller (§15).
|
||||||
|
tc_safety::grants::consume(&self.pool, approval_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| BrokerError::GrantRefused)?;
|
||||||
|
|
||||||
|
let credential = store.reveal_internal(secret_id).await?;
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(credential)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
Ok(Response::HttpDone {
|
||||||
|
status: response.status().as_u16(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
use tc_domain::WorkspaceId;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::crypto::{FileKey, Sealed};
|
||||||
|
use crate::BrokerError;
|
||||||
|
|
||||||
|
/// Encrypted persistence for the `secrets` table. Plaintext exists only
|
||||||
|
/// transiently inside broker memory during a capability call.
|
||||||
|
pub struct SecretStore<'a> {
|
||||||
|
pub pool: &'a PgPool,
|
||||||
|
pub key: &'a FileKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretStore<'_> {
|
||||||
|
pub async fn store(
|
||||||
|
&self,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
kind: &str,
|
||||||
|
plaintext: &str,
|
||||||
|
) -> Result<Uuid, BrokerError> {
|
||||||
|
let sealed = self.key.seal(plaintext.as_bytes())?;
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO secrets (id, workspace_id, kind, ciphertext, nonce)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
id,
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
kind,
|
||||||
|
sealed.ciphertext,
|
||||||
|
sealed.nonce,
|
||||||
|
)
|
||||||
|
.execute(self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrokerError::Io(e.to_string()))?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn kind(&self, id: Uuid) -> Result<String, BrokerError> {
|
||||||
|
sqlx::query_scalar!("SELECT kind FROM secrets WHERE id = $1", id)
|
||||||
|
.fetch_optional(self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrokerError::Io(e.to_string()))?
|
||||||
|
.ok_or(BrokerError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts a secret for immediate use inside the broker. Callers must
|
||||||
|
/// never serialize the result onto the wire.
|
||||||
|
pub async fn reveal_internal(&self, id: Uuid) -> Result<String, BrokerError> {
|
||||||
|
let row = sqlx::query!("SELECT ciphertext, nonce FROM secrets WHERE id = $1", id)
|
||||||
|
.fetch_optional(self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrokerError::Io(e.to_string()))?
|
||||||
|
.ok_or(BrokerError::NotFound)?;
|
||||||
|
let plaintext = self.key.open(&Sealed {
|
||||||
|
ciphertext: row.ciphertext,
|
||||||
|
nonce: row.nonce,
|
||||||
|
})?;
|
||||||
|
String::from_utf8(plaintext).map_err(|e| BrokerError::Crypto(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# The agent sandbox base image (spec §15): the "Computer" an agent's
|
||||||
|
# environment tools run inside. Non-root by construction, no setuid
|
||||||
|
# binaries, designed to run with cap-drop ALL, a seccomp deny profile,
|
||||||
|
# read-only rootfs, and no network.
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN useradd --uid 10001 --user-group --create-home --shell /usr/sbin/nologin agent \
|
||||||
|
# No privilege-escalation paths: strip every setuid/setgid binary.
|
||||||
|
&& find / -xdev -perm /6000 -type f -delete
|
||||||
|
|
||||||
|
USER 10001:10001
|
||||||
|
WORKDIR /home/agent
|
||||||
|
|
||||||
|
# Idle keep-alive; the orchestrator execs work into the container.
|
||||||
|
CMD ["sleep", "infinity"]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"defaultAction": "SCMP_ACT_ALLOW",
|
||||||
|
"archMap": [
|
||||||
|
{
|
||||||
|
"architecture": "SCMP_ARCH_X86_64",
|
||||||
|
"subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"architecture": "SCMP_ARCH_AARCH64",
|
||||||
|
"subArchitectures": ["SCMP_ARCH_ARM"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"syscalls": [
|
||||||
|
{
|
||||||
|
"names": [
|
||||||
|
"acct",
|
||||||
|
"add_key",
|
||||||
|
"bpf",
|
||||||
|
"clone3",
|
||||||
|
"delete_module",
|
||||||
|
"finit_module",
|
||||||
|
"init_module",
|
||||||
|
"kexec_file_load",
|
||||||
|
"kexec_load",
|
||||||
|
"keyctl",
|
||||||
|
"mount",
|
||||||
|
"move_mount",
|
||||||
|
"open_by_handle_at",
|
||||||
|
"perf_event_open",
|
||||||
|
"pivot_root",
|
||||||
|
"process_vm_readv",
|
||||||
|
"process_vm_writev",
|
||||||
|
"ptrace",
|
||||||
|
"quotactl",
|
||||||
|
"reboot",
|
||||||
|
"request_key",
|
||||||
|
"setns",
|
||||||
|
"swapoff",
|
||||||
|
"swapon",
|
||||||
|
"umount2",
|
||||||
|
"unshare",
|
||||||
|
"userfaultfd"
|
||||||
|
],
|
||||||
|
"action": "SCMP_ACT_ERRNO",
|
||||||
|
"errnoRet": 1,
|
||||||
|
"comment": "Deny list on top of cap-drop ALL: kernel-facing syscalls an agent workload never needs. P6 hardening replaces this with a strict allowlist profile."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user