Metadata-Version: 2.4
Name: blocks-faraday
Version: 0.1.0
Requires-Dist: pytest>=8 ; extra == 'test'
Requires-Dist: pytest-cov>=5 ; extra == 'test'
Provides-Extra: test
Summary: Coding Agent hooks at OS level for preventing unwanted actions
Author: blocks
License: Apache-2.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM

# 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:

```bash
# 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:

```bash
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.

```bash
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.

```bash
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):**

```bash
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:

```toml
# 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"]
```

```bash
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.):

```toml
# 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
```

```bash
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:

```toml
# 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"]
```

```bash
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.

```bash
# 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:

```bash
$ 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`:

```json
{"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:

```bash
# 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

```bash
# 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.

```toml
[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:

```toml
[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

