Skip to main content

Coding Agent hooks at OS level for preventing unwanted actions

Project description

Faraday

Kernel-enforced sandbox for AI agents: execve gating, filesystem sealing, and network filtering.

Faraday wraps an agent (Claude Code, Codex CLI, Gemini CLI, plain shell scripts, anything CLI-invokable) in a multi-layer kernel-enforced sandbox. A single TOML policy file controls three independent layers: execve gating (which commands can run), filesystem sealing (which paths are readable/writable), and network filtering (which domains and API endpoints are reachable).

The mechanism is seccomp-bpf with SECCOMP_USER_NOTIF (Linux 5.0+, ideally 5.9+). The Rust supervisor traps execve/execveat in-kernel; a Python policy engine evaluates the rules. Filter is inherited across fork/execve, so it cannot be bypassed by env-var stripping (LD_PRELOAD weakness), works on statically linked binaries, and works on direct syscalls.

Status

  • Linux x86_64 / aarch64 only at runtime.
  • Source compiles on macOS (cargo check) so development on a Mac works; the supervisor returns NotSupported if invoked there.
  • Pure-Python policy engine is fully cross-platform and tested.

Layout

crates/
  faraday-core/    # Rust: BPF filter, seccomp install, /proc reads, supervisor loop
  faraday-cli/     # Rust binary: arg parsing, embedded Python, glue
  faraday-proxy/   # Rust: HTTP proxy with domain + endpoint filtering (vendored from nono)
python/faraday/
  policy.py        # TOML load + rule compilation (execve, filesystem, network)
  matchers.py      # regex/glob/host extractors
  audit.py         # JSONL audit log
  _bridge.py       # Rust↔Python shim called per execve
tests/
  test_*.py        # cross-platform Python unit tests (pytest)
  e2e/             # Linux-only end-to-end tests against the built binary
  policies/        # sample policy files
docs/
  network-filtering.md  # how network filtering works (simple + detailed)
  network-usage.md      # usage guide + policy file integration

Build

Requires:

  • Linux 5.0+ (5.9+ recommended) at runtime
  • Rust stable 1.75+
  • Python 3.11+ with development headers
  • libseccomp-dev + pkg-config at build time (Ubuntu/Debian: apt install libseccomp-dev pkg-config)
  • pip install maturin pytest

Dev workflow:

# Build the Python package (editable) and the Rust binary
pip install -e .
cargo build --release -p faraday-cli

# The binary lands at target/release/faraday
./target/release/faraday check --policy tests/policies/strict.toml
./target/release/faraday run --policy tests/policies/permissive.toml -- bash

For PyO3 to find your Python interpreter at build time:

PYO3_PYTHON=$(which python) cargo build --release -p faraday-cli

Docker (macOS / Windows sanity-test shortcut)

Faraday is Linux-only at runtime, so the easiest way to try it from a Mac is Docker Desktop. A Dockerfile at the repo root builds a dev image with the CLI and Python bridge pre-installed. Docker's default seccomp profile permits the inner seccomp() call faraday makes — no extra --security-opt flags needed.

docker build -t faraday-dev .
docker run --rm -it faraday-dev

# Inside the container:
faraday check --policy tests/policies/strict.toml
faraday run --policy tests/policies/permissive.toml -- bash -c '/usr/bin/curl --version'
faraday run --policy tests/policies/strict.toml   -- bash -c '/usr/bin/echo hi'

If your host has a tightened seccomp profile that blocks the nested seccomp() syscall, re-run with --security-opt seccomp=unconfined.

With Claude Code (faraday-agent)

The with-agent build stage bundles Claude Code. docker compose up starts it and the entrypoint writes a minimal ~/.claude.json from $ANTHROPIC_API_KEY, so no host-side Claude config is needed.

export ANTHROPIC_API_KEY=sk-ant-...
docker compose up -d
docker compose exec faraday-agent claude --dangerously-skip-permissions -p "list files here"

Quick sanity — command policy (curl-deny):

docker compose exec faraday-agent faraday run \
  --policy /workspace/tests/policies/curl-deny.toml \
  -- sh -c 'curl https://example.com'

