Skip to main content

agent-flight-recorder

Tests CodeQL OpenSSF Scorecard Fuzzing: ClusterFuzzLite Python 3.11+ License: MIT

A flight recorder for AI agents: what did the agent actually do, who let it, and can you prove it?

AI agents now call tools that merge code, move money and delete data. When something goes wrong, or an auditor asks, most teams can show what the agent could do (its permissions) but not what it did: which person it was acting for, which tool it called with which arguments, what the policy said, and who approved the risky step. Ordinary application logs can't answer that well, because whoever runs the agent can also edit the logs.

agent-flight-recorder (afr) sits between an agent and its MCP tool servers and does three things:

  1. Enforces policy on every tool call. Rules written as code allow a call, deny it, or hold it until a named human approves. Approvals are bound to the exact call (server, tool and arguments), expire, and work once.
  2. Records every call in a tamper-evident log. Each entry is hash-chained to the one before and signed with Ed25519. It records the agent, the person it acted for, the server, the tool, a digest (and optionally a redacted copy) of the arguments, the rule that decided, the approval it ran under, and the outcome.
  3. Turns the log into audit evidence. afr report re-checks the chain and signatures and re-performs seven controls against the log. It then writes a reviewer report, a CSV of every call, hashed evidence JSON and OSCAL assessment results.
pip install multiagent-flight-recorder
afr demo
AFR-01  LOW     1 finding(s)
AFR-02  PASS    0 finding(s)
AFR-03  PASS    0 finding(s)
AFR-04  MEDIUM  1 finding(s)
AFR-05  PASS    0 finding(s)
AFR-06  MEDIUM  1 finding(s)
AFR-07  PASS    0 finding(s)

Tampered copy (one "deny" changed to "allow"): chain BROKEN
  - entry 4: content does not match its hash (edited after it was written)

The demo runs a scripted session for a release agent working through the GitHub MCP server. The agent reads a pull request, is blocked from pushing to main, and waits for priya to approve a merge. Its retry needs a fresh approval, because approvals are single-use. Then dana approves her own agent's branch deletion, and the server quietly changes a tool description. Read the sample report and the signed log without installing anything.

agent-access-review answers what can this agent do? This tool answers what did it do?

How it works

flowchart TB
    A["🤖 MCP client<br/>Claude Code, Claude Desktop, Cursor"]
    subgraph AFR["⚙️ afr proxy"]
        direction TB
        P{"Policy decision<br/>policy.yaml · first match wins"}
        D["deny<br/>error returned to the agent"]
        Q["approve<br/>held until a human approves"]
        OK["allow<br/>or approved, once"]
        P --> D
        P --> Q
        P --> OK
        Q -->|"approval bound to<br/>server + tool + arguments"| OK
    end
    H["👤 Approver<br/>afr approve"]
    S["🧰 MCP server<br/>GitHub, Slack, filesystem, shell"]
    LOG[("flight.jsonl<br/>hash-chained · Ed25519-signed")]
    RPT["afr verify · afr report<br/>7 controls · Markdown · CSV · OSCAL"]
    A -->|tools/call| P
    H --> Q
    OK -->|forwarded| S
    AFR -. "every decision, approval and result" .-> LOG
    LOG --> RPT

    classDef client fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e
    classDef core fill:#ede9fe,stroke:#7c3aed,color:#3b0764
    classDef deny fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef gate fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    class A,S client
    class P core
    class D deny
    class Q,H,LOG gate
    class OK,RPT ok

The agent and the server don't change. The proxy speaks MCP to both, so the agent sees the same tools, and the server sees only the calls the policy let through.

An approval, step by step

sequenceDiagram
    autonumber
    participant Agent as 🤖 Agent
    participant AFR as afr proxy
    participant Log as flight.jsonl
    participant Human as 👤 priya
    participant GH as GitHub MCP server

    Agent->>AFR: tools/call merge_pull_request PR 41
    AFR->>Log: tool-call · decision approval-required · rule merges-need-a-human
    AFR-->>Agent: isError: needs approval, request cc3118f9-1
    Human->>AFR: afr approve cc3118f9-1 --approver priya
    AFR->>Log: approval · bound to sha256(server, tool, args) · expires in 60 min
    Agent->>AFR: tools/call merge_pull_request PR 41 (same arguments)
    AFR->>Log: tool-call · decision approved · approval_entry 6:419c…
    AFR->>GH: forwarded
    GH-->>AFR: result
    AFR->>Log: tool-result · ok · result sha256
    AFR-->>Agent: result
    Agent->>AFR: tools/call merge_pull_request PR 41 again
    AFR->>Log: tool-call · approval-required (the approval was single-use)
    AFR-->>Agent: isError: new request cc3118f9-2

