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