Agent demo

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.

The problem

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 type-system guarantee: a tool's capability surface is its signature, the agent loop's surface is the union of its tools' signatures, and the compiler refuses to type-check a call to any authority a function did not name.

What the demo is

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 entire authority union the loop may exercise.

ToolCapabilityWhat it does
read_fileReadOnlyFsRead a file's contents.
list_dirReadOnlyFsList a directory's entries.
get_urlGetOnlyHttpHTTP GET against an allow-listed host (no POST, PUT or DELETE).
current_timeClockReturn 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 holder of ReadOnlyFs cannot call Fs.write because the wrapper never exposes it; the analyzer guarantees consumers can never reach past the declared surface to the authority underneath.

The audit moment

One command answers "what is the worst this agent can do?"

capa --manifest agent.capa
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 and you are done. The model controls that function for up to 5 turns, and its blast radius is exactly those six capabilities: no write-capable Fs, no POST-capable HTTP, no Net, no Db, no Unsafe. A prompt injection cannot widen the surface: the compiler refuses to type-check a call to an authority the function did not declare, so there is nothing for the injected text to reach.

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.

Supply chain

The demo's own dependencies are pinned and key-verified

capa.toml
[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.

Run it

The tool calls run locally

$ 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). Point the model at any other host and the wrapper refuses before a request is made.

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 host allow-list and no streaming. Production would source the list from configuration or a restrict_to call.