Why the log is tamper-evident

flowchart LR
    E0["entry 0<br/>session-start<br/>prev: 000…0<br/>hash: 2f19…"] --> E1["entry 1<br/>tools-listed<br/>prev: 2f19…<br/>hash: 506a…"]
    E1 --> E2["entry 2<br/>tool-call<br/>prev: 506a…<br/>hash: e542…"]
    E2 --> EN["…"]
    EN --> HEAD["entry 15<br/>session-end<br/>hash: 711f…"]
    HEAD -. "afr verify prints the head;<br/>store it outside the system" .-> ANC[("Anchor<br/>15:711f…")]

    classDef entry fill:#ede9fe,stroke:#7c3aed,color:#3b0764
    classDef anchor fill:#fef3c7,stroke:#d97706,color:#78350f
    class E0,E1,E2,EN,HEAD entry
    class ANC anchor

Each entry stores the hash of the one before it and its own hash, and the recorder signs that hash with Ed25519. Editing, deleting or reordering any entry breaks every link after it. Without the private key, nobody can write a replacement chain that verifies. The anchor catches the one thing a chain can't: entries cut off the end.

Run an MCP server behind the recorder

Write a policy:

# policy.yaml
agent: release-bot
on_behalf_of: env:USER                 # the person running the agent, or a literal name
default: approve                       # anything not listed needs a human
record_arguments: redacted             # digest | redacted | full
approval_ttl_minutes: 60

rules:                                 # first match wins
  - id: read-only
    tools: ["get_*", "list_*", "search_*"]
    decision: allow

  - id: no-push-to-protected
    tools: ["push_files", "create_or_update_file"]
    when: {arg: branch, matches: "^(main|master|release/.*)$"}
    decision: deny
    reason: Agents don't write to protected branches; open a pull request instead.

  - id: merges-need-a-human
    tools: ["merge_pull_request", "delete_*"]
    decision: approve

Create a signing key:

afr keygen --out afr-signing

That writes afr-signing.key (keep it secret) and afr-signing.pub. Then point your MCP client at afr proxy instead of the server. For example, in a Claude Code .mcp.json or a Claude Desktop config:

{
  "mcpServers": {
    "github": {
      "command": "afr",
      "args": ["proxy", "--policy", "policy.yaml", "--key", "afr-signing.key", "--server-name", "github",
               "--", "npx", "-y", "@modelcontextprotocol/server-github"],
      "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "..."}
    }
  }
}

The agent sees the same tools as before. A denied call returns an error the agent can relay, carrying the rule's reason. A call that needs approval returns a request ID and never reaches the server until someone approves it:

afr pending --log .afr/release-bot.jsonl
afr approve 3f9c2a1b-1 --approver priya@example.com --log .afr/release-bot.jsonl --key afr-signing.key

The agent's next identical call goes through once. A call with different arguments, a second retry, or a retry after the TTL needs a new approval.

Remote servers (Streamable HTTP)

For an MCP server reached by URL, run the proxy as a local endpoint and point the client at it instead of the server:

afr proxy --policy policy.yaml --key afr-signing.key --http-upstream https://api.githubcopilot.com/mcp/ --listen 127.0.0.1:8765
{
  "mcpServers": {
    "github": {"type": "http", "url": "http://127.0.0.1:8765/mcp",
               "headers": {"Authorization": "Bearer ${GITHUB_PAT}"}}
  }
}

