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.
- permitd-0.1.0/.github/workflows/ci.yml +19 -0
- permitd-0.1.0/.github/workflows/release.yml +32 -0
- permitd-0.1.0/.gitignore +11 -0
- permitd-0.1.0/DESIGN.md +134 -0
- permitd-0.1.0/LICENSE +21 -0
- permitd-0.1.0/PKG-INFO +208 -0
- permitd-0.1.0/README.md +180 -0
- permitd-0.1.0/docs/demo.gif +0 -0
- permitd-0.1.0/examples/mcp_server/README.md +52 -0
- permitd-0.1.0/examples/mcp_server/server.py +85 -0
- permitd-0.1.0/pyproject.toml +48 -0
- permitd-0.1.0/src/permitd/__init__.py +31 -0
- permitd-0.1.0/src/permitd/audit.py +62 -0
- permitd-0.1.0/src/permitd/binding.py +24 -0
- permitd-0.1.0/src/permitd/cli.py +137 -0
- permitd-0.1.0/src/permitd/gate.py +199 -0
- permitd-0.1.0/src/permitd/guard.py +156 -0
- permitd-0.1.0/src/permitd/kernel.py +247 -0
- permitd-0.1.0/src/permitd/permit.py +70 -0
- permitd-0.1.0/src/permitd/store.py +151 -0
- permitd-0.1.0/tests/test_binding.py +18 -0
- permitd-0.1.0/tests/test_cli.py +54 -0
- permitd-0.1.0/tests/test_gate.py +105 -0
- permitd-0.1.0/tests/test_guard.py +79 -0
- permitd-0.1.0/tests/test_kernel.py +194 -0
- permitd-0.1.0/tests/test_store.py +51 -0
|
@@ -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
|
permitd-0.1.0/.gitignore
ADDED
permitd-0.1.0/DESIGN.md
ADDED
|
@@ -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
|
+

|
|
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.
|
permitd-0.1.0/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# permitd
|
|
2
|
+
|
|
3
|
+
**Governed tool execution for agent loops.**
|
|
4
|
+
|
|
5
|
+

|
|
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).
|