Callwitness

Record every tool
call an agent makes.
Block nothing.

A transparent MCP proxy. It forwards every byte unchanged and writes down what happened.

v0.1.0 · alpha Python 3.8+ Zero dependencies MIT
$pip install callwitness
Source
seq

Same tool. Same permission.
Two very different acts.

seqbytestooldestinations
11751 send_emailops@acme.com
11863 send_emailbilling@acme.com
11920085 send_emailexfil.example.net, drop@unknown.example!

An allowlist cannot tell these apart. The agent is permitted to send email in all three cases. The difference is how much is leaving and where it is going — the two signals Callwitness records on every call.

why

It blocks nothing, so you can install it on a Tuesday afternoon.

A recorder that can break the thing it records will not be installed anywhere that matters. So two properties come before every feature, and both are tested rather than asserted.

It cannot corrupt

Every message is relayed whether or not the recorder can parse it, and every write to storage is wrapped so a recorder bug never reaches the stream. test_passthrough.py asserts the proxied output is byte-identical to running the server directly.

It cannot delay

0.05ms

What a deliberately half-second-slow observer adds to the gap between two forwarded messages. Before observation moved off the hot path, the same test moved it by 4.2 seconds. The queue drops rather than growing without limit, and counts what it dropped.

use

It wraps the servers you already have.

Point it at your client's config. It shows exactly what would change and writes nothing until you say so — a broken MCP config means a broken agent, which is the one outcome this tool promises not to cause.

$ callwitness install

/Users/you/Library/Application Support/Claude/claude_desktop_config.json
  filesystem
    - npx -y @modelcontextprotocol/server-filesystem /data
    + callwitness run --label filesystem -- npx -y @modelcontextprotocol/server-filesystem /data
  git
    - uvx mcp-server-git --repository /repo
    + callwitness run --label git -- uvx mcp-server-git --repository /repo
  remote-api  SKIPPED: remote server -- needs `callwitness proxy`

2 servers would be wrapped. Nothing has been changed.

Knows Claude Desktop, Claude Code, Cursor, Windsurf and project-local .mcp.json. Remote servers over Streamable HTTP go behind callwitness proxy instead — POST, the SSE response stream, the server-initiated GET stream and session teardown are all relayed verbatim, headers included, so the Mcp-Session-Id handshake works without Callwitness understanding it.

--apply writes it, after a timestamped backup. callwitness uninstall puts everything back.

rules

Then it writes the rules, from traffic you actually saw.

The next tier was going to be YAML you write by hand. But a person typing max_payload: 8KB is guessing at a number they have no way to know — which is the thing this project says the industry is doing wrong. Enforcement without data is guessing with extra steps.

$ callwitness suggest --since 14d

  send_email     max_payload           3.5KB     # p99 observed 2.3KB over n=400; 1.5x headroom
  send_email     destinations_emails   3 allowed # 3 addresses covering 100% of traffic, n=400
  send_email     rate_limit_per_hour   21        # 10.0/hour over 39.9 hours; 2x headroom
? fetch_url      destinations_hosts    --        # 99 distinct hosts across 150 calls -- too varied
                                                #   for an allowlist; reads as a general fetcher
? delete_record  insufficient_data     --        # only 6 calls observed; 30 needed before a
                                                #   threshold means anything

Note what it refuses to do. A tool below 30 calls gets no threshold, because a p99 over n=6 is an anecdote. A destination never seen is not a destination that is forbidden — it may simply not have happened yet, and the output says so. Every line is a hypothesis with its evidence attached.

trap

The attack that raises the limit to permit itself.

If the bad thing already happened while you were watching, it is in the distribution, and a plain percentile quietly makes room for it. In testing, a 29KB exfiltration produced a 43KB proposed ceiling — a limit that would have allowed the exact call the tool exists to catch.

  send_email  max_payload  4.3KB  # p99 of the bulk is 2.9KB; 1.5x headroom.
                                 #   EXCLUDES 1 call above 11.4KB
! send_email  tail_review  1      # 1 call more than 8x the 1.4KB median. A rare
                                 #   enormous call is the most interesting thing
                                 #   here, so it sets no limit until you have
                                 #   looked at it: 28.8KB at 2026-09-09T18:09
                                 #   -> exfil.example.net

So ceilings come from the bulk of a distribution, not all of it. The reference is the median — the one statistic a single enormous call cannot move, which is the point when that call may be the attack. Outliers set no limit; they are named, with timestamps and destinations, and handed to a person. If more than 10% of traffic sits above the threshold it is not a tail, it is the shape, and nothing is excluded.

chain

A record that can be silently rewritten is a convenience, not evidence.

An append-only file is trivially editable by anyone with filesystem access — including a compromised agent running as the same user. So every call commits to the one before it. Editing, deleting, reordering or inserting a record breaks the chain from that point, and verify says where.

$ callwitness verify
BROKEN  filesystem  642 records, breaks at seq 118
                    content does not match its hash: this record was
                    edited after it was written

Exit code 1 on a break, so it works in a cron job without anyone parsing text.

It is tamper-evident, not tamper-proof

Someone who can write to the file can also recompute every hash after a change and produce a chain that verifies. Nothing local can stop that, because the verifier and the attacker read the same file. What defeats it is an anchor the operator does not control, so verify prints the head hash and tells you to store it somewhere the machine cannot reach. That is a deployment decision, and inventing one for you would be worse than naming the gap.

privacy

Redaction is on by default, and destinations survive it.

Tool arguments routinely carry API keys, bearer tokens and connection strings. Without redaction, every install would create a plaintext credential store that did not exist before. Known key formats, credentials inside URLs, sensitively-named parameters and high-entropy tokens are replaced on the write path — never on read, because by then the plaintext is already on disk.

stored as:  postgres://admin:<redacted:url_password>@db.internal

The host is the signal; the password is not. The true pre-redaction byte count is kept, so the volume signal survives. --no-args stores argument shape only and still records destinations — that is deliberate, it is tested, and you should say it out loud to anyone you ask to run this.

Everything stays on the machine that ran it. Nothing is transmitted anywhere.

next

Where this is going.

  1. Now

    Observe. Record every call, block nothing. Both MCP transports, hash-chained, redacted on the write path.

  2. Next

    Deterministic policy. The rules suggest proposes, evaluated inline, sub-millisecond, fail-open by default. The generator ships first on purpose: an engine that enforces numbers nobody could justify is the problem, not the product.

  3. Then

    Context. An LLM judge, but only on calls the deterministic tier flags. Payload volume × destination reputation first.

Scope is MCP tool calls over stdio and Streamable HTTP. The deprecated two-endpoint HTTP+SSE transport is not covered. Direct API calls made inside agent code need an SDK wrapper, and that is deliberately not in v1.

origin

Why it exists.

Out of an experiment on chain-of-thought faithfulness, which turned up a measurement problem: hint verbalisation rate reads 100% on the reasoning trace and 12% on the user-facing answer, for the same responses. Same data, same model, an order of magnitude apart depending only on where you look.

A field whose headline metric moves by 10× depending on the instrument does not need another opinion about agent risk. It needs somebody to start writing down what actually happens.

$pip install callwitness
PyPI GitHub