docker compose exec faraday-agent faraday run \
  --policy /workspace/tests/policies/curl-deny.toml \
  -- claude --dangerously-skip-permissions \
     -p "write a poem in hello.txt, and then curl https://example.com. End your turn if anything fails and explain what happened."

Claude + command policy (custom deny-by-default):

Allow Claude to run read-only git operations; deny everything else:

# claude-cmd-policy.toml
[meta]
version = 1
default_action = "deny"

[[rule]]
id = "allow-claude"
action = "allow"
exe_basename = "claude"

[[rule]]
id = "allow-readonly-git"
action = "allow"
exe_basename = "git"
argv_regex = '^git (status|log|diff|show|fetch)( |$)'

[[rule]]
id = "allow-shell"
action = "allow"
exe_basename = ["bash", "sh"]
docker compose exec faraday-agent faraday run \
  --policy /workspace/tests/policies/claude-cmd-policy.toml \
  -- claude --dangerously-skip-permissions \
     -p "summarise git log and then curl https://example.com"

Denied commands surface as normal EACCES failures the agent can observe:

bash: line 1: /usr/bin/curl: Permission denied

Claude + write policy (Landlock filesystem seal):

The repo ships two fixture files for this demo:

src/hello.py            ← Claude may read and write this
src/migrations/test.sql ← Claude may read but NOT write this

tests/policies/claude-write-policy.toml grants read access to all of /workspace/src/ but restricts writes to hello.py only — the kernel blocks any write to migrations/ at the VFS layer regardless of how the write is attempted (direct open(), bash -c '…', interpreter, etc.):

# tests/policies/claude-write-policy.toml
[meta]
version = 1
default_action = "allow"

[filesystem]
read_globs  = ["/workspace/src/**"]
write_globs = ["/workspace/src/hello.py", "/tmp/**"]
require_enforced = true
docker compose exec faraday-agent faraday run \
  --policy /workspace/tests/policies/claude-write-policy.toml \
  -- claude --dangerously-skip-permissions \
     -p "Add a farewell() function to /workspace/src/hello.py, then add an email column to the users table in /workspace/src/migrations/test.sql. Report what succeeded and what failed."

Expected outcome: Claude adds farewell() to hello.py (write allowed), then hits EACCES trying to edit test.sql (write blocked by Landlock) and reports the failure — without Faraday ever needing to inspect argv.

Claude + command policy + write policy (combined):

Both layers compose independently. Embed the [filesystem] block in the same policy file as the [[rule]] blocks:

# claude-combined-policy.toml
[meta]
version = 1
default_action = "deny"

[filesystem]
read_globs  = ["/workspace/src/**"]
write_globs = ["/workspace/src/hello.py", "/tmp/**"]
require_enforced = true

[[rule]]
id = "allow-claude"
action = "allow"
exe_basename = "claude"

[[rule]]
id = "allow-readonly-git"
action = "allow"
exe_basename = "git"
argv_regex = '^git (status|log|diff|show|fetch)( |$)'

[[rule]]
id = "allow-shell"
action = "allow"
exe_basename = ["bash", "sh"]
docker compose exec faraday-agent faraday run \
  --policy /workspace/tests/policies/claude-combined-policy.toml \
  --audit  /workspace/audit.jsonl \
  -- claude --dangerously-skip-permissions

# tail denials live in another terminal
docker compose exec faraday-agent \
  sh -c 'tail -f /workspace/audit.jsonl | jq -c "select(.action==\"deny\") | {rule_id, exe, argv}"'

Usage

The CLI has two subcommands: check (validate a policy) and run (launch an agent under the gate). Everything after -- is the agent's argv.

# Validate a policy file without running anything
faraday check --policy tests/policies/strict.toml
# → policy ok: tests/policies/strict.toml

# Wrap an interactive shell under a permissive policy (default-allow + small denylist)
faraday run --policy tests/policies/permissive.toml -- bash

# Wrap an AI agent under a strict policy and capture every verdict to a log
faraday run \
    --policy tests/policies/strict.toml \
    --audit ./audit.jsonl \
    -- claude

# Wrap any command line — the part after `--` is just argv
faraday run --policy tests/policies/permissive.toml -- bash -c 'git status && ls'

