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
Generated
+125
View File
@@ -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"
+3
View File
@@ -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]
+15
View File
@@ -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
+57
View File
@@ -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}"))
}
+21
View File
@@ -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
+175
View File
@@ -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())),
}
}
}
+31
View File
@@ -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>;
}
+28
View File
@@ -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,
}
+192
View File
@@ -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());
}
+32
View File
@@ -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
+73
View File
@@ -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:?}"))),
}
}
}
+73
View File
@@ -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()))
}
}
+31
View File
@@ -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),
}
+115
View File
@@ -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()))
}
+108
View File
@@ -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(),
})
}
}
}
}
+61
View File
@@ -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()))
}
}
+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());
}
+15
View File
@@ -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"]
+49
View File
@@ -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."
}
]
}