agentjail

September 3, 2026 · View on GitHub

agentjail is a policy-guardrail layer for AI coding agents (Claude Code, Codex CLI, Cursor). It intercepts every tool call before it executes and evaluates it against OPA-based Rego policies, returning an allow, deny, or ask decision to the agent. No proxy, no wrapper binary, no dynamic-library injection — just hooks and a warm policy daemon.


How It Works

Every major coding agent ships a hook system that fires a command before each tool call — before a file is written, before a shell command runs, before an MCP server is contacted. agentjail installs a hook (agentjail-hook) that forwards the tool-call payload over a Unix socket to a persistent background daemon (agentjail-daemon). The daemon holds the OPA engine warm and evaluates Rego rules in under 5 ms.

coding agent (Claude Code / Codex / Cursor)
  |
  | fires hook on every tool use (PreToolUse / beforeShellExecution)

agentjail-hook  (tiny binary, ~1 ms overhead)
  |
  | forwards JSON payload over Unix socket

agentjail-daemon  (persistent process, holds OPA engine warm)
  |
  | evaluates Rego rules against the input

allow / deny / ask  →  returned to the coding agent

The daemon is started on login (launchd plist on macOS, systemd user service on Linux) so OPA cold-start cost (~50 ms) is incurred once at startup. Per-decision latency target is <5 ms.

Hook wire format (Claude Code example)

// stdin to agentjail-hook
{
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "rm -rf /tmp/foo" },
  "session_id": "...",
  "cwd": "/Users/dev/project"
}

// stdout from agentjail-hook
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "rm -rf on paths outside project dir is blocked by policy"
  }
}

Exit code 2 + stderr blocks immediately without requiring JSON output.

Fail-open guarantee

The hook never blocks a coding agent when agentjail-daemon is unreachable. agentjail-hook connects to the daemon's Unix socket with a 30ms timeout; if the socket is missing, the daemon isn't running, or it doesn't respond in time, the tool call is allowed. This keeps a daemon crash or restart from freezing every agent on the box. The OS-level shield is independent of this path and keeps enforcing filesystem/network restrictions regardless of daemon state, so a fail-open hook decision doesn't relax sandbox enforcement. Every fail-open event is captured via telemetry so degraded daemon availability is visible rather than silent. This guarantee does not extend to credential paths: the shield's OS-level deny on sensitive paths (~/.ssh, ~/.aws, etc.) is enforced by the sandbox itself, not the hook, so it holds even when the daemon is down.


Hook Integration per Platform

PlatformHook eventConfig fileWhat is intercepted
Claude CodePreToolUse~/.claude/settings.jsonBash, Write, Edit, Read, all MCP tools
Codex CLISessionStart + PreToolUse + PermissionRequest + PostToolUse + Stop~/.codex/hooks.jsonLifecycle attestation; Bash, apply_patch, MCP tools
CursorbeforeShellExecution + beforeMCPExecution + beforeReadFile~/.cursor/hooks.jsonShell, MCP, and agent file reads

The hook is configured in a file the agent reads at startup. Because the agent runs inside the hook framework, it cannot remove the hook from within itself. The policy binary runs in the host shell, not in the agent's process.

Codex native approval bridge

Codex's PreToolUse hook cannot create a native approval prompt. For a canonical AgentJail ask on a Codex Bash call, the daemon therefore mints an in-memory, one-use approval challenge and the hook rewrites only that tool input to AgentJail's managed approval broker. Codex's exact managed execpolicy rule then creates its native prompt. PermissionRequest binds the challenge to the same session, turn, and working directory and records a fresh process-start boundary; it deliberately leaves the native decision to Codex.

On approval, the broker asks the daemon to redeem the challenge. Redemption is one-use and requires the active Codex session plus a verifiable, fresh descendant process chain from the recorded agent process. The daemon returns the original command and working directory only after those checks; the broker execs that exact command. Cancel, expiry, replay, a later tool-call epoch, a missing managed rule, --ignore-rules, approval_policy=never, a daemon restart, or any unverifiable process topology fails closed. The standard hook availability posture remains fail-open for ordinary policy evaluation. Approval-capable Codex PreToolUse requests are the exception: they deny when the daemon response is unavailable because the hook cannot yet distinguish a canonical allow from an approval-requiring ask. A broker invocation likewise cannot execute while its authorization service is unavailable.