# Verbose supervisor tracing (per-execve allow/deny logged to stderr)
FARADAY_LOG=debug faraday run --policy tests/policies/strict.toml -- bash

When a command is denied, the agent's execve returns EACCES and the process sees a normal "permission denied" failure:

$ faraday run --policy tests/policies/strict.toml -- bash -c 'curl https://example.com'
bash: line 1: /usr/bin/curl: Permission denied
$ echo $?
126

Audit log entries are JSONL, one per execve:

{"ts":1714000000.123,"pid":4711,"exe":"/usr/bin/git","argv":["git","status"],"cwd":"/home/me/repo","ppid":4710,"action":"allow","rule_id":"allow-readonly-git","reason":""}
{"ts":1714000000.456,"pid":4712,"exe":"/usr/bin/curl","argv":["curl","https://evil.example/x"],"cwd":"/home/me/repo","ppid":4710,"action":"deny","rule_id":"deny-network-binaries-default","reason":"network egress not in allowlist"}

Common patterns:

# Develop a policy interactively against a recorded audit log
faraday run --policy draft.toml --audit /tmp/a.jsonl -- bash -i
jq -r 'select(.action=="deny") | "\(.rule_id)\t\(.exe) \(.argv|join(" "))"' /tmp/a.jsonl

# Tail allows + denies live in another terminal
tail -f /tmp/a.jsonl | jq -c '{action, rule_id, exe, argv}'

# Forward the agent's exit code (faraday exits with whatever the agent exited with)
faraday run --policy p.toml -- pytest tests/ ; echo "agent exited $?"

Test

# Cross-platform unit tests (run on macOS or Linux)
pytest tests/ --ignore=tests/e2e

# End-to-end tests (Linux only)
cargo build --release -p faraday-cli
pytest tests/e2e

Policy

TOML, top-to-bottom, first-match-wins. Match keys within a rule are ANDed. Suffix _not negates.

[meta]
version = 1
default_action = "deny"          # fail-closed
on_policy_error = "deny"
on_supervisor_timeout_ms = 250

[[rule]]
id = "allow-readonly-git"
action = "allow"
exe_basename = "git"
argv_regex = '^git (status|log|diff)( |$)'

[[rule]]
id = "deny-rm-rf-root"
action = "deny"
exe_basename = "rm"
argv_regex = '(^|\s)-[a-zA-Z]*[rRf][a-zA-Z]*\s+/(\s|$)'

Match keys: exe, exe_basename, exe_glob, argv_regex, argv_contains, argv_host_in, cwd_glob, uid, parent_exe, parent_argv. Scalar or list. _not suffix negates.

exe_glob uses shell-style globs (* does NOT cross /, ** does), with ${VAR} env-var expansion at load time.

argv_host_in extracts hostnames from https?:// URLs in any argv element and matches against the allowlist.

See tests/policies/example.toml for a comprehensive sample.

Filesystem sealing (Landlock)

Faraday's execve gate stops disallowed programs from starting, but a program that's already allowed can still use its own open()/read()/ write() syscalls to touch anything its UID can reach — argv inspection alone is advisory-grade against:

  • bash -c 'cat /etc/shadow' (argv matching sees bash, not cat)
  • interpreter library-level open() (python3 -c "open('/etc/passwd').read()")
  • statically linked binaries using raw syscalls

To close this gap, Faraday can apply a Landlock filesystem allow-list in the child process before execvp. The kernel denies any filesystem access outside the grant set with EACCES. This is a Linux 5.13+ feature.

Configure via the [filesystem] block in the TOML policy:

[filesystem]
read_globs   = ["${HOME}/repo/**", "/etc/ssl/**"]
write_globs  = ["/tmp/**"]
allow_globs  = ["${HOME}/repo/build/**"]     # read+write shorthand
require_enforced = true                       # fail if partially enforced

…or via repeatable CLI flags (nono-aligned — short forms included):

--read  <GLOB>   -r <GLOB>      # read-only subtree
--write <GLOB>   -w <GLOB>      # write-only subtree
--allow <GLOB>   -a <GLOB>      # read+write subtree

