A flight recorder for AI agents. Gurdy sits between your agent and the tools it calls, decides every call against a policy you can read, and writes a tamper-evident record that someone who does not trust you can verify offline.
Default Act is monitor: every call is recorded and forwarded. Pass
-enforce (or create ~/.gurdy/state/enforce) to stop calls the pack
concludes should block. The three fields stay three fields — see
Monitor mode.
Status: early, Phase 1. The core works and is tested; a lot is not built. Gurdy is entirely open source — there is no paid tier and no withheld component. See What is not built.
Pick one. All four give you the same two binaries, gurdy-proxy and
gurdy-verify, and none of them needs a server, an account, or a network call
at run time.
brew install deterministic-systems-lab/tap/gurdy # macOS, Linux
curl -fsSL https://raw.githubusercontent.com/deterministic-systems-lab/gurdy/main/install.sh | sh
npm i -g @gurdy/cli # one platform binary, not four
pipx install gurdy # or: pip install gurdypip install gurdy is the one that does two things: the same binaries plus
the Python SDK, so import gurdy works in an agent process. npm i @gurdy/sdk
is the TypeScript half.
Binaries are built by release.yml, signed
with cosign keyless (no stored private key), and shipped with an SBOM per
archive. Every release is rebuilt on a second runner and fails if a byte
differs. To check origin rather than just integrity:
cosign verify-blob --certificate-identity-regexp 'https://github.com/deterministic-systems-lab/gurdy/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate checksums.txt.pem --signature checksums.txt.sig checksums.txtinstall.sh always verifies the checksum, and says out loud that a checksum
without cosign proves integrity and not origin — the checksum file travels
from the same place as the archive.
You need Go 1.26+ (proxy/go.mod). Nothing else.
git clone https://github.com/deterministic-systems-lab/gurdy && cd gurdy/proxy
go build -o /tmp/gurdy-proxy ./cmd/gurdy-proxy
go build -o /tmp/gurdy-verify ./cmd/gurdy-verifyNow govern something. gurdy-proxy -stdio wraps any MCP server as a subprocess
and relays its protocol untouched — so cat stands in as a server that echoes
whatever you send it:
cd /tmp && mkdir -p demo && cd demo
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"/home/me/.ssh/id_rsa"}}}' \
| /tmp/gurdy-proxy -stdio -ledger-dir ./ledger -state-dir ./state -- catKeep that JSON on one line. MCP stdio framing is newline-delimited, so a pretty-printed frame is two incomplete frames: the shim relays them untouched, records nothing, and it looks like it worked. (This paragraph exists because the first draft of this README got it wrong.)
Two things happen. Your JSON comes back on stdout, byte for byte — Gurdy is in the path and did not touch it. And on stderr you get the decision:
{"decision":"flag","policy_ids":["flag-credential-read"],
"action_applied":"forwarded","principal":"svc:stdio:cat",
"principal_tier":"attested-coarse","tool":"read_file"}An agent read a private key. That is now written down.
/tmp/gurdy-verify ./ledgerOK ledger/_proxy-….jsonl: 5 records, 0 decisions (0 answered), 2 batch signatures, key: embedded (unpinned — pin the key for third-party verification)
lifecycle: ended cleanly
chain: tenant=local workload=(none) instance=gurdy-local#01M3T… schema=v1 key=f493f3b5d35b8fa9
producer: gurdy/dev+dc4db4c6af52
head: seq 5 hash 8e58cdc74ab24011…
OK ledger/local_stdio_cat-….jsonl: 3 records, 1 decisions (0 answered), 1 batch signatures, key: embedded (unpinned — pin the key for third-party verification)
chain: tenant=local workload=stdio:cat instance=gurdy-local#01M3T… schema=v1 key=f493f3b5d35b8fa9
producer: gurdy/dev+dc4db4c6af52
head: seq 3 hash 043268a97f191ba0…
Two files, because the proxy keeps its own lifecycle chain alongside the
workload's. unpinned is the honest default: with no -pubkey the verifier
trusts the key the export names, which proves the file is internally intact but
not who produced it. Pass -pubkey for the third-party case.
gurdy-verify needs nothing but the binary and the directory — no server, no
key, no network. That is the point: the export is the evidence, and anyone
can re-walk its hash chain and check its signatures. Hand someone the folder and
they can check your work without trusting you.
Try breaking it. Change one character inside any record and run gurdy-verify
again; it will name the record where the chain stops.
cd gurdy/reporter && uv sync
uv run gurdy-report /tmp/demo/ledger --verifier /tmp/gurdy-verify
uv run gurdy-report /tmp/demo/ledger --framework eu-ai-actYou get Markdown a person can read, with every claim citing the ledger records
it rests on — and a JSON sibling for tooling (--json). --framework
projects the same verified export through policy/control_map.yaml. It
refuses to produce a report from an export that fails verification,
because a report over unverified records looks exactly like a report over
sound ones. The framework section does not state that an organization is
compliant.
Every intercepted call runs five stages (§4.2):
identify → classify → decide → act → attest
who was what kind Cedar forward append a
this? of call? policy (always) signed record
- identify — a proxy-observed principal, always. If your agent uses a Gurdy SDK it also carries an asserted identity and a lineage of which sub-agent spawned which. The two are recorded separately and the observed one never degrades, so an agent cannot choose the identity it is authorised as.
- classify — an extractor names the action (
mcp/tools_call,llm/completion) and pulls out metadata: which tool, which path, which model. Never payload content. - decide — embedded Cedar. Local, deterministic, sub-millisecond. No network call, no model.
- act — forwards in monitor mode; with
-enforce, adecision=blockstops the call after the record is durable. - attest — appends to a hash-chained, batch-signed JSONL ledger.
This is a deliberate default, not a missing flag.
A record can say decision: block — that means a policy concluded it would
have blocked. What actually happened is a separate field, action_applied.
In monitor mode that field is forwarded. Pass -enforce and the same
forbid records action_applied=blocked and the call does not run. A third
field, policy_mode, says which actuator was selected.
Three fields, because collapsing them is how a monitoring tool starts making enforcement claims it cannot support.
A report saying "37 violations" must also say how many were stopped.
In monitor mode that second number is zero. With -enforce it is the
count of action_applied=blocked.
Build the proxy once, then drop Gurdy onto the host you already use.
A surface is connected only when a tool call reaches gurdy-proxy and
lands in the ledger. Details — including what each product cannot see —
are in docs/hosts.md.
make proxy
python3 adapters/connect.py --root . --host cursor # ~/.cursor
python3 adapters/connect.py --root . --host claude # Claude Code
python3 adapters/connect.py --root . --host antigravity # ~/.gemini/config
python3 adapters/connect.py --root . --host chatgpt # desktop / Codex, not chatgpt.comRestart the host. Codex also needs /hooks to trust the new command.
Then gurdy-verify ~/.gurdy/ledger/<host> after the agent uses a tool.
stdio shim — wraps a local MCP server, zero infrastructure. What you just ran. stdout is the protocol channel; decisions go to stderr.
gurdy-proxy -stdio -ledger-dir ./led -state-dir ./state -- your-mcp-server --flagsReverse proxy — sits in front of an HTTP MCP server or a model API.
gurdy-proxy -upstream http://localhost:3000 -listen :8090 \
-ledger-dir ./led -state-dir ./state
curl localhost:8091/health # admin API, localhost-only
curl localhost:8091/latency # what the proxy itself costs, per stageThe bundled starter pack (BR-11) is three rules in
proxy/internal/policy/starter.cedar:
credential reads, destructive filesystem operations, and model calls to
unlisted hosts. Point -policy at your own .cedar file or a .tar.gz pack to
replace it.
@id("flag-credential-read") @enforce_action("flag") @on_error("open")
forbid (principal, action == Action::"mcp/tools_call", resource)
when { context has resource_path && context.resource_path like "*/.ssh/*" };
@enforce_action is the graduation knob: "block" makes the record read would
have blocked today, and blocks once the Phase 2 actuator exists.
We publish what our own pack does not catch. corpus/ holds 27
replayable attack traces, and seven of them are attacks that currently succeed —
each with why, and what would close it. Run them:
cd proxy && go build -o /tmp/gc ./cmd/gurdy-conform
/tmp/gc -cases ../corpus/traces -proxy /tmp/gurdy-proxyOptional. Without one, Gurdy still records every call against a coarse principal derived from the environment. With one, you also get the agent's own claim about who it is and which sub-agent spawned which.
Point them at a running proxy with GURDY_PROXY_URL and GURDY_TIS_SOCKET
(the proxy prints the socket path at startup), then mark the task boundary once:
import gurdy # sdk/python
with gurdy.task(agent="orchestrator", human_actor="alice@example.com"):
... # calls inside carry lineageimport * as gurdy from '@gurdy/sdk'; // sdk/typescript
await gurdy.task({ agent: 'orchestrator' }, async () => { ... });Both are judged by one shared corpus so they cannot drift. Neither holds signing keys, neither decides anything, and neither can fabricate a claim: outside a task context a call goes out unenriched.
proxy/ |
the Go core — proxy, identity, policy, ledger, verifier |
sdk/python, sdk/typescript |
provenance enrichment |
reporter/ |
ledger → readable governance report |
conformance/ |
the shared corpus both SDKs must pass |
corpus/ |
adversarial traces that gate the policy pack |
docs/ |
the normative spec, roadmap, performance, activity log |
policy/ |
pack builder: controls.json → Cedar, plus a replay gate |
fleet/ |
device shipper: verify, ingest, pull desired pack/enforce |
console/ |
suggested Next.js management framework (fleet + policy APIs) |
adapters/ |
drop Gurdy onto a host agent — install Cursor, Claude Code, Antigravity, ChatGPT/Codex |
docs/spec.md is normative — section numbers in
code comments point into it, and the doc wins over the code.
Stated plainly because a governance tool that overstates itself has picked the wrong thing to be bad at:
- No Windows binaries. macOS and Linux, amd64 and arm64. WSL2 works today; native Windows is Phase 2. (§3.J)
- Blocking is opt-in. Monitor is the default.
-enforceis the local actuator (ADR-14). Admin-API mutating routes are still unauthenticated on localhost — do not treat a laptop as sealed. - No LangChain or Claude-agent-SDK hooks yet. The SDKs do not bundle the
Go core for dev mode. Host adapters for Cursor, Claude Code, Antigravity,
and ChatGPT desktop / Codex live in
adapters/; install steps are indocs/hosts.md. chatgpt.com cannot be connected. - Seven known attack gaps, published in
corpus/rather than quietly omitted. Five share one root cause: controls match on strings the agent chooses. - Framework map is a projection, not a certificate.
gurdy-report --framework nist-ai-rmf|iso-42001|eu-ai-actprojects a verified export throughpolicy/control_map.yaml. Status is evidenced, partial, or not-in-scope. It does not state that an organization is compliant. HIPAA maps and HTML/PDF packs are still unbuilt.