The policy, approvals and log work the same way. Headers the client sends, including Authorization, go to the upstream and are never written to the log; the log records the upstream URL without its query string or userinfo. The proxy only listens on loopback, rejects requests whose Origin is not local (so a web page can't use it), and doesn't follow redirects, so a token goes only to the URL you configured. Clients that sign in with OAuth discovery may not accept the local URL; pass a token in a header instead.

Verify and report

afr verify --log .afr/release-bot.jsonl --pubkey afr-signing.pub
16 entries; chain intact; 16 signed
Head: 15:711fe81a...  (store it elsewhere; pass it later as --anchor)
afr report --log .afr/release-bot.jsonl --pubkey afr-signing.pub --anchor 15:711fe81a... --out evidence --fail-on medium

report does not trust the log's own claims. It recomputes every hash and signature. It also checks every recorded decision against the policy that was in force for that session, follows every approval reference back to a real, earlier, unexpired approval entry for the same call, and counts how many times each approval was used.

Check Control What fails it
AFR-01 The flight log is complete and unaltered an edited, removed or reordered entry; a bad signature; the anchored entry missing; no signing key at all
AFR-02 Every tool call is attributed to an agent and the person it acts for a call with no agent or no on_behalf_of
AFR-03 Calls that need approval ran only with a valid, single-use human approval a call claiming a missing, later or rejected approval; an approval for different arguments; one used twice or after it expired
AFR-04 Nobody approved an agent acting on their own behalf the approver is the person the agent was acting for
AFR-05 Policy decisions were enforced a decision that contradicts its rule; a denied call that has a result, meaning it reached the server
AFR-06 Tool definitions did not change without review a tool's description or input schema hash changed between listings (a possible tool-poisoning "rug pull")
AFR-07 Every forwarded call has a recorded outcome an allowed call with no result

Each check is mapped to NIST SP 800-53, SOC 2, ISO/IEC 42001 Annex A and the EU AI Act in controls.yaml. The mappings are my judgment; check them against official crosswalks before relying on them in an audit. The tool produces evidence that is relevant to the AI Act's record-keeping (Article 12) and human-oversight (Article 14) duties. It does not by itself make a system compliant with them.

What it records, and what it doesn't

  • Arguments: digest keeps only a SHA-256. redacted (the default) also keeps a copy with secret-looking keys removed and long strings cut to 500 characters. full keeps everything. Every mode records the digest, so an approval can be bound to the exact call.
  • Results: whether the call succeeded, the error code, the duration and a SHA-256 of the result. Never the result body, which may hold the very data you are protecting.
  • Tool listings: each tool's name plus hashes of its description and input schema, so changes show up without the log storing the text.
  • The server command: the program name and a hash of the full command line, because arguments sometimes carry tokens.

Limits

  • Truncation needs an anchor. A hash chain shows edits, deletions and reordering in the middle of a log, but someone who can write the file can cut entries off the end. Store the Head: from afr verify somewhere the agent's operator can't change it (a ticket, a WORM bucket, a separate system), and pass it as --anchor. Reports without an anchor say so.
  • One signing key per log. Whoever holds the private key can write a new, valid log. Keep the key away from the agent's host, or rotate it per session. Hardware or KMS-backed signing would be the next step.
  • stdio and Streamable HTTP. The older HTTP+SSE transport (separate /sse and /messages endpoints) isn't supported, and OAuth discovery through the proxy isn't either: give the client a token header.
  • It sees the tool calls, not the model. Prompts, reasoning and anything the agent does outside MCP (its own shell, direct API calls) are out of view. Pair it with sandboxing for those.
  • Approvals are command-line. afr approve records who approved and when, but it trusts the --approver you type. In production, run approvals through a system that authenticates the approver and calls afr approve for them.

Development

pip install -r requirements.txt
pytest -q

If a change alters the demo, regenerate the sample output (CI fails if it is stale):

SOURCE_DATE_EPOCH=1791763200 python -m agent_flight_recorder demo --out sample-output

CI runs the tests, rebuilds the sample output and fails if it changed, checks the wheel, and runs CodeQL, OpenSSF Scorecard and ClusterFuzzLite. The fuzz target feeds arbitrary bytes to the log verifier, the report and the policy loader. Releases carry SLSA provenance.

MIT licensed. Security reports: see SECURITY.md.

Metadata

Release files for multiagent-flight-recorder 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for multiagent-flight-recorder 0.2.0
File Size Uploaded Provenance Actions
multiagent_flight_recorder-0.2.0.tar.gz 43.6 kB With provenance Details for multiagent_flight_recorder-0.2.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for multiagent-flight-recorder 0.2.0
File Interpreter ABI Platform Actions
multiagent_flight_recorder-0.2.0-py3-none-any.whl Python 3 none any Details for multiagent_flight_recorder-0.2.0-py3-none-any.whl

Total release size: 79.4 kB

Release files / multiagent_flight_recorder-0.2.0.tar.gz

Download URL multiagent_flight_recorder-0.2.0.tar.gz
Size 43.6 kB
Tags Source
SHA-256 checksum
How to use checksums
86a8547d529b31ab06a73fbe77cd24b46e1182c4578a4bfbdd7882d1ee716c25
BLAKE2b-256 checksum
How to use checksums
a74ab3275dedd1fe215e6559f1441c1f816a1c64359df4f2e2f8bf1209cc462b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 11, 2026.

Transparency log

Release files / multiagent_flight_recorder-0.2.0-py3-none-any.whl

Download URL multiagent_flight_recorder-0.2.0-py3-none-any.whl
Size 35.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4eb5ed643aec67391f7327eaafaeb82120f64e08e9a47d78129a2b15567b7d85
BLAKE2b-256 checksum
How to use checksums
0aecae77c3ab2cd85271574fd7d94e11c2c2f3c693a10c3432cf805a006243b1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 11, 2026.

Transparency log

Security for multiagent-flight-recorder 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for multiagent_flight_recorder-0.2.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 11, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page