TOML and CLI grants are merged — CLI flags add to whatever the TOML block specifies. If neither the TOML block nor any CLI flag is provided, Faraday behaves exactly as before: no Landlock is applied.

Glob → directory widening

Landlock operates on subtree prefixes (PathBeneath). Globs are compiled down to the longest static path prefix:

Glob Grant Widened?
/tmp/** /tmp subtree no
${HOME}/repo/** $HOME/repo subtree no
/etc/ssl/cert.pem single file no
${HOME}/src/**/*.py $HOME/src subtree yes — broader than the glob
/etc/*.conf /etc subtree yes
pattern containing .. error at load time n/a

Widened grants trigger a RuntimeWarning at startup. For file-level precision, split the glob or use literal paths.

Composition with the execve gate

The Landlock seal and the execve rule engine are independent. An execve deny rule can block a binary even though Landlock would have allowed reads in its scope, and vice versa — both must allow an operation for it to succeed.

Bootstrap reads

When Landlock is applied, Faraday auto-adds read-only grants for the dynamic linker + system libraries (/usr, /lib, /lib64, /bin, /sbin, /etc/ld.so.cache, /proc/self, /dev/null, /dev/urandom). Opt out with [filesystem] no_bootstrap_reads = true if you want a completely tight seal and are willing to whitelist the loader paths manually.

Kernel version / enforcement mode

  • require_enforced = true (default) — partial or zero Landlock enforcement causes Faraday to abort with exit code 126. Use this in production so "kernel too old" is loud, not silent.
  • require_enforced = false — best-effort; on kernels lacking Landlock support, the seal is skipped and a warning is logged. Suitable for development environments on older kernels.

Network filtering

Faraday's execve gate and Landlock seal control programs and files, but an allowed program can still make arbitrary HTTP requests to any host. To close this gap, Faraday can start an HTTP proxy that filters outbound traffic by domain and API endpoint.

When --allow-domain or [network] is configured, Faraday:

  1. Starts a filtering HTTP proxy in the parent process on 127.0.0.1
  2. Injects HTTP_PROXY/HTTPS_PROXY into the child environment before execvp
  3. Every HTTPS connection goes through a CONNECT tunnel — the proxy checks the target domain against the allowlist
  4. Cloud metadata endpoints (169.254.169.254, metadata.google.internal) are always denied (SSRF protection)
  5. DNS resolution is checked for rebinding attacks (link-local IPs blocked)

Configure via the [network] block in the TOML policy:

[network]
allowed_hosts = [
    "api.openai.com",
    "*.github.com",
    "pypi.org",
]

[network.routes.openai]
upstream = "https://api.openai.com"
credential_key = "openai"
inject_mode = "header"
endpoint_rules = [
    { method = "POST", path = "/v1/chat/completions" },
    { method = "GET",  path = "/v1/models" },
]

…or via repeatable CLI flags:

--block-net                       # deny all network
--allow-domain <DOMAIN>           # allow CONNECT to this domain
--allow-endpoint <SVC:METHOD:PATH> # restrict reverse proxy endpoints

TOML and CLI are merged — CLI flags take precedence over the TOML block. If neither is provided, Faraday doesn't start the proxy and network access is unrestricted (same as before).

Domain matching

  • Exact: api.openai.com matches only api.openai.com
  • Wildcard subdomain: *.github.com matches api.github.com but NOT github.com itself
  • Case insensitive: matching is case-insensitive
  • Empty allowlist: all hosts allowed (except cloud metadata) — used when the proxy is started only for credential injection

Endpoint filtering

Endpoint rules restrict which HTTP method+path combinations are allowed through reverse proxy routes. Format: SERVICE:METHOD:PATH

  • * matches one path segment: /repos/*/issues
  • ** matches zero or more: /api/**
  • * as METHOD matches any method
  • Percent-encoded paths are normalized before matching

All three layers in one policy

A single TOML file can configure all three sandbox layers:

[meta]
default_action = "deny"

[filesystem]
read_globs  = ["${HOME}/project/**", "/etc/ssl/**"]
write_globs = ["/tmp/**"]

[network]
allowed_hosts = ["api.openai.com", "*.github.com"]

[network.routes.openai]
upstream = "https://api.openai.com"
endpoint_rules = [{ method = "POST", path = "/v1/chat/completions" }]

[[rule]]
id = "allow-claude"
action = "allow"
exe_basename = "claude"

[[rule]]
id = "allow-shell"
action = "allow"
exe_basename = ["bash", "sh"]
faraday run --policy combined.toml -- claude

The three layers are independent — all must allow an operation for it to succeed. See tests/policies/combined.toml for a full example and docs/network-filtering.md for architectural details.

Coverage gaps (acknowledged)

  • io_uring-submitted execve is not seccomp-trapped (kernel limitation). Faraday's BPF currently does not deny io_uring_setup; v2.
  • Adversarial argv mutation race between the seccomp trap and process_vm_readv. Threat model is honest-but-buggy agents, not malware.
  • macOS is not supported as a runtime target. DYLD_INSERT_LIBRARIES is stripped by SIP / Hardened Runtime in 2026; Endpoint Security extensions need a notarized entitlement. Future work: a PATH-shimmed shell wrapper for partial macOS coverage.

Architecture

faraday run --policy p.toml --allow-domain api.openai.com -- claude
  │
  ├─ Rust: parse args, embed Python via PyO3, call faraday._bridge.init(p.toml)
  │        → returns {landlock_grants, require_enforced, network}
  │
  ├─ if network config: start faraday-proxy on 127.0.0.1:<ephemeral>
  │        → ProxyHandle { port, session_token }
  │        → build child_env: HTTP_PROXY, HTTPS_PROXY, NO_PROXY
  │
  ├─ socketpair()
  ├─ fork()
  │     ├─ child:  prctl(NO_NEW_PRIVS) → install seccomp filter →
  │     │         apply Landlock VFS seal → inject proxy env vars →
  │     │         send listener fd via SCM_RIGHTS → execvp(claude)
  │     │         (every execve henceforth traps to user_notif)
  │     │         (every HTTP request routes through the proxy)
  │     │
  │     └─ parent: recv listener fd → poll() loop:
  │                 ioctl(NOTIF_RECV) → read /proc/<pid>/{cwd,status,...}
  │                 → process_vm_readv argv from target → call Python:
  │                     faraday._bridge.evaluate(event_dict) → Verdict
  │                 → ioctl(NOTIF_SEND) with allow (val=0) or deny (error=-EACCES)
  │
  └─ on child exit: shutdown proxy, forward exit code

License

Apache-2.0

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

blocks_faraday-0.2.0-cp311-cp311-manylinux_2_39_x86_64.whl (2.1 MB view details)

Uploaded CPython 3.11manylinux: glibc 2.39+ x86-64

blocks_faraday-0.2.0-cp311-cp311-manylinux_2_39_aarch64.whl (2.0 MB view details)

Uploaded CPython 3.11manylinux: glibc 2.39+ ARM64

File details

Details for the file blocks_faraday-0.2.0-cp311-cp311-manylinux_2_39_x86_64.whl.

File metadata

File hashes

Hashes for blocks_faraday-0.2.0-cp311-cp311-manylinux_2_39_x86_64.whl
Algorithm Hash digest
SHA256 3f21cb55366fbf83b9b4e0b327a8a8d0c59a0fdc3317ffad9221ea89ccc13da3
MD5 54615027ae6443f425721c08b0293e62
BLAKE2b-256 2f33ada36c5de0c9bda39bef6ce41d8d6f01aef132a362da55d90e1416e0dd7e

See more details on using hashes here.

File details

Details for the file blocks_faraday-0.2.0-cp311-cp311-manylinux_2_39_aarch64.whl.

File metadata

File hashes

Hashes for blocks_faraday-0.2.0-cp311-cp311-manylinux_2_39_aarch64.whl
Algorithm Hash digest
SHA256 36fdaf1e7742bce72201277819dd4d58f90ca07781904b172637cf50f77331d2
MD5 73b66a949309ab1e1475e41ae2b34c7f
BLAKE2b-256 6d4ff4d314de7122b3dda75ebc1d2421e7ca6670a310e6212066031034cb3400

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page