agent-bridle 0.7.13

The capability leash for agent tools: a ready-to-use, capability-governed tool registry for the Gilamonster agent line.
Documentation
# agent-bridle

**The capability leash for agent tools.** `agent-bridle` is the shared tool +
capability-enforcement layer for the Gilamonster agent line (newt, gilamonster,
Monty, hermes-thoon). It turns each host's hand-wired, ambient-authority tool
surface into an extensible, **capability-governed registry**.

> **the toolchain** (`git`, `cargo`, `python`, …) = the hands. **`Caveats`** =
> the leash. **bridle** = the enforcer that binds them.

> Governed by the [Steward's Charter]https://github.com/Gilamonster-Foundation/steward-charter.
> agent-bridle realizes the **`writ`** invariant (authority is borrowed, scoped,
> revocable — the `Caveats`/`Gate`). A leash *denial* is a Charter **`refusal`**:
> the draft edge in [`integrations/charter-bridle`]integrations/charter-bridle
> records each denial into the **`scar`**, so a refusal becomes metabolized memory
> rather than an ephemeral error.

Every tool declares the authority it needs as an
[`agent_mesh_protocol::Caveats`] requirement. The registry refuses to dispatch
unless `required ⊑ granted` under the meet-semilattice, and hands the tool only
the **meet** of granted-and-required — least authority by construction. The
confused-deputy gap (an LLM picking tool arguments while holding full ambient
authority) is closed **structurally**, not by prompt hygiene:

- A `ToolContext` is a **mint-token**: its fields are private and it is
  constructible *only* inside `Gate::authorize`. A `Tool` cannot run without one,
  so the only path to running a tool runs through the leash.
- Effective authority is `granted.meet(tool.required())` — provably
  non-amplifying (the lattice law is property-tested upstream).

## Thesis

A tool harness is a [confused deputy](https://en.wikipedia.org/wiki/Confused_deputy_problem):
it holds full ambient authority while taking instructions from an untrusted
source. Hardening the prompt does not fix this; it is an *architecture* problem.
`agent-bridle` makes the fix structural — attenuated capabilities, delegated
attenuation-only, with enforcement minted at a single choke point and (on Linux)
backstopped by Landlock. The tool you ship can only ever do what its leash
permits.

## Usage

```rust
use agent_bridle::registry;
use agent_bridle_core::{Caveats, CountBound, Scope};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Build the default registry (shell tool included under `--features shell`).
    let reg = registry();

    // Grant a tightly-scoped leash: may only exec `echo`, at most twice.
    let granted = Caveats {
        exec: Scope::only(["echo".to_string()]),
        max_calls: CountBound::AtMost(2),
        ..Caveats::top()
    };

    // ALLOWED: echo is in scope, budget available.
    let out = reg
        .dispatch(
            "shell",
            serde_json::json!({ "program": "echo", "args": ["hello"] }),
            &granted,
        )
        .await?;
    println!("{out}"); // -> { "exit_code": 0, "stdout": "hello\n", ... }

    // DENIED: `rm` is not in the granted `exec` scope. The leash refuses
    // before the tool ever runs — no prompt hygiene required.
    let denied = reg
        .dispatch(
            "shell",
            serde_json::json!({ "program": "rm", "args": ["-rf", "/"] }),
            &granted,
        )
        .await;
    assert!(denied.is_err());

    Ok(())
}
```

The engine spawns `echo` as an external program (resolved via `PATH`) **after**
the `exec` leash admits it at the single spawn funnel; an out-of-scope program
(`rm`) is denied there before anything runs. agent-bridle parses the command
itself and refuses dynamic constructs by design — it is the argv + safe-subset
engine (ADR 0005), not a full shell.

## Crates

| Crate | Purpose | Heavy deps |
|---|---|---|
| `agent-bridle-core` | `Tool` trait, `Registry`, `Gate`, `Caveats` re-export, `Sandbox` trait, result envelope | none beyond `anyhow`, `serde`, `serde_json`, `async-trait`, `agent-mesh-protocol` |
| `agent-bridle-tool-shell` | confined shell — argv + safe-subset engine (ADR 0005), `shell` feature | tokio |
| `agent-bridle-tool-web` | confined `web_fetch` (the `net` enforcer), `web` feature | reqwest+rustls, dom_smoothie, htmd, hickory-resolver, url, tokio |
| `agent-bridle` | facade re-exporting a ready-to-use registry | — |
| `agent-bridle-mcp` | MCP (Model Context Protocol) stdio server frontend over the registry (binary) | tokio, toml |

## MCP server frontend (`agent-bridle-mcp`)

MCP is the lingua franca of the agent line (DESIGN §4): any MCP client can drive
`agent-bridle-mcp` over stdio and call the **Caveats-confined** Rust tools. It
speaks newline-delimited JSON-RPC 2.0 and handles `initialize`, `tools/list`,
`tools/call`, and `shutdown`/`exit`.

```bash
# Build the binary (shell tool on by default).
cargo build -p agent-bridle-mcp --release
# Binary: target/release/agent-bridle-mcp  (reads/writes JSON-RPC on stdio)
```

### Wiring it into an MCP client

**hermes-thoon** (`mcp_servers:` config):

```yaml
mcp_servers:
  agent-bridle:
    command: /path/to/agent-bridle-mcp
    # The leash for this server's whole session (see "Confinement" below):
    env:
      AGENT_BRIDLE_CAVEATS: '{"fs_read":"all","fs_write":"all","exec":{"only":["git","cargo"]},"net":"all","max_calls":{"at_most":50},"valid_for_generation":"all"}'
```

**Claude Code / `claude mcp add`:**

```bash
claude mcp add agent-bridle \
  --env AGENT_BRIDLE_CAVEATS='{"fs_read":"all","fs_write":"all","exec":{"only":["git"]},"net":"all","max_calls":"unlimited","valid_for_generation":"all"}' \
  -- /path/to/agent-bridle-mcp
```

### The leash: granting Caveats

The session's granted `Caveats` are sourced in this order (first hit wins):

1. **`$AGENT_BRIDLE_CAVEATS`** — a JSON document using the `agent-mesh-protocol`
   `Caveats` serde shape.
2. **`~/.agent-bridle/config.toml`**, a `[caveats]` table (same field/enum shape
   in TOML).
3. **Default `Caveats::top()`** — *unconfined*. The server prints a prominent
   `WARNING: ... running UNCONFINED ...` to stderr in this case, because an
   unconfined leash defeats the purpose of the bridle. Always set (1) or (2) in
   production.

The serde shape matches the Rust type exactly — each string axis is either
`"all"` or `{ "only": [...] }`; `max_calls` is `"unlimited"` or
`{ "at_most": N }`:

```jsonc
// $AGENT_BRIDLE_CAVEATS — JSON
{
  "fs_read": "all",
  "fs_write": "all",
  "exec": { "only": ["echo", "git"] },   // may exec ONLY echo and git
  "net": "all",
  "max_calls": { "at_most": 20 },
  "valid_for_generation": "all"
}
```

```toml
# ~/.agent-bridle/config.toml — TOML
[caveats]
fs_read = "all"
fs_write = "all"
exec = { only = ["echo", "git"] }
net = "all"
max_calls = { at_most = 20 }
valid_for_generation = "all"
```

### Confinement example (restricting `exec`)

Grant a leash that may exec only `echo`, then watch the server enforce it
*through the MCP boundary*:

```bash
export AGENT_BRIDLE_CAVEATS='{"fs_read":"all","fs_write":"all","exec":{"only":["echo"]},"net":"all","max_calls":"unlimited","valid_for_generation":"all"}'

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"shell","arguments":{"program":"echo","args":["hi"]}}}' \
  '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"shell","arguments":{"program":"rm","args":["-rf","/"]}}}' \
  | agent-bridle-mcp
```

`echo` runs (`isError: false`, stdout `hi`). `rm` is **denied** — the leash
refuses it and the reason comes back as an MCP *tool error*, not a transport
fault:

```json
{"id":3,"jsonrpc":"2.0","result":{"content":[{"text":"denied: exec of \"rm\" is not within the granted authority","type":"text"}],"isError":true}}
```

## The `net` enforcer: `web_fetch` (`agent-bridle-tool-web`)

`web_fetch` is the tool that exercises the **`net`** axis of the leash — the
axis no other tool touches (DESIGN §7). It fetches an http(s) URL and returns
the page's main content as markdown, with the `net` Caveat enforced *before the
first request and on every redirect hop*:

1. **Host allowlist, default-deny.** The URL's host must satisfy the effective
   `net` scope (`ToolContext::check_net`).
2. **SSRF block.** The host is DNS-resolved and any private / loopback /
   link-local / unique-local address is **rejected** — `127.0.0.0/8`,
   `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`,
   `100.64.0.0/10` (CGNAT), `::1`, `fc00::/7`, `fe80::/10`, IPv4-mapped forms,
   and more — **unless** that host is explicitly named in the `net` allowlist
   (the deliberate opt-in for a test loopback or a named internal endpoint).
3. **Per-redirect re-check.** Redirects are followed *manually*: every hop's
   host is re-screened by (1) and (2). A 302 to a disallowed or private host is
   denied, never blindly followed.
4. **DNS-rebinding pin.** The connection is pinned to the exact IP that passed
   screening, so a rebind between the check and the connect cannot smuggle
   traffic elsewhere.

The TLS stack is **rustls, not OpenSSL**, so the tool is portable and builds on
Windows with no system OpenSSL. The result `{ url, final_url, status, title,
markdown }` is **untrusted data** — never spliced into a system prompt.

### Usage

```rust
use agent_bridle::registry;            // build with --features web
use agent_bridle_core::{Caveats, CountBound, Scope};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let reg = registry();

    // Confine the net axis to a single host. example.com may be reached;
    // nothing else, and no private/loopback address (it is not opted in).
    let granted = Caveats {
        net: Scope::only(["example.com".to_string()]),
        max_calls: CountBound::AtMost(5),
        ..Caveats::top()
    };

    let out = reg
        .dispatch(
            "web_fetch",
            serde_json::json!({ "url": "https://example.com/" }),
            &granted,
        )
        .await?;
    println!("{}", out["markdown"]); // extracted page content as markdown

    // DENIED: a different host is not in the `net` allowlist.
    let denied = reg
        .dispatch(
            "web_fetch",
            serde_json::json!({ "url": "https://not-allowed.test/" }),
            &granted,
        )
        .await;
    assert!(denied.is_err());

    Ok(())
}
```

### Confinement example (the `net` allowlist, through MCP)

Build the server with the web tool and grant a `net` allowlist of exactly one
host:

```bash
cargo build -p agent-bridle-mcp --features web --release

# net allowlist = only example.com. Note: a private/loopback host would ALSO be
# SSRF-blocked unless you name it here (e.g. "127.0.0.1" for a local test).
export AGENT_BRIDLE_CAVEATS='{"fs_read":"all","fs_write":"all","exec":"all","net":{"only":["example.com"]},"max_calls":"unlimited","valid_for_generation":"all"}'

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"web_fetch","arguments":{"url":"https://example.com/"}}}' \
  '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"web_fetch","arguments":{"url":"http://169.254.169.254/latest/meta-data/"}}}' \
  | agent-bridle-mcp
```

The fetch to `example.com` returns markdown; the cloud-metadata SSRF probe to
`169.254.169.254` is **denied** by the host allowlist (and would be SSRF-blocked
even under `net: "all"`).

## Status

This is **P0** plus the **MCP frontend** (DESIGN §4 frontend 2): the core leash,
a confined argv + safe-subset shell (ADR 0005), and an `agent-bridle-mcp` stdio
JSON-RPC server, with
tests proving the leash *denies* out-of-scope exec, exhausted budgets,
generation mismatch, and path-escape (`..` / symlink) attempts — including a
through-MCP integration test that drives the real binary over stdio and proves
an out-of-scope `tools/call` is denied across the protocol boundary.

The **`net` enforcer** (`agent-bridle-tool-web`, `web` feature) is also landed:
a confined `web_fetch` whose host allowlist, SSRF IP screen, per-redirect
re-check, and DNS-rebinding IP pin are unit-tested in isolation and exercised
end-to-end against a localhost mock server (a disallowed host, a private/loopback
address, and a redirect to a disallowed host are all proven *denied*).

Landlock `fs_write`/`fs_read` kernel enforcement is landed (Linux,
`linux-landlock`). On **macOS** (`macos-seatbelt`) the Seatbelt backend
kernel-confines `fs_write`/`fs_read`, denies all egress for `net: none`, and —
per ADR 0014 — kernel-confines the **`exec` axis** via `process-exec*` (closing
the loader trampoline with no seccomp backstop, by hardware W^X + code signing).
The remaining cross-OS L3 boundary — the Linux `net`/`exec` axes (#35/#57) and
the Windows backend (#51), per the three-tier strategy in ADR 0009 —
the optional full-bash `brush` engine (#20), the Python pillars (sidecar + host
tools dir), the browse tier (headless Chrome — subprocess), `web_search`, and scm
tools are later phases (see `docs/DESIGN.md` §12).

## License

Apache-2.0 (see [`LICENSE`](LICENSE)). The deferred, optional `brush` engine
(#20) is MIT; its notice is carried in [`NOTICE`](NOTICE) for when it is adopted.

[`agent_mesh_protocol::Caveats`]: https://crates.io/crates/agent-mesh-protocol