A capability-bound LLM agent
capa_agent_demo is an LLM agent harness in about 650 lines of Capa across four files. The model can call a small set of tools (read a file, list a directory, fetch a GET-only URL, read the clock); each tool's authority is statically narrowed by the type system, and the capability manifest is the audit contract.
Status: this demo does not build with the current Capa compiler; the manifest excerpt and the transcripts below were recorded with an earlier release.
Tool permissions today rest on convention
Mainstream agent harnesses (LangChain, OpenAI function calling, MCP servers) ship tools as arbitrary functions with no permission system. Whether a prompt injection that says "now delete the home directory" succeeds depends on whether some tool happens to reach subprocess.run or an unrestricted filesystem handle deep in its body. The blast radius is implicit, dynamic, and auditable only by reading every line of every tool.
The demo replaces the convention with a compiler check: a tool's capability surface is its signature, the agent loop's surface is the union of its tools' signatures, and the compiler refuses a call on a built-in capability that is not in scope.
Four tools, one narrow waist
A four-tool agent loop against the Anthropic Messages API, running up to 5 turns. Every tool call is forwarded through one function, dispatch_tool: the narrow waist whose signature holds the union of the capabilities its tools declare.
| Tool | Capability | What it does |
|---|---|---|
| read_file | ReadOnlyFs | Read a file's contents. |
| list_dir | ReadOnlyFs | List a directory's entries. |
| get_url | GetOnlyHttp | HTTP GET to a URL that passes the allow-list check (no POST, PUT or DELETE). |
| current_time | Clock | Return Unix seconds. |
ReadOnlyFs and GetOnlyHttp are user-defined capabilities implemented as cap-bearing structs: ReadOnlyFsImpl holds the underlying Fs internally and exposes only read_file / list_dir. A call to write on a ReadOnlyFs is refused, because the capability declares no such method.
One command answers "which capabilities does this agent hold?"
tool_read_file -> [ReadOnlyFs]
tool_list_dir -> [ReadOnlyFs]
tool_get_url -> [GetOnlyHttp]
tool_current_time -> [Clock]
dispatch_tool -> [ReadOnlyFs, GetOnlyHttp, Clock]
run_agent_loop -> [Stdio, Logger, LlmClient, ReadOnlyFs, GetOnlyHttp, Clock]
main -> [Stdio, Env, Clock, Fs, Unsafe]Read the run_agent_loop line first. The model drives that function for up to 5 turns, and the function holds those six capabilities: no write-capable Fs, no POST-capable HTTP, no Net, no Db, no Unsafe. Injected text cannot add a capability to that signature, and the compiler refuses a call on a built-in capability that is not in scope.
main is the wiring point: it legitimately holds Unsafe and the raw Fs, passes them through three factories, and hands the loop only the attenuated versions. The dangerous line is the one line a human has to review, and the function behind it is under twenty lines of wiring.
The demo's own dependencies are pinned and key-verified
[dependencies.capa_http]
git = "https://github.com/nelsonduarte/capa_http"
tag = "v0.1.3"
verify_key = "6C1D222D491FB88031E041A536CFB426101AA24B"
[dependencies.capa_log]
git = "https://github.com/nelsonduarte/capa_log"
tag = "v0.1.2"
verify_key = "6C1D222D491FB88031E041A536CFB426101AA24B"capa install runs three independent checks on each dependency: the SHA in capa.lock must match the resolved commit (catches tag retags), git verify-tag must match the pinned GPG fingerprint (catches account compromise), and the SLSA L2 build-provenance attestation is verified through Sigstore Rekor via gh attestation verify.
The tool calls run locally
These commands need a release in which the demo builds; with the current compiler, capa --run agent.capa stops at the type check.
$ git clone https://github.com/nelsonduarte/capa_agent_demo
$ cd capa_agent_demo
$ capa install
$ export ANTHROPIC_API_KEY=sk-ant-...
$ capa --run agent.capa -- "What's the weather in Lisbon today? \
Use get_url with https://wttr.in/Lisbon?format=3"The get_url allow-list is hard-coded in agent.capa (wttr.in and api.github.com). The wrapper's check is a substring match on the URL text: it admits a URL that contains ://, a listed host, then / or :, and refuses any other URL before a request is made. It does not parse the URL's host, so it is weaker than a host allow-list. It also applies only to the URL the model asks for: the demo's HTTP transport is Python's urllib reached through Unsafe, not Net, and it follows a redirect to a host that is not on the list, so a listed host can send the request elsewhere. A Net narrowed with restrict_to refuses that hop: see redirects.
Honest limits (v0.1)
- Single user prompt per run. The loop already supports multi-turn; the entry point does not yet expose it.
- Anthropic Messages API only. Adding another provider is one new LlmClient implementor.
- One string input per tool. Multi-argument schemas would extend dispatch_tool's JSON extraction.
- Hard-coded, substring-matched allow-list and no streaming. Production would source the list from configuration and check the URL's parsed host (the demo's own comment says a production wrapper would parse the URL properly), or move the transport onto a Net narrowed with restrict_to (one per host, since a narrowed set holds one host name), which re-checks every redirect hop; this demo's urllib transport does not. See redirects.