permitd 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ name: ci
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: ${{ matrix.python-version }}
18
+ - run: pip install -e ".[dev]"
19
+ - run: pytest -q
@@ -0,0 +1,32 @@
1
+ name: release
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+
6
+ jobs:
7
+ build:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with:
13
+ python-version: "3.12"
14
+ - run: pip install build
15
+ - run: python -m build
16
+ - uses: actions/upload-artifact@v4
17
+ with:
18
+ name: dist
19
+ path: dist/
20
+
21
+ publish:
22
+ needs: build
23
+ runs-on: ubuntu-latest
24
+ environment: pypi
25
+ permissions:
26
+ id-token: write # trusted publishing (OIDC) — no PyPI token stored anywhere
27
+ steps:
28
+ - uses: actions/download-artifact@v4
29
+ with:
30
+ name: dist
31
+ path: dist/
32
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ *.db
9
+ *.db.secret
10
+ *_audit.jsonl
11
+ permitd.secret
@@ -0,0 +1,134 @@
1
+ # permitd — design
2
+
3
+ Created: 2026-07-29
4
+
5
+ ## Step 0 — operator decisions (settled 2026-07-29)
6
+
7
+ 1. **Name:** `permitd`. The daemon-suffix reads as infrastructure, which is the
8
+ positioning: not an agent, not a framework — the small thing that sits under
9
+ an agent loop and holds the line.
10
+ 2. **License:** MIT. This is an adoption/portfolio play; AGPL-by-reflex was
11
+ explicitly rejected for this artifact. MIT over Apache-2.0 for brevity — no
12
+ patent-grant machinery, one page a stranger actually reads.
13
+ 3. **Repo:** `hbar-systems/permitd`, **public from day one**.
14
+
15
+ ## What this is
16
+
17
+ A pip-installable governance kernel for agent tool execution, extracted from
18
+ `brainfoundry-nous` (where it runs in production as the tool-dispatch gate).
19
+ One artifact, two vocabularies, deliberately:
20
+
21
+ - **Governance vocabulary:** permits, per-call approval, fail-closed
22
+ verification, append-only audit.
23
+ - **Loop-engineering vocabulary:** state that survives across agent loop turns.
24
+ A permit proposed in turn N is approved out-of-band and executed in turn
25
+ N+K — the kernel *is* durable loop state for the human-checkpoint layer.
26
+
27
+ ## The flow (the whole library in one line)
28
+
29
+ ```
30
+ propose(tool, args) → Permit(id, signed, TTL, single-use)
31
+ → approve(id) [out-of-band: CLI, HTTP, any callable]
32
+ → execute(tool, args, permit_id) — verify + burn → run
33
+ → one audit line lands (JSONL, append-only)
34
+ ```
35
+
36
+ Every non-execute outcome is fail-closed: missing, expired, denied, tampered,
37
+ args-mismatched, or already-burned permits are refused, and the refusal itself
38
+ is audited.
39
+
40
+ ## Core mechanics (carried over from nous, hardened where noted)
41
+
42
+ - **Binding hash.** A permit is bound to `sha256(tool + "\n" + canonical_args)`
43
+ where `canonical_args` is sorted-key, tight-separator JSON. Approval for
44
+ "send X to Alice" can neither be replayed nor bent to "send Y to Eve" —
45
+ argument order and whitespace can't change the binding; any value change does.
46
+ - **Signed permits (HMAC).** On approve, the kernel mints
47
+ `HMAC-SHA256(secret, permit_id . binding_hash . approved_at)` and stores it on
48
+ the record. At execute time the signature is *recomputed and compared* — a
49
+ store row edited behind the kernel's back (status flipped to approved, hash
50
+ swapped) fails verification. In nous this pattern is the intra-brain signed
51
+ permit (`api/identity/permits.py`); here it degrades gracefully to
52
+ store-tamper evidence since library and store usually share a machine.
53
+ - **Single-use burn, atomic.** nous documented a known limit: its JSON-file
54
+ store + in-process lock is only atomic single-worker. permitd's default store
55
+ is SQLite and the burn is one statement —
56
+ `UPDATE permits SET status='executed' WHERE id=? AND status='approved'` —
57
+ so two processes racing the same permit cannot both pass, without any
58
+ application-level lock.
59
+ - **TTL.** Two clocks, both bounded: a proposal is approvable for
60
+ `ttl_seconds` after propose; a minted approval is executable for
61
+ `ttl_seconds` after approve (default 300s each). Unparseable timestamps
62
+ count as expired (fail closed).
63
+ - **Tiers.** GREEN (run freely, audited), YELLOW (requires standing
64
+ authorization — one operator toggle, audited), RED (per-call permit, the flow
65
+ above). Same semantics as nous's `api/tools/` registry.
66
+ - **Egress guard.** Ported from nous `api/tools/egress.py`: before any
67
+ non-GREEN call runs — including at propose time, so a poisoned proposal never
68
+ even reaches the approval surface — arguments are scanned for
69
+ credential-shaped content (private-key blocks, Bearer/Basic headers,
70
+ AWS/GitHub/Slack/Stripe/OpenAI/Anthropic/Google key shapes, inline
71
+ `secret=...` assignments), for the process's own sensitive env-var *values*,
72
+ and by a conservative high-entropy backstop (URLs excised — signed CDN URLs
73
+ false-positive on entropy; named patterns still scan full text). Refusal
74
+ reasons name the matched *shape*, never the value.
75
+ - **Audit.** Append-only JSONL, one line per outcome (proposed, denied,
76
+ expired, blocked, executed, failed), best-effort by contract: an audit write
77
+ failure must never break the dispatch path. Arg values are trimmed in audit
78
+ lines; the approval surface, by contrast, always shows full args — it is the
79
+ operator's informed-consent surface and must not truncate.
80
+
81
+ ## Storage
82
+
83
+ Pluggable via a small `PermitStore` protocol. Shipped:
84
+
85
+ - `SqliteStore` — default; atomic burn; safe across processes on one machine.
86
+ - `MemoryStore` — tests and ephemeral gates.
87
+
88
+ Audit is a separate append-only JSONL file (not in SQLite) so it stays
89
+ `tail -f`-able and trivially exportable.
90
+
91
+ ## Deliberately OUT
92
+
93
+ Brains, RAG, memory, federation, budgets/metering, any UI beyond the approve
94
+ CLI. No framework dependencies in the core — stdlib only. The MCP server under
95
+ `examples/` is the only place a third-party package (`mcp`) appears, as an
96
+ optional extra (`pip install permitd[mcp]`).
97
+
98
+ The core is synchronous. Tool callables are plain functions; async frameworks
99
+ (the MCP example included) call the gate from their event loop — the gate's own
100
+ work is milliseconds of hashing and one SQLite statement.
101
+
102
+ ## The MCP wedge
103
+
104
+ `examples/mcp_server/` is one example that is also the positioning: an MCP
105
+ server whose tools are gated by the kernel. Any MCP-speaking agent — Claude
106
+ Code included — gets propose/approve/execute + audit for free: the agent calls
107
+ a RED tool, receives "permit PRM-… proposed, waiting for approval", a human
108
+ runs `permitd approve PRM-…` in another terminal, the agent retries and the
109
+ call executes with the audit line landing. No agent-side changes at all.
110
+
111
+ ## Ancestry
112
+
113
+ - `hbar-systems/hbar.brain.console` (2026-03) — the original
114
+ PROPOSE/CONFIRM/EXECUTE + audit-trail prototype; the interaction grammar
115
+ started there.
116
+ - `brainfoundry-nous` — the kernel as lived in production:
117
+ `api/tools/__init__.py` (tiers + dispatch gate), `api/tools/approvals.py`
118
+ (single-use args-bound tokens), `api/tools/egress.py` (outbound scan),
119
+ `api/tools/audit.py` (JSONL trail), `api/identity/permits.py` (typed,
120
+ time-bound signed permits). permitd is an extraction, not a fork: nous keeps
121
+ its copy; divergence is expected and fine.
122
+
123
+ ## Positioning inputs
124
+
125
+ `discussions/2026-07-12_brainfoundry-loop-state-layer-reframe.md` and
126
+ `discussions/2026-07-12_brainfoundry-competitive-landscape-memory-governance.md`
127
+ (hbar.world): the governance kernel and the MCP loop-state wedge are ONE
128
+ artifact; README speaks both languages; ride the loop-engineering vocabulary
129
+ while it is current.
130
+
131
+ ## Acceptance test (the only definition of done)
132
+
133
+ Clean machine, `pip install`, a stranger follows the README:
134
+ propose → approve → execute → the audit line lands. Nothing else counts.
permitd-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hbar-systems
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
permitd-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,208 @@
1
+ Metadata-Version: 2.4
2
+ Name: permitd
3
+ Version: 0.1.0
4
+ Summary: Governed tool execution for agent loops: propose -> permit -> approve -> execute -> audit. Fail-closed, zero dependencies.
5
+ Project-URL: Homepage, https://github.com/hbar-systems/permitd
6
+ Project-URL: Issues, https://github.com/hbar-systems/permitd/issues
7
+ Author: hbar-systems
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent,agent-loops,approval,audit,governance,human-in-the-loop,loop-engineering,mcp,permits,tool-use
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Security
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Requires-Python: >=3.10
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == 'dev'
25
+ Provides-Extra: mcp
26
+ Requires-Dist: mcp>=1.0; extra == 'mcp'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # permitd
30
+
31
+ **Governed tool execution for agent loops.**
32
+
33
+ ![An agent proposes a gated call, a human approves it in another terminal, the call executes, and the audit lines land](docs/demo.gif)
34
+
35
+ Your agent loop wants to send the email, write the file, hit the API. You want
36
+ a human decision in between — one that an agent cannot fake, replay, or bend
37
+ to different arguments — and a line in an audit log either way.
38
+
39
+ ```
40
+ propose(tool, args) ──> permit (signed, TTL, single-use)
41
+ ──> approve (a human, out-of-band: CLI, or any callable)
42
+ ──> execute (verify + burn, atomically)
43
+ ──> one audit line lands (append-only JSONL)
44
+ ```
45
+
46
+ permitd is that flow as a small, stdlib-only Python library. It is also
47
+ **loop state**: the permit lives in SQLite, so a call proposed in turn N of
48
+ your agent loop is approved from another terminal and executed in turn N+K —
49
+ across restarts, across processes.
50
+
51
+ Every non-execute outcome is fail-closed. A missing, expired, denied,
52
+ tampered, argument-mismatched, or already-used permit is refused, and the
53
+ refusal is audited too.
54
+
55
+ ## Install
56
+
57
+ ```
58
+ pip install permitd
59
+ ```
60
+
61
+ Python ≥ 3.10, zero dependencies.
62
+
63
+ ## Sixty seconds, two terminals
64
+
65
+ **Terminal 1 — the agent side** (`agent.py`):
66
+
67
+ ```python
68
+ import time
69
+ from permitd import Gate, RED
70
+
71
+ gate = Gate(db="permitd.db")
72
+
73
+ @gate.tool(tier=RED, description="send a message to someone")
74
+ def send_message(to, body):
75
+ return f"delivered to {to}: {body!r}"
76
+
77
+ args = {"to": "alice", "body": "hello from the loop"}
78
+ r = gate.call("send_message", args)
79
+ print(r.error) # "... permit PRM-xxxx is proposed ..."
80
+
81
+ pid = r.permit["id"]
82
+ while gate.get(pid).status == "proposed": # this state survives restarts
83
+ time.sleep(1)
84
+
85
+ r = gate.call("send_message", args, permit_id=pid)
86
+ print(r.result if r.ok else r.error)
87
+ ```
88
+
89
+ ```
90
+ python agent.py
91
+ ```
92
+
93
+ **Terminal 2 — the human side:**
94
+
95
+ ```
96
+ $ permitd pending
97
+ 1 pending permit(s):
98
+ PRM-3f9c21ab44de [proposed] send_message
99
+ args: {"to": "alice", "body": "hello from the loop"}
100
+ proposed: 2026-07-29T18:12:03+00:00 ttl: 300s
101
+
102
+ $ permitd approve PRM-3f9c21ab44de
103
+ approved — PRM-3f9c21ab44de is executable for 300s, single use, bound to exactly these arguments
104
+ ```
105
+
106
+ Terminal 1 wakes up and prints `delivered to alice: 'hello from the loop'`.
107
+ The trail:
108
+
109
+ ```
110
+ $ permitd audit
111
+ {"ts": "...", "event": "proposed", "permit_id": "PRM-3f9c21ab44de", "tool": "send_message", ...}
112
+ {"ts": "...", "event": "approved", "permit_id": "PRM-3f9c21ab44de", ...}
113
+ {"ts": "...", "event": "executed", "permit_id": "PRM-3f9c21ab44de", "ok": true}
114
+ ```
115
+
116
+ That is the whole product: propose, approve, execute, audit line.
117
+
118
+ ## What the permit actually guarantees
119
+
120
+ - **Bound to exact arguments.** A permit is scoped to
121
+ `sha256(tool + canonical_json(args))`. Approval for "send X to Alice" cannot
122
+ be replayed as "send Y to Eve" — key order and whitespace don't change the
123
+ binding; any value change does (`args_mismatch`).
124
+ - **Single-use, atomically.** The burn is one SQLite compare-and-swap
125
+ (`UPDATE ... WHERE status='approved'`), so two processes racing the same
126
+ permit cannot both pass (`already_used`).
127
+ - **Time-boxed twice.** A proposal is approvable for `ttl_seconds` (default
128
+ 300); a minted approval is executable for another `ttl_seconds`. Unparseable
129
+ timestamps count as expired.
130
+ - **HMAC-signed.** Approval mints
131
+ `HMAC-SHA256(secret, id.binding_hash.approved_at)`, re-verified at execute
132
+ time — a store row edited behind the kernel's back fails (`bad_signature`).
133
+ - **Fail-closed everywhere.** Anything the kernel cannot positively verify —
134
+ including "the arguments could not even be inspected" — is a refusal, not a
135
+ pass. Refusals are audited with their reason.
136
+
137
+ ## Tiers
138
+
139
+ `Gate` is a small registry with three tiers over the kernel:
140
+
141
+ | tier | meaning | gate |
142
+ |---|---|---|
143
+ | `GREEN` | read-only over your own state | runs freely, audited |
144
+ | `YELLOW` | external read (search, fetch) | one standing toggle: `gate.standing_authorization = True` |
145
+ | `RED` | write / exec / send | per-call permit: the flow above |
146
+
147
+ If you don't want the registry, use the kernel directly:
148
+
149
+ ```python
150
+ from permitd import PermitKernel, SqliteStore, AuditLog
151
+
152
+ kernel = PermitKernel(SqliteStore("permitd.db"),
153
+ secret_path="permitd.db.secret",
154
+ audit=AuditLog("permitd_audit.jsonl"))
155
+ p = kernel.propose("deploy", {"target": "prod"})
156
+ kernel.approve(p.id) # or from the CLI / your own UI
157
+ kernel.execute("deploy", {"target": "prod"}, p.id, runner=do_deploy)
158
+ ```
159
+
160
+ `approve` is just a method on a store-backed kernel — call it from a CLI, a
161
+ Slack handler, an HTTP endpoint, wherever your human is.
162
+
163
+ ## The egress guard
164
+
165
+ Before any non-GREEN call runs — **including at propose time** — its arguments
166
+ are scanned for credential-shaped content: private-key blocks, `Bearer`/`Basic`
167
+ headers, AWS/GitHub/Slack/Stripe/OpenAI/Anthropic/Google key shapes, inline
168
+ `api_key=...` assignments, the values of this process's own sensitive
169
+ environment variables, and a conservative high-entropy backstop. A poisoned
170
+ context that steers a secret into a tool argument is refused before anything
171
+ leaves, before any approval card is shown, and the refusal reason names the
172
+ matched *shape*, never the value.
173
+
174
+ ## MCP: gate any agent's tools, including Claude Code's
175
+
176
+ [`examples/mcp_server/`](examples/mcp_server/) is an MCP server whose tools go
177
+ through the gate. Point any MCP-speaking agent at it and that agent gets
178
+ propose → approve → execute + audit with **zero agent-side changes**: the
179
+ agent calls a RED tool, is told "permit PRM-… proposed, waiting for approval",
180
+ you run `permitd approve PRM-…` in another terminal, the agent retries and the
181
+ call executes. The audit line lands either way.
182
+
183
+ ## Storage
184
+
185
+ `SqliteStore` (default, durable, atomic burn) and `MemoryStore` (tests,
186
+ ephemeral) ship in the box. Anything else needs five methods — see the
187
+ `PermitStore` protocol in [`store.py`](src/permitd/store.py); keep `burn`
188
+ compare-and-swap or you lose the one-shot guarantee. The audit trail is a
189
+ separate append-only JSONL file so it stays `tail -f`-able.
190
+
191
+ ## What permitd is not
192
+
193
+ Not an agent, not a framework, not memory, not RAG. It has no opinion about
194
+ your loop, your model, or your prompts. It is the layer that holds the state
195
+ of "may this run?" across turns — and the receipt after it did.
196
+
197
+ ## Ancestry
198
+
199
+ Extracted from the tool-governance kernel of
200
+ [brainfoundry-nous](https://github.com/hbar-systems/brainfoundry-nous), where
201
+ it runs in production gating a personal AI node's tools. The
202
+ propose/confirm/execute + audit grammar goes back to
203
+ [hbar.brain.console](https://github.com/hbar-systems/hbar.brain.console)
204
+ (2026-03). Design notes: [DESIGN.md](DESIGN.md).
205
+
206
+ ## License
207
+
208
+ MIT.
@@ -0,0 +1,180 @@
1
+ # permitd
2
+
3
+ **Governed tool execution for agent loops.**
4
+
5
+ ![An agent proposes a gated call, a human approves it in another terminal, the call executes, and the audit lines land](docs/demo.gif)
6
+
7
+ Your agent loop wants to send the email, write the file, hit the API. You want
8
+ a human decision in between — one that an agent cannot fake, replay, or bend
9
+ to different arguments — and a line in an audit log either way.
10
+
11
+ ```
12
+ propose(tool, args) ──> permit (signed, TTL, single-use)
13
+ ──> approve (a human, out-of-band: CLI, or any callable)
14
+ ──> execute (verify + burn, atomically)
15
+ ──> one audit line lands (append-only JSONL)
16
+ ```
17
+
18
+ permitd is that flow as a small, stdlib-only Python library. It is also
19
+ **loop state**: the permit lives in SQLite, so a call proposed in turn N of
20
+ your agent loop is approved from another terminal and executed in turn N+K —
21
+ across restarts, across processes.
22
+
23
+ Every non-execute outcome is fail-closed. A missing, expired, denied,
24
+ tampered, argument-mismatched, or already-used permit is refused, and the
25
+ refusal is audited too.
26
+
27
+ ## Install
28
+
29
+ ```
30
+ pip install permitd
31
+ ```
32
+
33
+ Python ≥ 3.10, zero dependencies.
34
+
35
+ ## Sixty seconds, two terminals
36
+
37
+ **Terminal 1 — the agent side** (`agent.py`):
38
+
39
+ ```python
40
+ import time
41
+ from permitd import Gate, RED
42
+
43
+ gate = Gate(db="permitd.db")
44
+
45
+ @gate.tool(tier=RED, description="send a message to someone")
46
+ def send_message(to, body):
47
+ return f"delivered to {to}: {body!r}"
48
+
49
+ args = {"to": "alice", "body": "hello from the loop"}
50
+ r = gate.call("send_message", args)
51
+ print(r.error) # "... permit PRM-xxxx is proposed ..."
52
+
53
+ pid = r.permit["id"]
54
+ while gate.get(pid).status == "proposed": # this state survives restarts
55
+ time.sleep(1)
56
+
57
+ r = gate.call("send_message", args, permit_id=pid)
58
+ print(r.result if r.ok else r.error)
59
+ ```
60
+
61
+ ```
62
+ python agent.py
63
+ ```
64
+
65
+ **Terminal 2 — the human side:**
66
+
67
+ ```
68
+ $ permitd pending
69
+ 1 pending permit(s):
70
+ PRM-3f9c21ab44de [proposed] send_message
71
+ args: {"to": "alice", "body": "hello from the loop"}
72
+ proposed: 2026-07-29T18:12:03+00:00 ttl: 300s
73
+
74
+ $ permitd approve PRM-3f9c21ab44de
75
+ approved — PRM-3f9c21ab44de is executable for 300s, single use, bound to exactly these arguments
76
+ ```
77
+
78
+ Terminal 1 wakes up and prints `delivered to alice: 'hello from the loop'`.
79
+ The trail:
80
+
81
+ ```
82
+ $ permitd audit
83
+ {"ts": "...", "event": "proposed", "permit_id": "PRM-3f9c21ab44de", "tool": "send_message", ...}
84
+ {"ts": "...", "event": "approved", "permit_id": "PRM-3f9c21ab44de", ...}
85
+ {"ts": "...", "event": "executed", "permit_id": "PRM-3f9c21ab44de", "ok": true}
86
+ ```
87
+
88
+ That is the whole product: propose, approve, execute, audit line.
89
+
90
+ ## What the permit actually guarantees
91
+
92
+ - **Bound to exact arguments.** A permit is scoped to
93
+ `sha256(tool + canonical_json(args))`. Approval for "send X to Alice" cannot
94
+ be replayed as "send Y to Eve" — key order and whitespace don't change the
95
+ binding; any value change does (`args_mismatch`).
96
+ - **Single-use, atomically.** The burn is one SQLite compare-and-swap
97
+ (`UPDATE ... WHERE status='approved'`), so two processes racing the same
98
+ permit cannot both pass (`already_used`).
99
+ - **Time-boxed twice.** A proposal is approvable for `ttl_seconds` (default
100
+ 300); a minted approval is executable for another `ttl_seconds`. Unparseable
101
+ timestamps count as expired.
102
+ - **HMAC-signed.** Approval mints
103
+ `HMAC-SHA256(secret, id.binding_hash.approved_at)`, re-verified at execute
104
+ time — a store row edited behind the kernel's back fails (`bad_signature`).
105
+ - **Fail-closed everywhere.** Anything the kernel cannot positively verify —
106
+ including "the arguments could not even be inspected" — is a refusal, not a
107
+ pass. Refusals are audited with their reason.
108
+
109
+ ## Tiers
110
+
111
+ `Gate` is a small registry with three tiers over the kernel:
112
+
113
+ | tier | meaning | gate |
114
+ |---|---|---|
115
+ | `GREEN` | read-only over your own state | runs freely, audited |
116
+ | `YELLOW` | external read (search, fetch) | one standing toggle: `gate.standing_authorization = True` |
117
+ | `RED` | write / exec / send | per-call permit: the flow above |
118
+
119
+ If you don't want the registry, use the kernel directly:
120
+
121
+ ```python
122
+ from permitd import PermitKernel, SqliteStore, AuditLog
123
+
124
+ kernel = PermitKernel(SqliteStore("permitd.db"),
125
+ secret_path="permitd.db.secret",
126
+ audit=AuditLog("permitd_audit.jsonl"))
127
+ p = kernel.propose("deploy", {"target": "prod"})
128
+ kernel.approve(p.id) # or from the CLI / your own UI
129
+ kernel.execute("deploy", {"target": "prod"}, p.id, runner=do_deploy)
130
+ ```
131
+
132
+ `approve` is just a method on a store-backed kernel — call it from a CLI, a
133
+ Slack handler, an HTTP endpoint, wherever your human is.
134
+
135
+ ## The egress guard
136
+
137
+ Before any non-GREEN call runs — **including at propose time** — its arguments
138
+ are scanned for credential-shaped content: private-key blocks, `Bearer`/`Basic`
139
+ headers, AWS/GitHub/Slack/Stripe/OpenAI/Anthropic/Google key shapes, inline
140
+ `api_key=...` assignments, the values of this process's own sensitive
141
+ environment variables, and a conservative high-entropy backstop. A poisoned
142
+ context that steers a secret into a tool argument is refused before anything
143
+ leaves, before any approval card is shown, and the refusal reason names the
144
+ matched *shape*, never the value.
145
+
146
+ ## MCP: gate any agent's tools, including Claude Code's
147
+
148
+ [`examples/mcp_server/`](examples/mcp_server/) is an MCP server whose tools go
149
+ through the gate. Point any MCP-speaking agent at it and that agent gets
150
+ propose → approve → execute + audit with **zero agent-side changes**: the
151
+ agent calls a RED tool, is told "permit PRM-… proposed, waiting for approval",
152
+ you run `permitd approve PRM-…` in another terminal, the agent retries and the
153
+ call executes. The audit line lands either way.
154
+
155
+ ## Storage
156
+
157
+ `SqliteStore` (default, durable, atomic burn) and `MemoryStore` (tests,
158
+ ephemeral) ship in the box. Anything else needs five methods — see the
159
+ `PermitStore` protocol in [`store.py`](src/permitd/store.py); keep `burn`
160
+ compare-and-swap or you lose the one-shot guarantee. The audit trail is a
161
+ separate append-only JSONL file so it stays `tail -f`-able.
162
+
163
+ ## What permitd is not
164
+
165
+ Not an agent, not a framework, not memory, not RAG. It has no opinion about
166
+ your loop, your model, or your prompts. It is the layer that holds the state
167
+ of "may this run?" across turns — and the receipt after it did.
168
+
169
+ ## Ancestry
170
+
171
+ Extracted from the tool-governance kernel of
172
+ [brainfoundry-nous](https://github.com/hbar-systems/brainfoundry-nous), where
173
+ it runs in production gating a personal AI node's tools. The
174
+ propose/confirm/execute + audit grammar goes back to
175
+ [hbar.brain.console](https://github.com/hbar-systems/hbar.brain.console)
176
+ (2026-03). Design notes: [DESIGN.md](DESIGN.md).
177
+
178
+ ## License
179
+
180
+ MIT.
Binary file
@@ -0,0 +1,52 @@
1
+ # permitd MCP example
2
+
3
+ Created: 2026-07-29
4
+
5
+ An MCP server whose tools go through the permitd gate. Any MCP-speaking agent
6
+ gets propose → approve → execute + audit for free — no agent-side changes.
7
+
8
+ ## Setup
9
+
10
+ ```
11
+ pip install "permitd[mcp]"
12
+ ```
13
+
14
+ ### With Claude Code
15
+
16
+ ```
17
+ claude mcp add permitd-demo -- python /absolute/path/to/examples/mcp_server/server.py
18
+ ```
19
+
20
+ ### With any other MCP client
21
+
22
+ Run `python server.py` and connect over stdio.
23
+
24
+ ## The demo
25
+
26
+ Ask the agent to *"write a note called hello saying hi"*. It calls
27
+ `write_note` and gets back:
28
+
29
+ ```
30
+ write_note needs operator approval before it runs. Permit PRM-9a1b2c3d4e5f is
31
+ proposed — ask the operator to run `permitd approve PRM-9a1b2c3d4e5f`, then
32
+ retry this exact call with that permit_id. ...
33
+ ```
34
+
35
+ In another terminal (the db lives next to server.py):
36
+
37
+ ```
38
+ permitd --db examples/mcp_server/permitd.db pending
39
+ permitd --db examples/mcp_server/permitd.db approve PRM-9a1b2c3d4e5f
40
+ ```
41
+
42
+ The agent retries `write_note(name, text, permit_id="PRM-9a1b2c3d4e5f")` — the
43
+ note is written. Deny instead, and the retry is refused (`denied`). Change the
44
+ text between propose and execute, and it is refused (`args_mismatch`). Every
45
+ outcome is one line in:
46
+
47
+ ```
48
+ permitd --db examples/mcp_server/permitd.db audit
49
+ ```
50
+
51
+ Tools: `read_notes` (GREEN — runs freely, audited), `write_note` (RED — the
52
+ flow above), `pending_permits` (convenience view).