The broker command contains an opaque challenge rather than the original shell text. AgentJail uses Codex's supported systemMessage field to show the redacted effective shell command immediately before the native prompt, while the fixed command inside Codex's prompt identifies only the shell-command approval operation. The transport follows every effective Codex Bash ask, including user-authored custom rules, rather than enumerating policy rule IDs. Non-Bash Codex ask decisions retain their fail-closed adapter behavior because Codex cannot initiate an equivalent prompt for those tools. The normal redacted PreToolUse decision record remains unchanged; the bridge adds no original command text to its audit records or structured logs. The opaque, one-use challenge is necessarily visible in the short-lived broker argv and its daemon socket request so concurrent prompts can be correlated; it is never logged or audited and cannot redeem without the bound session, epoch, and fresh process-topology checks. Same-user process-list exposure is therefore a bounded limitation, not an authorization channel. See ADR 0118-codex-approval-broker and ADR 0119-command-approval-transport.


Policy Model

Policies live under agentpolicy/policies/ (mirrored into the binary at cmd/agentjail/policies/) and are written in Rego.

Candidate → resolver → decision

Every policy file contributes candidate entries to the partial set data.agentjail.candidate. resolver.rego is the sole producer of data.agentjail.decision: it derives an effective_candidate set and picks the most restrictive (deny > ask > allow; lowest rule_id breaks ties). When nothing fires, the default is ask (fail-safe, not silent allow). Every rule has a namespaced rule_id (file_policy/…, command_policy/…, mcp_policy/…, library/…, custom/<name>/…).

For Bash calls, the daemon parses executable invocations before OPA evaluation and supplies both binary names and typed command intents. Git remote-update policy classifies the subcommand after documented global options such as -C and -c; it does not scan the whole shell string for adjacent words. This distinguishes an executed operation from inert arguments in commands such as source searches. See ADR 0118-codex-approval-broker.

Config overlay (ADR 0012)

The daemon loads ~/.agentjail/policy.yaml, merges it over built-in defaults, and injects it into OPA as data.agentjail.config (re-injected on reload — a control message over the daemon socket, with SIGHUP retained as a fallback; see ADR 0052 — decision cache invalidated). Rego reads config from there — e.g. data.agentjail.config.mcp.allowed, .file.temp_roots, .disabled_rules. Request paths and cwd are canonicalized (symlinks/.. resolved) at ingest, so policies always see real absolute paths; cwd is part of the decision-cache key.

file_policy.rego — sensitive path enforcement (ADR 0013)

Two tiers:

  • is_protected_credential → hard deny everywhere (regardless of cwd): ~/.ssh, ~/.aws, ~/.gnupg, ~/.config, ~/Downloads, ~/Desktop, ~/.npmrc/~/.pypirc/~/.git-credentials/~/.docker/config.json/~/.kube/config/~/.cargo/credentials, ~/Library/Keychains, /etc, and non-temp /var.
  • is_sensitive_basename (.env*, credentials*, secrets*, *.pem/.key/…, id_rsa-family) → ask when inside the granted project dir, deny outside. The .env predicate is op-aware: reads use the broad .env* match above unchanged, writes/edits use a narrower secret-form deny-list only (.env, .env.local, .env.production, ...) so non-secret templates like .env.example are writable - see ADR 0057.

The temp tree ($TMPDIR, /tmp, /var/folders/…) is allowed. Project membership is boundary-safe (p == cwd OR startswith(p, cwd + "/")), so a sibling like /proj2 doesn't match cwd=/proj. Writes to ~/.agentjail/ get their own locked file_policy/agentjail_self deny (self-protection).

mcp_policy.rego — MCP server allowlist

