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
+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()))
}