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
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user