P6: shell.exec — agents execute code in their real hardened sandbox
- SandboxManager (tc-runtime): one container per agent, provisioned
lazily on first use, reused for the manager's lifetime, replaced
transparently if dead, destroyed on shutdown
- shell.exec tool: sh -lc inside the agent's sandbox; stdout/stderr/
exit_code return to the model as the step output. No external effects
declared — the sandbox boundary (uid 10001, no caps, seccomp
allowlist, read-only rootfs, zero egress) is the §15 control here,
not an approval gate
- RuntimeConfig.sandboxes (+ with_sandboxes builder); [sandbox] config
{image, enabled}; the server connects the Docker driver at boot and
tolerates an absent engine (shell.exec reports it per-call)
- Tests with the REAL DockerDriver: a scripted run executes two
commands — output proves uid 10001 from inside, and /home/agent state
written by the first call is read by the second (same sandbox); a
deployment without a sandbox runtime records honest error steps and
the run still completes
151 Rust tests + 27 Playwright journeys.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
84c51168be
commit
7392ce1d08
@@ -0,0 +1,74 @@
|
||||
//! Per-agent sandbox lifecycle for environment tools. One hardened
|
||||
//! container per agent, provisioned lazily on first use and reused for
|
||||
//! the manager's lifetime; a dead sandbox is replaced transparently.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tc_domain::AgentId;
|
||||
use tc_sandbox::{ExecResult, SandboxDriver, SandboxHandle, SandboxSpec};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
impl std::fmt::Debug for SandboxManager {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SandboxManager")
|
||||
.field("image", &self.image)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SandboxManager {
|
||||
driver: Arc<dyn SandboxDriver>,
|
||||
image: String,
|
||||
handles: Mutex<HashMap<AgentId, SandboxHandle>>,
|
||||
}
|
||||
|
||||
impl SandboxManager {
|
||||
pub fn new(driver: Arc<dyn SandboxDriver>, image: &str) -> SandboxManager {
|
||||
SandboxManager {
|
||||
driver,
|
||||
image: image.to_owned(),
|
||||
handles: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `sh -lc <command>` in the agent's sandbox, provisioning it on
|
||||
/// first use. The sandbox has no egress and no credentials — running
|
||||
/// agent-authored code here is the point of the architecture.
|
||||
pub async fn exec(&self, agent_id: AgentId, command: &str) -> Result<ExecResult, String> {
|
||||
let mut handles = self.handles.lock().await;
|
||||
let alive = match handles.get(&agent_id) {
|
||||
Some(handle) => self.driver.health(handle).await.unwrap_or(false),
|
||||
None => false,
|
||||
};
|
||||
if !alive {
|
||||
let short = uuid::Uuid::now_v7().simple().to_string();
|
||||
let spec = SandboxSpec {
|
||||
name: format!("tc-agent-{}", &short[short.len() - 12..]),
|
||||
image: self.image.clone(),
|
||||
memory_bytes: 512 * 1024 * 1024,
|
||||
nano_cpus: 1_000_000_000,
|
||||
pids_limit: 256,
|
||||
};
|
||||
let handle = self
|
||||
.driver
|
||||
.provision(&spec)
|
||||
.await
|
||||
.map_err(|e| format!("sandbox provision failed: {e}"))?;
|
||||
handles.insert(agent_id, handle);
|
||||
}
|
||||
let handle = handles.get(&agent_id).expect("just ensured");
|
||||
self.driver
|
||||
.exec(handle, &["sh", "-lc", command])
|
||||
.await
|
||||
.map_err(|e| format!("sandbox exec failed: {e}"))
|
||||
}
|
||||
|
||||
/// Destroys every sandbox this manager provisioned.
|
||||
pub async fn shutdown(&self) {
|
||||
let mut handles = self.handles.lock().await;
|
||||
for (_, handle) in handles.drain() {
|
||||
let _ = self.driver.destroy(&handle).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user