Allowlist by server name (glob). At install, agentjail seeds the allowlist from the MCP servers already configured in Claude/Codex/Cursor (trust-on-install), so existing setups keep working; the default blocklist (*stripe*, *payment*, …) always takes precedence. Manage with agentjail mcp allow/block/listallow/block mutate policy, so they require an interactive-terminal confirmation (an agent can't self-approve a server).

# ~/.agentjail/policy.yaml
mcp:
  allowed: ["claude-mem", "context7", "github*"]
  blocked: ["*stripe*", "*payment*"]

web_policy.rego — web read tools (WebSearch / WebFetch)

Coding agents route their read-only web tools through the hook. Without a rule these hit resolver/defaultask, so every search/fetch prompts the user (and the agent host's per-domain "don't ask again" can't suppress an agentjail ask). So agentjail governs them explicitly: WebSearch is always allowed (a query to the harness's search backend, no arbitrary endpoint), and WebFetch is allowed by default (read-only GET) unless its target host matches a configurable blocklist:

# ~/.agentjail/policy.yaml
web:
  blocked: ["*tracking*", "*.internal", "169.254.*"]   # host globs; default []

Host globs match case-insensitively and * spans dots. This is domain control, not exfil-proofing — a determined prompt-injected agent could pick an unlisted host; the bigger exfil vector (Bash curl/POST) stays governed by command_policy. Users who want WebFetch to prompt again can add web_policy/fetch to disabled_rules (it falls back to the default ask).

command_policy.rego — dangerous shell patterns

Block or prompt before high-risk patterns: rm -rf outside the project, curl … | bash, chmod -R 777, sudo, dd, > /dev/disk*, ssh-keygen, gpg --export-secret-keys, env | curl exfil, and more; ask on package publish. git force-push is branch-aware: force-pushing the default branch (main/master) is denied, force-pushing a topic/feature branch is allowed (normal rebase / PR-update flow), and a bare git push -f (implicit current branch) asks. An always-on, locked command_policy/no-policy-mutation rule blocks an agent from running agentjail policy disable/mcp or writing into ~/.agentjail/. The mutation guard uses specific subcommand patterns (e.g. agentjail\s+update\b) rather than broad keyword matching, to avoid false positives when "agentjail" or "update" appears as a path component or in prompt text. The sensitive-path rule likewise ignores only static, non-expanding git commit -m message text; command substitutions, other arguments, chained commands, and remote shell payloads remain subject to the deny.

Tuning, disabling, and custom rules (ADR 0014)

  • Disable any rule by adding its rule_id (or a policy/* glob) to disabled_rules in policy.yaml, or via agentjail policy disable <rule_id>. resolver.rego drops disabled candidates from effective_candidate.
  • Locked self-protection set — a hardcoded constant in resolver.rego (file_policy/agentjail_self, command_policy/no-policy-mutation, resolver/*) can never be suppressed by disabled_rules, so no policy.yaml edit unlocks it. The CLI also requires --force + an interactive TTY confirm to disable a core rule, and logs mutations to ~/.agentjail/audit.log. library/no-daemon-kill is on by default but disableable with --force — the daemon runs under launchd/systemd with KeepAlive=true, so a kill is a speed bump, not a permanent disable.
  • Custom rulesagentjail policy add <file.rego> parses the module AST and accepts only partial candidate entries in package agentjail; resolver helpers and decision are not extensible. It validates reserved custom/<name>/<rule> ids and compiles the full bundle before installation. The daemon repeats the extension-surface validation during deterministic quarantine, so a manually dropped unsafe module is skipped rather than weakening the baseline.

See ADRs 0012, 0013, and 0014 for the decisions behind these.

Self-protection model (ADR 0025)

agentjail's self-protection uses a layered enforcement model inspired by EDR architecture — enforcement at the point of effect, not the point of intent:

  • Regex rules (Tier 1) are the UX/signals layer: they produce clear deny messages ("you are trying to disable agentjail policy") and catch obvious attempts early. They are defense-in-depth, not the primary guarantee. Write/Edit to hook config files (~/.claude/settings.json, etc.) produce ask (user can approve legitimate edits); Bash-based writes deny.
  • Shield (Tier 1.5) is the enforcement layer: Seatbelt/Landlock deny writes to ~/.agentjail/ at the kernel level, regardless of how the write is attempted (python -c, node -e, eval, etc.).
  • Tier 2 (microVM) makes self-protection structural: the agent runs in a VM, agentjail runs on the host, and the attack surface doesn't exist.

See ADR 0025 for the full mutation surface analysis and design rationale.


OS-native Sandbox (agentjail-shield)

The hook layer is cooperative — the agent must call the hook, and the hook must pattern-match the command. Shell tricks like variable expansion, eval, or non-shell interpreters (python -c, osascript) can bypass hook-level protection. agentjail-shield closes this gap by wrapping the agent in the operating system's kernel sandbox before exec'ing it. Every subprocess inherits the restrictions.

agentjail-shield

  ├─ [macOS]  generates Seatbelt sbpl profile → sandbox-exec -p <profile> <agent>
  ├─ [Linux]  landlock_create_ruleset + landlock_restrict_self → execve <agent>
  └─ [other]  warning → exec <agent> (fail-open; hook still active)

macOS (Seatbelt): deny-list based. Denies writes to sensitive paths (~/.ssh, ~/.aws, ~/.gnupg, etc.), denies reads of credential paths, and restricts network egress. ~/Library/Keychains (user keychain root) is granted read+write by default so Claude Code's login/token-refresh flow works — see ADR 0037; the hook layer still denies an agent's own direct reads of that path. When agentjail-netproxy is enabled (opt-in via --netproxy; OFF by default in the interim -- ADR 0046), the agent is restricted to localhost-only outbound TCP and all HTTPS traffic flows through the proxy, which enforces the effective allowlist from policy.yaml. By default (no --netproxy) egress is port-only (80/443, no per-host filtering) until the transparent tunnel (planned) supersedes the proxy. The effective allowlist is three tiers -- non-removable essentials (provider auth + mcp-proxy.anthropic.com), hosts auto-derived from the MCP servers you allow (mcp.allowed), then your editable network.allowed_hosts -- computed by EffectiveAllowedHosts(). A malformed policy.yaml or a failed netproxy make the shield refuse to launch (fail loud / fail closed) rather than silently weaken enforcement. See ADR 0040 and ADR 0041; for the end-to-end walkthrough see docs/FLOW.md.

Linux (Landlock): allowlist-based. Grants read-write to /tmp and the project CWD, read-only to system directories and $HOME, and denies everything else. Sensitive subdirectories (~/.ssh, ~/.aws, ~/.gnupg) are never allowlisted. ~/.agentjail -- agentjail's own enforcement state -- is granted read-only, so policy.yaml, the SQLite DB, and trusted.yaml stay unwritable and the agent cannot disable its own guardrail. The sandboxed hook still reaches daemon.sock with no write grant of any kind: Landlock is a filesystem LSM and does not mediate AF_UNIX connect() at all (measured -- ADR 0067-control-plane-token-auth, addendum 1). ~/.agentjail/run/daemon-ctl.sock (the grant control socket, ADR 0047) is deliberately excluded from the write grant -- the agent can file grant requests through daemon.sock but cannot approve them.

No special privileges required. Both sandbox-exec and Landlock run as the invoking user — no sudo, no entitlement, no kernel module.

Environment hardening (before exec):

  • Strips ambient credentials from the agent's env (configurable blocklist)
  • Audits for root, readable credential files, IMDS reachability
  • agentjail-secrets broker issues scoped, short-lived credentials via grant/revoke over Unix socket (AES-256-GCM at rest)

For the full user guide, see docs/SANDBOX.md. For the decision record, see ADR 0001.


Decision Store (SQLite)

Every policy decision is persisted to ~/.agentjail/agentjail.db (SQLite, WAL mode). The daemon writes; the CLI, UI, and replay tools read via ReadOnlyStore (a separate read-only connection that cannot write even if type-asserted).

agentjail-daemon (writer)
  │  RecordDecision / RecordAuditEvent

agentjail.db  (WAL mode, concurrent readers OK)

  │  ListDecisions / CountActionsBySession / ListSessions
agentjail logs / replay / ui  (readers via OpenReadOnly)

Schema highlights:

  • decisions table: id, ts, session_id, tool_name, action, rule_id, reason, summary, tool_input_redacted, elapsed_us, cwd, agent
  • sessions table: session_id, agent, start_ts, end_ts, decision_count
  • audit_events table: policy enable/disable/reload mutations
  • Indexes on (session_id, ts), ts, action, tool_name, rule_id
  • Automatic retention cleanup via Cleanup(maxAge)
  • Tool input redaction at write time: secret-bearing keys are stripped, and recognised secret values (bearer tokens, provider keys, PEM blocks, URL passwords) are stripped wherever they appear — including inside a positional value such as a Bash command string (ADR 0019, ADR 0084-redact-secret-values)

Filter support: store.Filter supports SessionID (substring), Actions (case-insensitive OR), Tool (exact), Rule (case-insensitive substring), AfterID (keyset pagination, direction-aware for ASC/DESC), and Limit (clamped to [100, 10000]).

Unified Audit Log

The audit_log table captures every significant event across daemon, CLI, shield, and secrets — policy mutations, session lifecycle, shield activation, credential grants/revokes, config reloads, and more. It replaces the scattered audit_events table and flat-file audit.log with a single queryable source.

Components emit audit events through the AuditEmitter interface (internal/audit/), which decouples event production from storage. Event type constants live in internal/audit/audit.go. Three durability classes govern how events are written:

  • Fail-closed: policy mutations (policy.change_requested / policy.changed) — audit must succeed before config.Save() proceeds.
  • Transactional: emitted in the same SQLite transaction as the primary data write (e.g. session upsert, tool registration). Audit failure is logged but does not roll back the primary write.
  • Best-effort: lifecycle events (daemon startup, shield activation). Fire- and-forget, never blocks the hot path.

Decisions are NOT duplicated into audit_log — they remain in the decisions table, which is optimized for the per-decision hot path. A unified query layer provides combined chronological views when needed.

The Detail column is redacted at the store boundary using the same key-pattern matcher and value-pattern sweep as RedactToolInput, with a 4096-byte cap. Credential values are never stored — only fingerprints (ADR 0032). The shield opens the database before sandbox activation so pre-opened file descriptors survive Landlock/Seatbelt restrictions.

See ADR 0033 for the full decision record.


Cost Analytics

The daemon maintains a typed cost index in the existing AgentJail SQLite store. It runs once after daemon readiness and then at each next local calendar midnight. Durable per-file checkpoints make missed invocations harmless and let later runs read only appended, newline-complete Claude Code and Codex JSONL records. OpenCode remains an external typed SQLite source. agentjail cost and the local UI use the singleton store's read-only interface and never scan raw transcripts on a request. See ADR 0142-incremental-cost-index.

The index retains only source/session identity, project/model attribution, timestamps, token and request-pricing dimensions, fork lineage, and recorded or computed cost—never conversation or tool-result content. Normalized lineage facts outlive the 90-day report window because a new Codex fork can inherit an older parent's cumulative history. Report rows are grouped by UTC session-start day so existing whole-session --period semantics remain stable.

The shared per-model summary exposes uncached input, cache-read, cache-write, and output totals. Pricing includes every category, so CLI and UI consumers do not present output volume as if it were the complete basis for model cost. Claude's five-minute and one-hour cache-write totals remain distinct through the typed report contract. Codex cumulative totals provide displayed usage, while complete last_token_usage records drive request-level price tiers; an incomplete request sequence is explicitly marked as a base-rate estimate. Codex usage deltas retain the active model, and forked transcripts remove ancestor events by their cumulative usage identity before aggregation.

Model costs are computed offline through Gryph's bundled model-resolution and pricing provider, with source-verified supplemental rates for models newer than Gryph's latest release (ADR 0120-bundled-model-pricing and ADR 0121-current-model-pricing and ADR 0123-supplemental-model-pricing). AgentJail sends no transcript or usage data to Gryph. Supplemental pricing semantics overlay a resolved Gryph base rate so a catalog update cannot silently remove TTL or long-context rules. OpenCode's own non-zero recorded cost takes precedence. Pricing revisions rebuild the derived projection from retained typed facts; request-time reporting reads that projection without source I/O. Missing optional sources are ignored; malformed available sources are reported as warnings without affecting enforcement. Oversized JSONL content records are discarded individually within a fixed memory bound so later usage records in the same transcript remain readable (ADR 0122-transcript-record-recovery). Token-bearing models absent from both offline catalogs produce an explicit pricing-unavailable warning.

Budget alerts come from the global cost section of policy.yaml:

cost:
  daily_budget: 25
  alert_threshold: 0.8
  project_budgets:
    "~/Repos/production-api": 10

These values are reporting settings, not OPA policy data. Trusted project overlays cannot change them. The shared report contract powers the CLI and GET /api/cost/summary?period=7d&project=<dir>.


Local UI

agentjail ui starts a loopback-only HTTP server backed by the SQLite store.

  • /api/state — sessions, counters (global), recent events (filtered)
  • /api/session?id=<id> — chronological session replay with filters
  • /api/audit — policy-mutation audit log
  • Server-side filter query params: action, tool, rule, limit
  • Counters (total_allow/deny/ask) are always global; only recent_events and session replay rows are filtered
  • FilteredCount and TotalDecisions in response for "showing N of M"
  • Frontend sends filters with 300ms debounce; SSE live events remain client-filtered
  • --edit-policy opt-in enables policy enable/disable controls (read-only by default)

Isolation Tiers

agentjail is designed across three levels of isolation strength. They are not mutually exclusive — stronger tiers can layer on top of lighter ones.

Tier 1 — Hooks (lightest isolation)

The agent runs normally on the host. agentjail intercepts at the agent's own tool-call boundary using the platform hook system described above. No changes to the host OS, no container, no kernel module required.

Characteristics:

  • Zero friction to install; hooks are a first-class feature of all supported agents.
  • Policy decisions happen in user space, in the host shell.
  • An agent that cooperates with its hook framework cannot bypass this layer from within itself.
  • Does not protect against agents that have been modified to skip hook dispatch entirely.

Tier 2 — Container / MicroVM (stronger isolation)

The agent runs inside a microVM. The proposed substrate is Microsandbox (built on libkrun) for the developer-laptop path — macOS (HVF), Linux (KVM), and Windows (WSL2) — with Firecracker retained as the server-fleet backend. The VM boundary enforces egress from network.allowed_hosts and keeps credentials out of the guest; the Tier 1 hook + daemon run inside the VM unchanged. The same OPA policy engine governs both sides of the boundary.

What it adds over Tier 1:

  • Hard containment: an agent that attempts to ignore hooks is physically prevented from reaching the host filesystem or network.
  • Works for agents that do not support hooks at all.
  • Stronger audit trail: every syscall crossing the boundary is logged, not just declared tool calls.

Substrate selection, the two-backend split, and the long-term pros/cons are decided in ADR 0016 (status: Proposed). The libkrun and Firecracker spikes live under agentjail/research/.

Configured host connectors across the boundary

Runtime approval for a configured host connector is not host reachability. The tree contains fixed-destination probe, synthetic ConnectorID route, verified launch capability, and Linux private AF_UNIX transport primitives, but the runtime MCP hook does not activate them. Its allow response cannot safely bind a raw route or an existing tunnel to grant consumption, expiry, or revocation; production connector use therefore fails closed pending a grant-aware MCP data plane. There is no wildcard listener, source-IP trust, guest-selected TCP destination, or guest-loopback shortcut.

The current tree has no production container or microVM launcher. In particular, the Firecracker/libkrun code is research-only and provides neither vsock nor a shared socket registration seam; doctor reports microVM guest transport as unavailable and the Firecracker fixture exits unavailable. The same-host netproxy primitive is likewise not exposed through runtime MCP grants; no macOS VM/container guest transport is claimed. See ADR 0141-runtime-grants.

Tier 3 — Kernel Module (strongest isolation)

A kernel module (eBPF LSM on Linux, macOS SystemExtension) intercepts all file, network, and process events system-wide, regardless of whether the agent runs in a container or directly on the host.

What it adds over Tier 2:

  • Covers any process on the machine, not only agents that agentjail spawned.
  • No agent cooperation required: works even if the agent binary is replaced or modified.
  • Suitable for fleet-wide deployment where every machine needs a consistent enforcement boundary.

Setup

# One-time setup
agentjail install --for claude-code
# → writes hook entry to ~/.claude/settings.json
# → starts agentjail-daemon as a launchd service
# → writes default ~/.agentjail/policy.yaml

# Then use your agent normally
claude  # every tool call is now policy-checked