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
+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,
}