Phase 2 gave us kernel-level enforcement (blocking denied file access) and process tree tracking. But two critical weaknesses remained:
Weakness 1: Process name spoofing. Any process can change its own comm name with a single syscall:
import ctypes
libc = ctypes.CDLL("libc.so.6")
libc.prctl(15, b"systemd\0") # Now /proc/PID/comm says "systemd"After this, Guardian stops monitoring the agent. It walks free.
Weakness 2: Same-runtime ambiguity. Two Python agents (e.g., Aider and OpenClaw) both appear as python3. Phase 2's process tree tracking helps (separate trees), but the initial identification still relies on a spoofable name.
Weakness 3: No resource control. A malicious or buggy agent could consume all system memory, spawn thousands of processes (fork bomb), or hog the CPU.
Phase 3 solves all three by using Linux cgroups — a kernel-enforced isolation mechanism that a process cannot escape or spoof, no matter what.
| Binary | Purpose |
|---|---|
guardian-launch |
Launches an agent inside a dedicated cgroup with resource limits, registers it with the Guardian daemon |
guardian-ctl |
CLI tool to list running agents, stop them, and grant temporary file access |
| File | What Changed |
|---|---|
guardian-ebpf/src/main.rs |
Added 3 new BPF maps (WATCHED_CGROUPS, ENFORCE_CGROUPS, CGROUP_DEFAULT_ACTION). Added bpf_get_current_cgroup_id() call. Implemented 3-tier identification: cgroup → TGID → comm. |
guardian-common/src/lib.rs |
Changed from unconditional #![no_std] to conditional #![cfg_attr(not(feature = "user"), no_std)]. Added IPC protocol types (IpcRequest, IpcResponse, AgentStatus) behind the user feature. Added length-prefixed JSON send/recv helpers. |
guardian-common/Cargo.toml |
Added serde and serde_json as optional dependencies behind the user feature. |
guardian/src/main.rs |
Added IPC server startup, cgroup BPF map management, cgroup cleanup background task, temporary grant expiry task. Modified event processing to work with both comm-based and cgroup-based agents. |
guardian/src/config.rs |
Added socket_path to GlobalConfig. Added identity field to AgentConfig ("comm" or "cgroup"). Made process_name optional. Added ResourceLimits struct. Added backward-compatible effective_identity() and effective_process_name() methods. |
guardian/src/ipc.rs |
New file. Complete IPC server: Unix socket listener, agent registration handler, list/stop/grant handlers, cgroup lifecycle cleanup, temporary grant expiry. |
guardian/Cargo.toml |
Added serde_json and libc dependencies. |
guardian-launch/ |
New crate. The launcher binary. |
guardian-ctl/ |
New crate. The management CLI. |
Cargo.toml |
Added guardian-launch and guardian-ctl to workspace members and default-members. |
config.toml |
Added socket_path. Added commented cgroup-based agent example with resource limits. |
USER SPACE
┌──────────────────────────────────────────────────────────────────┐
│ │
│ guardian-launch Guardian Daemon │
│ ┌────────────────┐ IPC ┌──────────────────────┐ │
│ │ 1. Create cgroup├──────────────>│ Unix socket listener │ │
│ │ 2. Set limits │ register │ /run/guardian.sock │ │
│ │ 3. Register │<─────────────┤ │ │
│ │ 4. Move to cgrp │ ACK │ Populates BPF maps: │ │
│ │ 5. exec(agent) │ │ WATCHED_CGROUPS │ │
│ └────────────────┘ │ ENFORCE_CGROUPS │ │
│ │ CGROUP_DEFAULT_ACTION│ │
│ guardian-ctl │ │ │
│ ┌────────────────┐ IPC │ Background tasks: │ │
│ │ list / stop / ├──────────────>│ - Cgroup cleanup │ │
│ │ grant │ │ - Grant expiry │ │
│ └────────────────┘ │ - PID rescan │ │
│ └──────────┬───────────┘ │
│ │ │
│ Cgroup Hierarchy: │ │
│ /sys/fs/cgroup/guardian/ │ │
│ ├── aider-1234/ ← PID 1234, 1235 │ │
│ └── openclaw-5678/ ← PID 5678 │ │
│ │ │
├════════════════════════════════════════════════╪════════════════┤
│ │ │
│ KERNEL SPACE │ │
│ │ │
│ eBPF Programs │ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ sys_enter_openat tracepoint: │ │
│ │ cgroup_id = bpf_get_current_cgroup_id() │ │
│ │ if WATCHED_CGROUPS[cgroup_id] ← Priority 1 │ │
│ │ OR WATCHED_TGIDS[tgid] ← Priority 2 │ │
│ │ OR WATCHED_COMMS[comm] ← Priority 3 │ │
│ │ → capture event, evaluate policy, set PENDING_DENY │ │
│ │ │ │
│ │ LSM file_open: │ │
│ │ if PENDING_DENY[pid_tgid] → return -EACCES (blocked) │ │
│ │ │ │
│ │ sched_process_fork: child inherits cgroup automatically │ │
│ │ sched_process_exit: cleanup CHILD_PIDS │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
The eBPF program now checks three levels of identity, from strongest to weakest:
1. CGROUP ID (Phase 3) ← cannot be spoofed, kernel-enforced
bpf_get_current_cgroup_id() → lookup in WATCHED_CGROUPS map
2. TGID / Child PID (Phase 2) ← tracks process tree
WATCHED_TGIDS map + CHILD_PIDS map
3. COMM NAME (Phase 1) ← fallback, can be spoofed
bpf_get_current_comm() → lookup in WATCHED_COMMS map
If any tier matches, the process is monitored. The same pattern applies for enforcement checking — if the cgroup, TGID, or comm is in the enforcement maps, policy evaluation runs in-kernel and can block access.
The Guardian daemon listens on a Unix domain socket (default: /run/guardian.sock). Messages use a length-prefixed JSON format:
┌──────────┬──────────────────────────────────┐
│ 4 bytes │ N bytes │
│ (u32 BE) │ (JSON payload) │
│ length │ │
└──────────┴──────────────────────────────────┘
Register — sent by guardian-launch when starting an agent:
{
"type": "register",
"cgroup_path": "guardian/aider-1234",
"cgroup_id": 789456,
"agent_name": "aider"
}ListAgents — sent by guardian-ctl list:
{ "type": "list" }StopAgent — sent by guardian-ctl stop:
{ "type": "stop", "agent_name": "aider" }GrantAccess — sent by guardian-ctl grant:
{
"type": "grant",
"agent_name": "aider",
"path": "/home/user/.aws/**",
"duration_secs": 300
}{ "type": "ack" }
{ "type": "error", "message": "No agent config found for 'unknown'" }
{
"type": "agents",
"agents": [
{
"name": "aider",
"cgroup_path": "guardian/aider-1234",
"cgroup_id": 789456,
"num_processes": 3,
"uptime_secs": 1847
}
]
}When you run:
sudo guardian-launch --name aider --memory 4G --pids 200 -- python3 -m aiderHere is what happens internally:
mkdir /sys/fs/cgroup/guardian/ ← base directory (if not exists)
mkdir /sys/fs/cgroup/guardian/aider-54321/ ← unique per launch (name + PID)
echo "+memory +pids +cpu" > /sys/fs/cgroup/guardian/cgroup.subtree_control
This enables memory, PID, and CPU controllers for child cgroups. Best-effort — if a controller is not available, it's silently skipped.
echo "4G" > /sys/fs/cgroup/guardian/aider-54321/memory.max
echo "200" > /sys/fs/cgroup/guardian/aider-54321/pids.max
The cgroup ID is the inode number of the cgroup directory. This is what bpf_get_current_cgroup_id() returns in the kernel:
let metadata = std::fs::metadata("/sys/fs/cgroup/guardian/aider-54321")?;
let cgroup_id = metadata.ino(); // e.g., 789456Connect to /run/guardian.sock and send a registration message. The daemon:
- Finds the matching agent config in
config.toml - Inserts
cgroup_idinto theWATCHED_CGROUPSBPF map - If enforce mode: also inserts into
ENFORCE_CGROUPSandCGROUP_DEFAULT_ACTION - Stores the registration in memory for lifecycle management
- Responds with
ACK
echo $$ > /sys/fs/cgroup/guardian/aider-54321/cgroup.procs
The launcher process is now in the cgroup.
Command::new("python3").args(&["-m", "aider"]).exec();exec() replaces the launcher process with the agent. The agent inherits:
- The cgroup (kernel-enforced, cannot escape)
- All resource limits
- Guardian monitoring via the WATCHED_CGROUPS BPF map
Every child process the agent spawns (bash, git, curl, etc.) automatically inherits the same cgroup. No process can leave the cgroup without root privileges.
The daemon runs a background task every 5 seconds that:
-
Checks for empty cgroups: reads
/sys/fs/cgroup/guardian/<name>/cgroup.procs. If empty (all processes exited), the agent is cleaned up:- Removes cgroup ID from
WATCHED_CGROUPS,ENFORCE_CGROUPS,CGROUP_DEFAULT_ACTIONBPF maps - Removes the cgroup directory
- Logs:
Agent 'aider' cleaned up (uptime=1847s)
- Removes cgroup ID from
-
Expires temporary grants: checks each grant's expiry time. When expired:
- Removes the allow rule from
ALLOW_EXACTorALLOW_PREFIXESBPF maps - Logs:
Temporary grant expired: agent='aider' path='/home/user/.aws/**'
- Removes the allow rule from
When guardian-ctl stop --name aider is run:
- Reads PIDs from
/sys/fs/cgroup/guardian/aider-54321/cgroup.procs - Sends
SIGTERMto each process - Cleans up BPF maps and cgroup directory immediately
Sometimes an agent needs temporary access to a sensitive resource — for example, reading AWS credentials during a deployment.
- Grant request arrives via IPC (from
guardian-ctl grant) - Daemon adds the path to the
ALLOW_EXACTorALLOW_PREFIXESBPF maps - Daemon stores the grant with an expiry timestamp
- Agent can now access the previously-denied path
- After the duration, the cleanup task removes the rule from BPF maps
- Access is blocked again automatically
# Agent needs to deploy using AWS credentials for 5 minutes
sudo guardian-ctl grant --name claude-code --path "/home/user/.aws/**" --duration 300
# After 300 seconds, access is automatically revoked
# Daemon logs: "Temporary grant expired: agent='claude-code' path='/home/user/.aws/**'"The grant modifies the kernel-side BPF maps directly, so enforcement takes effect immediately — no round-trip to userspace needed for each file access.
[global]
log_level = "info"
mode = "enforce"
pid_rescan_interval = 5
socket_path = "/run/guardian.sock" # ← NEW: IPC socket path
# Cgroup-based agent (Phase 3 — recommended)
[[agents]]
name = "aider"
identity = "cgroup" # ← NEW: "cgroup" or "comm" (default: "comm")
[agents.file_access]
default = "deny"
allow = ["/tmp/**"]
deny = ["/etc/shadow"]
[agents.resources] # ← NEW: resource limits for cgroup agents
memory_max = "4G"
pids_max = 200
cpu_max = "200000 100000" # 2 CPU cores
# Comm-based agent (Phase 1/2 — still works)
[[agents]]
name = "claude-code"
process_name = "claude" # identity defaults to "comm" when process_name is set
[agents.file_access]
default = "deny"
allow = ["/tmp/**"]
deny = ["/etc/shadow"]| Config Pattern | Identity Method |
|---|---|
process_name = "claude" (no identity field) |
comm — works exactly like Phase 1/2 |
identity = "comm", process_name = "claude" |
comm — explicit, same behavior |
identity = "cgroup" (no process_name) |
cgroup — requires guardian-launch |
Existing Phase 1/2 configs work without any changes. The identity field and resources section are optional.
| Decision | Rationale |
|---|---|
| Cgroup ID = inode number | bpf_get_current_cgroup_id() returns the cgroup directory's inode number. We read the same value via stat() in userspace. Guaranteed to match. |
| Length-prefixed JSON IPC | Simple, debuggable, no external dependencies. Works well for our low-frequency request/response pattern. |
| Launcher exec()'s the agent | The launcher replaces itself with the agent process. No wrapper process overhead. The agent IS the process in the cgroup. |
| Cgroup name = agent-PID | Unique per launch. Multiple instances of the same agent get separate cgroups with separate monitoring. |
| Controllers enabled best-effort | Not all kernels have all cgroup controllers enabled. Failing to enable a controller logs a warning but doesn't block the launch. |
| Temporary grants in BPF maps | Grants are added directly to the kernel-side allow maps, so enforcement/evaluation happens at kernel speed with no userspace round-trip. |
| 5-second cleanup interval | Balances responsiveness with overhead. Empty cgroups are detected within 5 seconds. Grants expire within 5 seconds of their deadline. |
| Separate guardian-ctl binary | Keeps the launcher focused on one job (launch). Management operations (list, stop, grant) are separate concerns with different argument patterns. |
| Feature | Phase 1 | Phase 2 | Phase 3 |
|---|---|---|---|
| Enforcement | Monitor only (log) | Kernel blocks access (LSM) | Kernel blocks access (LSM) |
| Identity | Process name (comm) | Process name + PID tree | Cgroup (unspoofable) |
| Child tracking | None | Fork-based (sched_process_fork) |
Cgroup inheritance (automatic) |
| Spoofing | Vulnerable | Partially resistant | Immune |
| Multi-agent | Only if different names | Better with PID trees | Perfect isolation |
| Setup | Edit config, find name | Edit config, find name | guardian-launch one command |
| Resource limits | None | None | Memory, CPU, PID limits |
| Cleanup | Manual | Process exit hooks | Automatic cgroup cleanup |
| Agent management | Manual | Manual | CLI: list, stop, grant |
| Temporary access | Not supported | Not supported | Time-based grants |