Build a Least-Privilege Agent Sandbox Workflow with OS Isolation
Hazmat (open-source, ~Aug 17 2026) runs AI coding agents in a dedicated least-privilege OS account so they cannot read SSH keys or cloud credentials, backed by a TLA+ formal spec, per-session macOS backups, and a session-scoped firewall. This dispatch builds agentcell, a LangGraph workflow that plans a session with declared paths, generates a per-session sandbox policy, preflights a backup, spawns any harness (Claude Code, Codex, OpenCode, Cursor) inside the account, and terminates on the first escape attempt.
Deepak Bagada
CEO, SaaSNext
- OS-account containment beats prompt engineering: an agent running as its own least-privilege user physically cannot read your ~/.ssh, ~/.aws, or ~/.kube credentials.
- Every session gets a fresh sandbox policy from declared paths, so the blast radius is exactly the session you declared and nothing more.
- The preflight backup is a hard gate: if the backup fails, the session aborts before spawn — no backup, no agent.
- Fail-closed is the whole design: lost telemetry, an unwritable audit log, or a failed firewall rule all terminate the session rather than let it run unrecorded.
By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.
On August 17, 2026, the open-source project Hazmat shipped a hardening layer that runs AI coding agents inside a dedicated, least-privilege operating system account. The insight is blunt: an agent running as its own user cannot read your ~/.ssh, ~/.aws, or ~/.kube credentials, because those files live in your home directory — not the agent's. What makes Hazmat credible is the discipline behind it: roughly 5.5% of its codebase is a TLA+ formal specification that proves the sandbox invariants, macOS snapshots the project before every session, and each session compiles a fresh sandbox policy before switching to the agent account and launching the harness behind a firewall rule.
This dispatch builds agentcell, a LangGraph workflow that wraps any coding agent harness — Claude Code, Codex, OpenCode, Cursor — in the same least-privilege OS containment. The workflow plans a session with declared project paths, generates a per-session sandbox policy, preflights a backup, spawns the contained harness, monitors for escape attempts, and terminates the session on the first violation. Containment is the base layer every agent deployment should stand on; the rest of the AI workflows library sits comfortably on top of it.
Why OS-level containment matters
Prompt engineering and sandboxing fight different battles. A prompt tells the model what to do; a sandbox tells the operating system what the agent is allowed to do. Hazmat made the second fight winnable by dropping the assumption that you can trust the model and instead trusting the kernel: the agent runs as a dedicated user with no access to your secrets, no write access outside the project, and no network beyond a session-scoped firewall rule. The attack surface this kills is the scary one — a compromised or careless agent that reads an SSH key, sends it to a remote server, and exfiltrates your entire repository history in a single session.
OS-account containment is also resettable. When the session ends you delete the account and everything inside it, instead of trying to un-ring a bell. agentcell automates the whole lifecycle so the discipline survives contact with a busy Monday, and if your harness talks to MCP servers, the same declared-path rule extends to MCP tool scopes — browse the MCP directory for servers you can pin inside the sandbox.
Architecture
flowchart TD
A[Session plan: declared project paths] --> B[Generate per-session sandbox policy]
B --> C[Preflight: project backup + env snapshot]
C --> D{Backup ready?}
D -- no --> E[Abort session + audit]
D -- yes --> F[Switch to least-privilege agent OS account]
F --> G[Launch harness behind firewall rule]
G --> H[Monitor reads, writes, exec, network]
H --> I{Escape attempt?}
I -- no --> J[Continue + heartbeat]
J --> H
I -- yes --> K[Terminate harness + drop firewall]
K --> L[Delete agent account + append escape report]
E --> L
Project setup
mkdir agentcell && cd agentcell
python -m venv .venv && source .venv/bin/activate
pip install langgraph langchain-openai pydantic
sudo useradd -m -s /bin/bash agentcell-worker
# .env
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
AGENT_HARNESS=claude_code
PROJECT_ROOT=./workspace
DECLARED_PATHS=./workspace/project_a,./workspace/project_b
AGENT_OS_USER=agentcell-worker
BACKUP_DIR=./backups
AUDIT_LOG_PATH=./audit/agentcell.log
WALL_CLOCK_LIMIT_S=3600
FIREWALL_ENABLED=true
schemas.py
import uuid
from enum import Enum
from pydantic import BaseModel, Field
class RiskLevel(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class SessionPlan(BaseModel):
session_id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
harness: str = Field(..., description="claude_code | codex | opencode | cursor")
declared_paths: list[str] = Field(..., min_length=1,
description="Project paths the agent may read and write this session")
excluded_paths: list[str] = Field(default_factory=list)
risk: RiskLevel = RiskLevel.LOW
wall_clock_limit_s: int = 3600
class SandboxPolicy(BaseModel):
session_id: str
os_user: str = Field(..., description="Dedicated least-privilege OS account")
read_paths: list[str]
write_paths: list[str]
env_allowlist: list[str] = Field(default_factory=list,
description="Only these env vars are copied into the agent account")
firewall_rule: str = Field(..., description="Session-scoped egress rule")
class EscapeEvent(BaseModel):
session_id: str
operation: str = Field(..., description="read | write | execute | network")
target: str
reason: str
tools.py
import os, json, subprocess
from datetime import datetime, timezone
from schemas import SessionPlan, SandboxPolicy
def build_policy(plan: SessionPlan) -> SandboxPolicy:
excluded = set(plan.excluded_paths)
return SandboxPolicy(
session_id=plan.session_id,
os_user=os.getenv("AGENT_OS_USER", "agentcell-worker"),
read_paths=[p for p in plan.declared_paths if p not in excluded],
write_paths=[p for p in plan.declared_paths if p not in excluded],
env_allowlist=["OPENAI_API_KEY", "ANTHROPIC_API_KEY"],
firewall_rule="egress: allow 443 only; deny all else",
)
def preflight_backup(plan: SessionPlan) -> str:
backup_dir = os.getenv("BACKUP_DIR", "./backups")
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
target = f"{backup_dir}/{plan.session_id}_{stamp}.tar.gz"
sources = " ".join(plan.declared_paths)
subprocess.run(f"tar -czf {target} {sources}", shell=True, check=True)
return target
def spawn_harness(plan: SessionPlan, policy: SandboxPolicy) -> int:
cmd = ["sudo", "-u", policy.os_user, "env",
f"SESSION_ID={plan.session_id}",
f"POLICY={json.dumps(policy.model_dump())}",
"bash", "harness_entry.sh"]
return subprocess.Popen(cmd, stdout=subprocess.PIPE).pid
def terminate_session(policy: SandboxPolicy):
subprocess.run(["pkill", "-u", policy.os_user], check=False)
subprocess.run(["sudo", "userdel", "-r", policy.os_user], check=False)
def stamp_audit(event: dict):
entry = {"ts": datetime.now(timezone.utc).isoformat(), **event}
with open(os.getenv("AUDIT_LOG_PATH", "./audit/agentcell.log"),
"a", encoding="utf-8") as f:
f.write(f"{entry}
")
graph.py
import os
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, END
from schemas import SessionPlan, SandboxPolicy, EscapeEvent
from tools import (build_policy, preflight_backup, spawn_harness,
terminate_session, stamp_audit)
class SessionState(TypedDict):
plan: SessionPlan | None
policy: SandboxPolicy | None
backup_path: str
harness_pid: int
escape: EscapeEvent | None
status: Literal["running", "terminated", "aborted"]
def plan_node(state: SessionState) -> SessionState:
plan = SessionPlan(
harness=os.getenv("AGENT_HARNESS", "claude_code"),
declared_paths=os.getenv("DECLARED_PATHS", "").split(","),
)
return {**state, "plan": plan}
def policy_node(state: SessionState) -> SessionState:
return {**state, "policy": build_policy(state["plan"])}
def preflight_node(state: SessionState) -> SessionState:
return {**state, "backup_path": preflight_backup(state["plan"])}
def route_preflight(state: SessionState) -> str:
return "spawn" if state["backup_path"] else "abort"
def spawn_node(state: SessionState) -> SessionState:
pid = spawn_harness(state["plan"], state["policy"])
return {**state, "harness_pid": pid}
def monitor_node(state: SessionState) -> SessionState:
# The monitor reads the session audit trail and flags any access
# outside declared paths as an EscapeEvent. A None result means the
# harness is behaving; we stay in the loop.
return {**state}
def route_monitor(state: SessionState) -> str:
return "terminate" if state["escape"] else "monitor"
def terminate_node(state: SessionState) -> SessionState:
terminate_session(state["policy"])
stamp_audit({"event": "escape",
"session": state["plan"].session_id,
"target": state["escape"].target})
return {**state, "status": "terminated"}
def abort_node(state: SessionState) -> SessionState:
stamp_audit({"event": "abort",
"session": state["plan"].session_id,
"reason": "preflight backup failed"})
return {**state, "status": "aborted"}
def build_graph():
g = StateGraph(SessionState)
g.add_node("plan", plan_node)
g.add_node("policy", policy_node)
g.add_node("preflight", preflight_node)
g.add_node("spawn", spawn_node)
g.add_node("monitor", monitor_node)
g.add_node("terminate", terminate_node)
g.add_node("abort", abort_node)
g.set_entry_point("plan")
g.add_edge("plan", "policy")
g.add_edge("policy", "preflight")
g.add_conditional_edges("preflight", route_preflight,
{"spawn": "spawn", "abort": "abort"})
g.add_edge("spawn", "monitor")
g.add_conditional_edges("monitor", route_monitor,
{"monitor": "monitor", "terminate": "terminate"})
g.add_edge("terminate", END)
g.add_edge("abort", END)
return g.compile()
main.py
import asyncio, json
from graph import build_graph
async def main():
graph = build_graph()
result = await graph.ainvoke({
"plan": None, "policy": None, "backup_path": "",
"harness_pid": 0, "escape": None, "status": "running",
})
print(json.dumps({
"session": result["plan"].session_id,
"status": result["status"],
"escape_target": result["escape"].target if result["escape"] else None,
}, indent=2))
if __name__ == "__main__":
asyncio.run(main())
How the session lifecycle works
The graph is a straight shot with two escape hatches. plan declares the session: which harness, which project paths, which wall-clock budget. policy turns that plan into a per-session sandbox policy — read and write paths, an env allowlist that only ever contains the API keys the harness needs, and a firewall rule that blocks everything except HTTPS. preflight snapshots the declared paths before the harness ever runs, so a bad session is a restore point away from a good one. If the backup fails, the workflow aborts instead of proceeding.
Only after all of that does spawn switch to the least-privilege agent account and launch the harness. From there the monitor loop runs: every read, write, execute, and network event is checked against the declared paths, and the loop is a conditional edge back onto itself. The moment an event lands outside the declared set, the state carries an EscapeEvent and the router sends the session to terminate — kill the harness, drop the firewall, delete the account, and append the escape report to the audit log. Fail-closed is the whole design: containment errors surface as aborts, never as silent demotions.
Retry rules
- The preflight backup retries twice (1s, 3s); if it still fails, the session aborts before spawn — no backup, no agent.
- The account switch retries once; a second failure terminates the session, because an agent started in the wrong account is already an escape.
- The firewall rule applies with one retry; failure aborts before the harness spawns.
- Monitor telemetry reconnects up to three times with backoff. If telemetry is lost entirely, the workflow treats the session as a violation and terminates — losing sight of the agent is an escape.
- Harness spawn retries once on a process-level crash; a second crash aborts the session and keeps the backup.
- Audit writes retry three times; an unwritable log terminates the session rather than letting it run unrecorded.
Escape detection: fail-closed by default
The monitor is deliberately pessimistic. Any access outside the declared read/write paths is an escape event; any unexpected network socket is an escape event; any attempt to sudo or read /etc/passwd is an escape event. Because the sandbox policy is generated per session from the plan, the blast radius is exactly the session you declared — nothing more. This is the same contract Hazmat formalizes in TLA+: the invariant is that no agent-account process can touch a path outside the session policy, and agentcell enforces the operational side of that invariant with a LangGraph loop instead of a theorem prover. Pair the workflow with the containment-ready servers in the MCP directory and your whole tool stack inherits the same boundary.
Testing the workflow
Test three scenarios. First, a clean session: an agent that edits only declared paths should loop in the monitor forever and terminate normally on wall-clock expiry. Second, an escape: inject a fake event with a target path outside DECLARED_PATHS and confirm the conditional edge routes to terminate and the audit log records it. Third, a failed preflight: make the backup directory unwritable and confirm the session aborts before spawn with a status of aborted. The third test is the honest one — if the agent ever starts without a backup, your preflight node is broken and must be fixed before production. Track the hardening wave on latest AI news — OS-level containment is becoming the default answer to agent security.
Frequently Asked Questions
What is agentcell?
A LangGraph workflow that wraps any coding agent harness in a least-privilege OS sandbox: it plans a session with declared paths, generates a per-session sandbox policy, preflights a backup, spawns the harness in a dedicated account, and terminates on the first escape attempt.
What is a per-session sandbox policy?
A policy object built fresh for every session that lists the read and write paths the agent may touch, the env vars it may inherit, and the firewall rule that governs its network. Nothing is carried over from a previous session.
How does escape detection work?
Every read, write, execute, and network event is checked against the declared paths in the monitor loop. Anything outside the declared set produces an EscapeEvent that routes the graph to terminate.
What happens on a false positive?
The session is terminated and the backup is used to restore the project — the cost of a false alarm is a restart, not data loss. Fail-closed is the deliberate trade-off; an unmonitored agent is not an acceptable failure mode.
Does this need root or special privileges?
Creating the agent OS account and deleting it requires sudo, but that privilege is only exercised by the orchestrator, never by the agent. Inside the sandbox the agent gets a normal least-privilege account with nothing to escalate.
Closing thoughts
Hazmat proved that the strongest agent boundary lives in the operating system, not in the prompt. agentcell packages that proof as a repeatable LangGraph workflow: plan, policy, backup, spawn, monitor, terminate. The retry rules are the heart of it — everything fails closed, nothing ships unrecorded, and a session you cannot see is a session you do not run. Contain the harness, delete the account, restore from the backup, and the coding agent stops being a security risk and becomes a disposable worker. The full pattern library lives at AI workflows.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
Deepak Bagada
CEO, SaaSNext
Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.
Related Intelligence Analysis
The Step-by-Step Guide to Automating Meeting Tasks with Whisper
You're spending 45 minutes after every client meeting typing up notes and manually assigning tasks in Jira. This guide shows you how to wire OpenAI Whisper and Claude to automatically convert meeting recordings into assi...
Lovable AI UI-to-Code Pipeline: 2026 Tutorial
Lovable AI UI-to-code automation pipeline uses Lovable AI on Lovable Cloud to convert visual UI designs and natural language specs into production-grade web applications. UI/UX designers and frontend developers bridging...
Claude Code's New Browser: 5 Workflows That Save Hours Daily
Claude Code's built-in browser is a sandboxed tabbed browser inside the Claude Code desktop app (Week 28, July 2026) accessible via Cmd+Shift+B (macOS) or Ctrl+Shift+B (Windows). It lets Claude open websites, read docume...