willow-gate
A minimalist check-in / check-out gate for agents. Symmetric 13-field validation, a five-rung trust ladder, an announce-loud-for-the-untrusted inversion, and a PGP-encrypted flat-file ledger. Hard stops, no swallowed exceptions.
From the WillowGate DRAFT_SPEC (v0314), hardened and verified.
The idea
Every agent knocks on the door with the same 13 fields going in and 13 coming
out. What it declares on entry is reconciled against what it actually did on
exit. Trust is not asserted — it is bound: the signature field is an
HMAC over the header keyed by a per-agent secret the gate holds, and a claimed
trust_level is capped at the agent's registered ceiling. "Elder" is not a
text field anyone can type.
Read is universal — but for a true outsider it is not gate-mediated. Exiled
(level 0) is refused a session at check-in (entry_allowed=False); it is the
one level that may not enter. An outsider still reads, loudly, by a path the gate
never claimed to mediate — what it cannot do is take information anywhere else:
export/exfiltration is gated, and the lower the trust, the louder every action is
announced. A session-bearing read is a Rookie+ affair.
Trust levels
| Level | Name | Session | Export | Announcement | Tools |
|---|---|---|---|---|---|
| 0 | Exiled | none — refused at check-in | ❌ | maximum | read (session-less) |
| 1 | Rookie | read-only | ❌ | large | read |
| 2 | Steady | read + write | ✅ | medium | read, write |
| 3 | Veteran | read + write | ✅ | small | + query, execute |
| 4 | Elder | read + write | ✅ | minimal | + admin |
Drift and fail budgets tighten as trust rises — the most powerful rung is held to the strictest tolerances, not the loosest.
Read is universal — and embedders must not inherit that
read is granted by construction, not by tier. It is not read out of
allowed_tools:
check_inunions it into whatever the header declares —declared <= (set(level.allowed_tools) | {READ_TOOL})authorize_toolshort-circuits it —if tool != READ_TOOL and tool not in granted_tools
So a level whose allowed_tools is empty still reads. The only thing that
withholds read is entry: Exiled is entry_allowed=False and refused at
check-in, so it never gets a session to read from. Read-universal therefore means
universal among agents who may enter, not universal full stop — which is why
the table above says "read (session-less)" for level 0.
This is a deliberate choice about willow-gate's own decisions, and it is a floor, not an entitlement you may hand to a host. The gate answers "may this session call this class?" — it does not know what your reads expose. A host with its own ACL, tenancy, or scoping must compose by intersection and stay fail-closed, so that embedding willow-gate can only ever narrow what a caller may read, never widen it:
effective = host_acl(identity) ∩ tier_ceiling(trust_level)
The reference embedding does exactly this. willow-mcp denies an unmanifested or
unscoped app_id even for read, and its store_scope still confines which
collections a permitted read may touch — willow-gate's read-universality does
not survive that seam, by design. If you are wiring the gate into something with
its own notion of who may see what, state which side wins before you ship it.
Inheriting this rule by accident is how a gate that was supposed to restrict an
agent quietly hands it a wider read surface than the host ever granted.
Enforcement vs. audit — read this first
WillowGate prevents only when a harness routes every tool call through
authorize_tool() before the tool runs. Wired into a pre-tool hook it is a
gate: a denied call never executes. Un-wired, it is a loud ledger — it
records and announces, but cannot stop what it is never asked about.
gate.bind_tools(session, tools) is that harness, in-process: it returns a
GatedSession holding the tool callables privately, so call() — which
authorizes before invoking and hard-stops a denied call — is the only path to
a tool. There is no un-gated way to reach the function, so "route every call
through the gate" stops being a convention you have to remember. Because only
authorized calls are ever recorded as used, check_out's reconciliation stays
true for free. Use bind_tools for the in-process case; use the raw
authorize_tool when you are wiring your own external pre-tool hook.
The identity binding is symmetric (HMAC — the gate holds each agent's
secret). Asymmetric "agent signs, gate verifies with only a public key" needs
the signature field widened beyond 64 hex.
Sibling module: the friction floor
willow_gate.friction_floor watches a different surface. WillowGate gates
access — who may do what. The friction floor watches the relationship —
whether an agent has stopped being other and started reflecting the user
back, smoothed, while the user is escalating. It is a deterministic, model-free
smoke detector: it raises a loud flag for a human, it never blocks, and it
must run outside the model it watches, because a mirror can't audit itself.
It flags sustained low friction (no pushback, no outside grounding, mostly echo)
during a ramp — and fails loud, not open. See the module docstring; pytest
pins the behavior.
Install
pip install -e . # python-gnupg is required for the encrypted ledger
Deploying for real use needs /willowgate provisioned first — the gate's
security properties rest on filesystem permissions the code does not set itself,
and the choice between in-process and supervisor topology decides whether the
earned-rung protection holds at all. See
docs/deployment-runbook.md.
Quickstart
from willow_gate import WillowGate
# Ledger encrypts to the operator's PGP key — never a bundled key.
gate = WillowGate(operator_key_fpr="<your PGP fingerprint>")
# Bind an identity to a shared secret and a trust CEILING. Operator-side only.
gate.register_agent("R1", secret=b"...32+ bytes...", max_trust=1)
ok, msg, session = gate.check_in(header) # 13 fields, HMAC-signed
# Prevention harness: the tools are only reachable through the gate.
from willow_gate import Tool
room = gate.bind_tools(session, [
Tool("read", read_fn),
Tool("write", write_fn),
Tool("send", send_fn, export=True), # exfiltrates -> export-gated
])
page = room.call("read") # authorized, then runs
# room.call("write") for a read-only level -> GateError, write_fn never runs
ok, msg = gate.check_out(session, exit_header) # 13 fields, diffed
Prefer room.call(...) when WillowGate is in-process: a denied tool never
runs, and you never have to remember to call authorize_tool first. Drop to
the raw gate.authorize_tool(session, "read") only when you are wiring an
external pre-tool hook yourself.
For local logic testing without PGP, pass require_pgp=False — this writes a
plaintext ledger and is for development only, never production. Pair it with
base_dir= so it does not try to create /willowgate; the default base dir is
absolute by design (see the runbook).
Tests
pip install -e '.[dev]'
pytest
Covers trust binding, the registered ceiling cap, inline authorize_tool
prevention, export denial, nonce replay (including across a restart, via the
persistent nonce store), the reserved trap field, drift limits, the
Exiled entry-refusal (entry_allowed), and symmetric check-out. A separate
PGP round-trip test (skipped if gpg/python-gnupg are unavailable) proves the
encrypted ledger encrypts and decrypts.
License
Apache-2.0
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file willow_gate-0.1.0.tar.gz.
File metadata
- Download URL: willow_gate-0.1.0.tar.gz
- Upload date:
- Size: 100.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7813c749833d2e74a019e70be7d23f3f3c31b5f5861eb5ffecc86eb6773aed87
|
|
| MD5 |
f9d0780d61056d6d05d8d4937eb5411c
|
|
| BLAKE2b-256 |
00644b4d4274d55c4f0f5160b7aea453dd60fffef621a441ed977a4a4a50cf0e
|
Provenance
The following attestation bundles were made for willow_gate-0.1.0.tar.gz:
Publisher:
release.yml on willow-memory/willow-gate
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
willow_gate-0.1.0.tar.gz -
Subject digest:
7813c749833d2e74a019e70be7d23f3f3c31b5f5861eb5ffecc86eb6773aed87 - Sigstore transparency entry: 2697790841
- Sigstore integration time:
-
Permalink:
willow-memory/willow-gate@19252f4864880b798a680a2249a21fb3473562d6 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/willow-memory
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@19252f4864880b798a680a2249a21fb3473562d6 -
Trigger Event:
push
-
Statement type:
File details
Details for the file willow_gate-0.1.0-py3-none-any.whl.
File metadata
- Download URL: willow_gate-0.1.0-py3-none-any.whl
- Upload date:
- Size: 44.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3b92677d992aebe2f9a4820ae4b24e04ae444ff16bf0caa198c29e5b60314053
|
|
| MD5 |
90ed9ccfcd80c3704f09184aa2f39b58
|
|
| BLAKE2b-256 |
c6fcc8ace9dfff7d41d2e2940fc9eb28d664d8ececc2121b4971b2db19025d1b
|
Provenance
The following attestation bundles were made for willow_gate-0.1.0-py3-none-any.whl:
Publisher:
release.yml on willow-memory/willow-gate
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
willow_gate-0.1.0-py3-none-any.whl -
Subject digest:
3b92677d992aebe2f9a4820ae4b24e04ae444ff16bf0caa198c29e5b60314053 - Sigstore transparency entry: 2697790906
- Sigstore integration time:
-
Permalink:
willow-memory/willow-gate@19252f4864880b798a680a2249a21fb3473562d6 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/willow-memory
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@19252f4864880b798a680a2249a21fb3473562d6 -
Trigger Event:
push
-
Statement type: