agent-locker
A lightweight, tamper-evident append-only locker for agent-to-agent task
handoff. One writer appends hash-chained blocks, seals the pad, and hands off a
read ticket; one reader verifies the chain end-to-end. Ships with a stdio MCP
server (lockermcp) so AI agents can drive it natively.
Python / FastAPI + aiosqlite (single-file SQLite, WAL mode). No Redis, no S3, no external services.
Three ways to use this project
They differ in what you get, not in how the daemon behaves.
- Installed package —
pip install lockermcp, oruvx lockermcpto run it without installing. Provides the two importable packages and thelockerd/lockermcpconsole scripts. No example scripts, no tests, no Docker or deployment files. - Source distribution — the
.tar.gzon the package index. Adds the test suite, the example scripts,demo_handoff.py,mcp_config.example.json, the Docker files and these release notes, so the workflows documented below all work from an unpacked sdist. - Repository checkout — everything in the sdist, plus
deploy/, which holds host deployment materials for the hosted service (reverse-proxy config and the public static site). Deployment materials are deliberately not published in the source distribution.
Repository: https://github.com/HexHillbilly/agent-locker
Install
A. Installed package
uvx lockermcp # MCP server (stdio)
uvx --from lockermcp lockerd # daemon
The same two commands are available after a normal installation:
pip install lockermcp
lockerd # daemon
lockermcp # MCP server (stdio)
B. From the source distribution
tar xzf lockermcp-0.1.1.tar.gz && cd lockermcp-0.1.1
python -m venv .venv && source .venv/bin/activate
pip install .
The unpacked tree also contains tests/, scripts/, demo_handoff.py and
mcp_config.example.json, so the example workflows below run from there
directly.
C. From a repository checkout (development)
git clone https://github.com/HexHillbilly/agent-locker && cd agent-locker
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
Run the daemon
LOCKER_DB_PATH=locker.db LOCKER_MODE=open lockerd # binds 127.0.0.1:8000
See .env.example for every runtime variable.
Configure an MCP client
lockermcp is a thin client: it talks to a running daemon and its only required
configuration is LOCKER_URL, the daemon's base URL.
{
"mcpServers": {
"lockermcp": {
"command": "uvx",
"args": ["lockermcp"],
"env": { "LOCKER_URL": "http://127.0.0.1:8000" }
}
}
}
mcp_config.example.json (in the source distribution and the checkout) is that
block verbatim. Register it with Claude Code / Cursor / Open WebUI via the stdio
mcpServers block. If the package is installed rather than fetched by uvx,
use the installed entry point directly:
{ "command": "lockermcp", "args": [], "env": { "LOCKER_URL": "http://127.0.0.1:8000" } }
API
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /v1/pads |
— | create a pad → {pad_id, write_key, read_ticket} |
| POST | /v1/pads/{id}/append |
Bearer <write_key> |
append one block (≤64 KB) |
| POST | /v1/pads/{id}/seal |
Bearer <write_key> |
freeze the pad, revoke writes |
| POST | /v1/pads/{id}/tickets |
Bearer <write_key> |
mint an extra read ticket (read_once / read_unlimited) |
| GET | /v1/pads/{id}/manifest |
none (free) | state, block count, bytes, sealed_at, head hash |
| GET | /v1/pads/{id}/blocks |
?ticket=<read_ticket> |
bounded slice of blocks |
| GET | /health |
none | liveness: 200 ok / 503 if DB read-only |
demo-pad-v1 is a permanent read-only pad seeded on startup; its blocks are
readable without a ticket (GET /v1/pads/demo-pad-v1/blocks). Its published
test write key is demo-write-key — writing to it returns 409 Conflict.
Handoff envelope (block 0)
Block 0 must be a strict locker.handoff.v1 envelope — rejected unless every
field is present and correctly typed:
{
"schema": "locker.handoff.v1",
"task_id": "str",
"from_agent": "str",
"to_agent": "str",
"constraints": ["str"],
"artifacts": [{"any": "dict"}],
"budget_usd": 1.25 // float, or null
}
Optional field: allowed_paths — a list of glob/path patterns (list[str]) or
null. When present, a reviewer may flag any changed file outside the patterns
as a scope violation. Absent or null = unconstrained (backward compatible).
Block 1 attestation (implementation envelope)
Block 1 is free-form JSON — the daemon does not schema-validate post-block-0 payloads — but the recommended attestation shape is:
{
"task_id": "str",
"from_agent": "str",
"to_agent": "str",
"status": "completed | failed | failed_dirty",
"canonical_ref": {"repo": "owner/name", "branch": "str", "commit_sha": "str"},
"verification": {"test_command": "str", "exit_code": 0, "tests_passed": 1, "tests_failed": 0},
"environment": {
"runtime": "python 3.11.8",
"dependency_manifest": "requirements.txt",
"dependency_hash": "sha256:..."
},
"artifacts": [{"type": "git_commit", "ref": "..."}, {"type": "diff_summary", "files_changed": [], "loc_added": 0, "loc_deleted": 0}]
}
environment.dependency_hash pegs the attestation to an exact dependency
snapshot, so an independent re-run reproduces the same result instead of
whatever upstream packages install today.
Hash chain & tamper-evidence
Each block computes:
curr_hash = sha256(prev_hash.encode() + payload_bytes).hexdigest()
Block 0's prev_hash is the genesis value 0x00…0 (64 zeros); the pad's
head_hash is the curr_hash of the latest block. A reader walks the chain
from genesis and must land exactly on head_hash — any payload mutation breaks
the chain. The /blocks response includes prev_hash, curr_hash, and the
base64 payload (payload_utf8 when text-decodable), so verification is
self-contained.
Integrity guarantees and their limits
What the chain establishes:
- Internal consistency. Each block's hash is bound to its predecessor, so a single mutated payload breaks verification. The tamper-evidence is checked programmatically by the client before blocks enter LLM context, which prevents context drift and transport manipulation.
- Change detection against a trusted head. If a reader retains a pad's
head_hashindependently, it can detect any later rewrite of the pad.
What the chain does not establish:
- It does not authenticate the writer.
from_agent/to_agentare client-supplied strings; holding the write key proves possession of the write key, not identity. - It does not make the attestation true. Block 1 is free-form and
unvalidated:
verification.exit_code,tests_passed, andcanonical_ref.commit_shaare claims the writer makes about itself. - It does not defend against a malicious operator. Whoever runs the daemon can rewrite both the chain and the head it serves; self-verification against a head obtained from the same server proves nothing new. It is not a substitute for decentralized consensus against a hostile host.
Auth & tickets
- write_key — returned once at creation; only its sha256 hash is stored.
append/sealverifysha256(presented_key) == write_key_hash. - read_once = a read lease. The first successful
/blocksread opens a window (default 10 minutes,LOCKER_READ_LEASE_SECONDS). Reads inside the window are unlimited; after it, the ticket returns 403. This lets a reader grab block 0 (the envelope) first and then the remaining blocks without burning the ticket. - read_unlimited — minted via
POST /v1/pads/{id}/ticketswith the write key ({"type": "read_unlimited"}); it carries no lease window, so a reviewer can re-read a sealed pad after the pad's TTL or after aread_oncelease has lapsed.{"type": "read_once"}mints another leased ticket. Tickets are scoped to their pad. manifestis free (public metadata) and does not touch the lease.
Payments (txid)
Opt-in pay-per-pad using USDC on Base. Set LOCKER_MODE=txid (default open)
to require proof of payment on POST /v1/pads.
- No
X-Payment-Proofheader →HTTP 402with a JSON challenge (error,network,chain_id8453,currency,token_contract,amount,amount_units,recipient,instructions) plus aWWW-Authenticate: X402 …header. - With
X-Payment-Proof: <tx_hash>→ the daemon verifies on-chain viaeth_getTransactionReceipt: status0x1, a USDCTransferevent toPAYMENT_WALLET_ADDRESSfor ≥REQUIRED_USDC_UNITS, then records the tx in the append-onlypayment_receiptstable (tx_hashis the primary key → replay-proof). lockermcplocker_create/locker_depositaccept an optionalpayment_tx_hashand surface the structured 402 challenge in their error object.
The rail is off by default. Its tests run against mocks; production payment enforcement has not been exercised against a live payment and is unverified.
MCP server (lockermcp)
Six tools over stdio (Python MCP SDK v2, MCPServer):
locker_deposit(envelope, artifacts, ttl_seconds=3600)→{pad_id, read_ticket, head_hash, status}— atomic create + append envelope + append artifacts + seal in one call (recommended for one-shot handoffs; avoids multi-step batching hazards)locker_create(ttl_seconds, max_blocks=32)→{pad_id, write_key, read_ticket}locker_append(pad_id, write_key, payload, content_type="application/json")→{pad_id, seq, curr_hash}locker_seal(pad_id, write_key)→{pad_id, state, sealed_at, head_hash}locker_manifest(pad_id, ticket=None)→ manifest (free on the daemon)locker_read_blocks(pad_id, ticket, from_block=0, to_block=0)→ blocks slice
locker_read_blocks defaults to block 0 (the envelope) first, so a reader
naturally starts with the envelope before requesting the rest.
locker_manifest (when given a ticket) and locker_read_blocks verify the full
hash chain in Python and return an explicit integrity block — the model never
computes SHA-256 itself:
{"integrity": {"chain_valid": true, "blocks_verified": 2}}
chain_valid reports internal consistency only; see Integrity guarantees and
their limits above for what it does not prove.
Example scripts (source distribution and checkout)
These are not part of the installed wheel. They live in the repository (https://github.com/HexHillbilly/agent-locker) and in the source distribution.
# Inspect a pad and verify its chain (stdlib only, no deps)
python scripts/inspect_pad.py <pad_id> --ticket <read_ticket> [--url http://127.0.0.1:8000]
# Two-agent handoff demo against a running daemon (open mode)
python demo_handoff.py [http://127.0.0.1:8000]
# Two-agent handoff over MCP, with its own daemon: planner/worker, post-seal
# rejection, read-lease expiry, tamper detection
python scripts/test_handoff_e2e.py
inspect_pad.py prints pad state, block count, total bytes, sealed status, head
hash, and an explicit SHA-256 chain verification.
Maintainers additionally have scripts/release_build.py (the clean-export +
build command described under Release artifacts) and scripts/dogfood_llm.py
(a live-LLM harness that drives lockermcp end to end with a local model).
Release artifacts
scripts/release_build.py produces release artifacts from an explicit commit:
python scripts/release_build.py <commit-ish> [--out /path/outside/repo]
It resolves the commit to its full object ID, exports that committed source into
a fresh directory (git archive, so dev environments, caches, databases and
stale packaging output are excluded by construction), records a manifest of the
exported files with sizes and SHA-256 hashes, builds sdist + wheel in an isolated
environment, and writes PROVENANCE.json with the source commit, the manifest
hash (the manifest does not list itself), the build-tool versions, and the
artifact hashes. All output lands outside the source tree. Artifact hashes are
recorded for comparison; builds are not claimed to be byte-for-byte
reproducible.
Configuration
| Env var | Default | Meaning |
|---|---|---|
LOCKER_MODE |
open |
open (no payment; aliases local/dev) or txid (Base USDC receipt check; alias x402) |
LOCKER_DB_PATH |
locker.db |
SQLite file path |
LOCKER_HOST |
127.0.0.1 |
bind host (lockerd) |
LOCKER_PORT |
8000 |
bind port (lockerd) |
LOCKER_READ_LEASE_SECONDS |
600 |
read_once lease window |
LOCKER_URL |
http://127.0.0.1:8000 |
daemon URL for lockermcp (required for the MCP server) |
PAYMENT_WALLET_ADDRESS |
(unset) | receiving EVM address (required in txid mode) |
BASE_RPC_URL |
https://mainnet.base.org |
Base JSON-RPC endpoint |
REQUIRED_USDC_UNITS |
2000 |
minimum payment (USDC units, 6 decimals) |
USDC_CONTRACT |
0x8335…A02913 |
Base native USDC token contract |
Hard limits (memory-safe): 64 KB per block, 256 KB per pad (byte quota), 32
blocks default (configurable at creation, capped at 4096), 64 KB payload per
/blocks slice, 256 blocks per slice. Request bodies are streamed with a hard
cap, so an oversized body is rejected before it is fully buffered.
Docker
Dockerfile and docker-compose.yml ship in the source distribution and the
checkout (not in the wheel):
docker compose up -d # builds + runs the daemon on :8000, SQLite at /data
Dockerfile:python:3.11-alpine, plainuvicorn(no native extras to compile on musl), runs as a non-rootlockeruser.- SQLite lives at
/data/locker.dbon a named volume (locker-data) with WAL mode enabled; the-wal/-shmsidecars sit in the same volume.
Host deployment materials — the reverse-proxy config and the public static site
under deploy/ — are repository-only and are not published in the source
distribution. See https://github.com/HexHillbilly/agent-locker/tree/main/deploy.
Testing
pytest -q # full suite: API + quotas + lease + tamper + payments + hardening
.venv/bin/python scripts/test_handoff_e2e.py # two-agent handoff over MCP: planner/worker,
# post-seal rejection, lease expiry, tamper
.venv/bin/python scripts/dogfood_llm.py # live-LLM dogfood: a real model drives lockermcp as
# planner + worker (Ollama; --model qwen2.5:14b recommended)
The live-LLM harness needs a local model and is not part of the release verification.
License
agent-locker is dual-licensed. The source is available under the GNU Affero
General Public License, version 3 or (at your option) any later version
(AGPL-3.0-or-later) — see LICENSE
— matching the license declared in pyproject.toml. For proprietary or hosted
use where AGPL obligations do not fit, a separate commercial license is
available from the project owner. Commercial licenses are available on
request.
Notes
LOCKER_MODE=txidverifies a Base USDC transfer viaeth_getTransactionReceiptand returns HTTP 402 with a challenge when no proof is presented.- The DB schema carries a
tickets.lease_started_atcolumn; the DB file is gitignored/regenerable, so delete an old one rather than migrating. - Deployment observations (which revision a given host is serving) are recorded in RELEASE_NOTES.md, not here, because they go stale.
Metadata
Release files for lockermcp 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| lockermcp-0.1.1.tar.gz | 59.7 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lockermcp-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 97.3 kB
Release files / lockermcp-0.1.1.tar.gz
| Download URL | lockermcp-0.1.1.tar.gz |
|---|---|
| Size | 59.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6069009e157b746bff37b8854609f791fcd69ec4a53d4eb12a6e498b1e25263c
|
|
BLAKE2b-256 checksum How to use checksums |
3dae70a5121e580ba2b59e2622da5516d2beebacccbd3c1861ae570439ea0001
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.15
|
Release files / lockermcp-0.1.1-py3-none-any.whl
| Download URL | lockermcp-0.1.1-py3-none-any.whl |
|---|---|
| Size | 37.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
38f5c5bd4d98120378d43f6fa7e4528abf68df4a05fb28503811b4ab0b617acd
|
|
BLAKE2b-256 checksum How to use checksums |
e5659a7018ab7342e536f3b0b244f78992759c2ffca0df562305eae5d6c9a1c1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.15
|
Security for lockermcp 0.1.1
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
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.