Skip to main content

Coding Agent hooks at OS level for preventing unwanted actions

Project description

Faraday

Claude-Code-style execve gating for any subprocess tree.

Faraday wraps an agent (Claude Code, Codex CLI, Gemini CLI, plain shell scripts, anything CLI-invokable) in a kernel-enforced gate. Every execve in the descendant process tree is intercepted and matched against a TOML policy file. Allow → the syscall proceeds. Deny → the syscall returns EACCES and the agent sees a normal exec failure.

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
python/faraday/
  policy.py        # TOML load + rule compilation
  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

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.

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 -- claude
  │
  ├─ Rust: parse args, embed Python via PyO3, call faraday._bridge.init(p.toml)
  │
  ├─ socketpair()
  ├─ fork()
  │     ├─ child:  prctl(NO_NEW_PRIVS) → install seccomp filter →
  │     │         send listener fd via SCM_RIGHTS → execvp(claude)
  │     │         (every execve henceforth traps to user_notif)
  │     │
  │     └─ 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: 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.1.0-cp311-cp311-manylinux_2_39_x86_64.whl (706.4 kB view details)

Uploaded CPython 3.11manylinux: glibc 2.39+ x86-64

blocks_faraday-0.1.0-cp311-cp311-manylinux_2_39_aarch64.whl (665.8 kB view details)

Uploaded CPython 3.11manylinux: glibc 2.39+ ARM64

File details

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

File metadata

File hashes

Hashes for blocks_faraday-0.1.0-cp311-cp311-manylinux_2_39_x86_64.whl
Algorithm Hash digest
SHA256 4b5a2c78830189ac5cb3d54e6634f78cd481874aa211c752912bd96aa0253214
MD5 96b32851dcfc54acedb97c3fb39e7765
BLAKE2b-256 b0b8328aa87ded2bdd1dc2c5a66a4a15fbd0a8d9cc932696ae631088e6f09ac4

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for blocks_faraday-0.1.0-cp311-cp311-manylinux_2_39_aarch64.whl
Algorithm Hash digest
SHA256 6f7f07aa73a045b86967238ee616c8a8c5e4dd6a0b1e9e63f23707feff67c1cc
MD5 ca4c08f18daac791088bbc3f8cc29680
BLAKE2b-256 13f62e6140ef3c4ab791d069c85b13fd6353b147fa82a355fcab9dfb44ba12c0

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