delego 0.2.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.
- delego/__init__.py +48 -0
- delego/approval.py +108 -0
- delego/audit.py +211 -0
- delego/brokers.py +72 -0
- delego/cli.py +150 -0
- delego/config.py +105 -0
- delego/engine.py +192 -0
- delego/mcp_server.py +196 -0
- delego/models.py +87 -0
- delego/policy.py +187 -0
- delego/util.py +28 -0
- delego-0.2.0.dist-info/METADATA +208 -0
- delego-0.2.0.dist-info/RECORD +17 -0
- delego-0.2.0.dist-info/WHEEL +4 -0
- delego-0.2.0.dist-info/entry_points.txt +3 -0
- delego-0.2.0.dist-info/licenses/LICENSE +202 -0
- policy.example.yaml +55 -0
delego/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""delego — a policy & audit firewall for agent actions.
|
|
2
|
+
|
|
3
|
+
This package authorises an *action* (deterministically, no LLM in the loop)
|
|
4
|
+
before any credential is used, parks sensitive actions for human approval, and
|
|
5
|
+
writes a tamper-evident signed audit chain. See ``ARCHITECTURE.md`` for the design
|
|
6
|
+
invariants and ``examples/demo.py`` for the de facto spec.
|
|
7
|
+
|
|
8
|
+
The names below are the public API: the CLI (``delego.cli``) and MCP server
|
|
9
|
+
(``delego.mcp_server``) are entry points, everything else an integrator needs is
|
|
10
|
+
re-exported here. This module is re-export only — no logic lives here.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .audit import AuditLog, ensure_keys
|
|
16
|
+
from .config import Paths, build_firewall
|
|
17
|
+
from .engine import Firewall
|
|
18
|
+
from .models import (
|
|
19
|
+
OUTCOME_ALLOW,
|
|
20
|
+
OUTCOME_APPROVAL,
|
|
21
|
+
OUTCOME_DENY,
|
|
22
|
+
Decision,
|
|
23
|
+
ProposedAction,
|
|
24
|
+
)
|
|
25
|
+
from .policy import Policy
|
|
26
|
+
|
|
27
|
+
__version__ = "0.2.0" # package (PyPI) version
|
|
28
|
+
|
|
29
|
+
# Highest delego *protocol* version (see the wire spec's "Protocol versions")
|
|
30
|
+
# this reference implements. The spec leads the reference: the spec's version
|
|
31
|
+
# MUST always be >= this. Distinct from __version__, the package release version.
|
|
32
|
+
__protocol_version__ = "0.2.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"ProposedAction",
|
|
36
|
+
"Decision",
|
|
37
|
+
"Firewall",
|
|
38
|
+
"Policy",
|
|
39
|
+
"AuditLog",
|
|
40
|
+
"ensure_keys",
|
|
41
|
+
"Paths",
|
|
42
|
+
"build_firewall",
|
|
43
|
+
"OUTCOME_ALLOW",
|
|
44
|
+
"OUTCOME_DENY",
|
|
45
|
+
"OUTCOME_APPROVAL",
|
|
46
|
+
"__version__",
|
|
47
|
+
"__protocol_version__",
|
|
48
|
+
]
|
delego/approval.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Human-in-the-loop approval queue.
|
|
2
|
+
|
|
3
|
+
When the policy says ``needs_approval``, the firewall parks the action here and
|
|
4
|
+
a human decides out-of-band (via the CLI: ``delego approve <id>``). Each record
|
|
5
|
+
is bound to the action's fingerprint, so an approval can only ever release the
|
|
6
|
+
*exact* action it was granted for.
|
|
7
|
+
|
|
8
|
+
v0.1 storage is an append-only JSONL file (last record per id wins). It's
|
|
9
|
+
deliberately simple and inspectable; a real deployment would put this behind
|
|
10
|
+
the daemon with proper access control.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import secrets
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from .util import now_iso
|
|
21
|
+
|
|
22
|
+
STATUS_PENDING = "pending"
|
|
23
|
+
STATUS_APPROVED = "approved"
|
|
24
|
+
STATUS_DENIED = "denied"
|
|
25
|
+
STATUS_CONSUMED = "consumed" # approved AND already released — single-use, never again
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApprovalStore:
|
|
29
|
+
def __init__(self, path):
|
|
30
|
+
self.path = Path(path)
|
|
31
|
+
|
|
32
|
+
def _read(self) -> dict[str, dict]:
|
|
33
|
+
records: dict[str, dict] = {}
|
|
34
|
+
if not self.path.exists():
|
|
35
|
+
return records
|
|
36
|
+
for line in self.path.read_text(encoding="utf-8").splitlines():
|
|
37
|
+
if line.strip():
|
|
38
|
+
rec = json.loads(line)
|
|
39
|
+
records[rec["id"]] = rec # later (decided) records overwrite earlier ones
|
|
40
|
+
return records
|
|
41
|
+
|
|
42
|
+
def _append(self, rec: dict) -> None:
|
|
43
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
45
|
+
f.write(json.dumps(rec) + "\n")
|
|
46
|
+
|
|
47
|
+
def create(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
action_fingerprint: str,
|
|
51
|
+
intent_hash: str,
|
|
52
|
+
summary: str,
|
|
53
|
+
instruction: str = "",
|
|
54
|
+
rule: Optional[str] = None,
|
|
55
|
+
) -> str:
|
|
56
|
+
approval_id = "apr_" + secrets.token_hex(6)
|
|
57
|
+
self._append(
|
|
58
|
+
{
|
|
59
|
+
"id": approval_id,
|
|
60
|
+
"status": STATUS_PENDING,
|
|
61
|
+
"action_fingerprint": action_fingerprint,
|
|
62
|
+
"intent_hash": intent_hash,
|
|
63
|
+
# The human-readable instruction (so the approver sees *what* they
|
|
64
|
+
# are authorising, not just its hash) and the rule that parked it
|
|
65
|
+
# (so the execution receipt can attribute the allow correctly).
|
|
66
|
+
"instruction": instruction,
|
|
67
|
+
"rule": rule,
|
|
68
|
+
"summary": summary,
|
|
69
|
+
"created_at": now_iso(),
|
|
70
|
+
"decided_at": None,
|
|
71
|
+
"approver": None,
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
return approval_id
|
|
75
|
+
|
|
76
|
+
def get(self, approval_id: str) -> Optional[dict]:
|
|
77
|
+
return self._read().get(approval_id)
|
|
78
|
+
|
|
79
|
+
def decide(self, approval_id: str, approved: bool, approver: str = "cli") -> Optional[dict]:
|
|
80
|
+
rec = self.get(approval_id)
|
|
81
|
+
if rec is None:
|
|
82
|
+
return None
|
|
83
|
+
if rec["status"] != STATUS_PENDING:
|
|
84
|
+
return rec # already decided; idempotent
|
|
85
|
+
updated = {
|
|
86
|
+
**rec,
|
|
87
|
+
"status": STATUS_APPROVED if approved else STATUS_DENIED,
|
|
88
|
+
"decided_at": now_iso(),
|
|
89
|
+
"approver": approver,
|
|
90
|
+
}
|
|
91
|
+
self._append(updated)
|
|
92
|
+
return updated
|
|
93
|
+
|
|
94
|
+
def consume(self, approval_id: str) -> Optional[dict]:
|
|
95
|
+
"""Mark an approved action as released. Single-use: an approval can only
|
|
96
|
+
ever execute once, so a replayed ``resolve`` of the same approved id is
|
|
97
|
+
refused. Transitions ``approved`` -> ``consumed``; a no-op otherwise."""
|
|
98
|
+
rec = self.get(approval_id)
|
|
99
|
+
if rec is None:
|
|
100
|
+
return None
|
|
101
|
+
if rec["status"] != STATUS_APPROVED:
|
|
102
|
+
return rec # only an approved action can be consumed
|
|
103
|
+
updated = {**rec, "status": STATUS_CONSUMED, "consumed_at": now_iso()}
|
|
104
|
+
self._append(updated)
|
|
105
|
+
return updated
|
|
106
|
+
|
|
107
|
+
def pending(self) -> list[dict]:
|
|
108
|
+
return [r for r in self._read().values() if r["status"] == STATUS_PENDING]
|
delego/audit.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Tamper-evident audit log.
|
|
2
|
+
|
|
3
|
+
Every decision the firewall makes is written as a *receipt*. Receipts form a
|
|
4
|
+
hash chain (each carries the previous receipt's hash) and each is signed with a
|
|
5
|
+
local Ed25519 key. That gives two properties a regulator actually asks for:
|
|
6
|
+
|
|
7
|
+
* **Integrity** — editing or deleting any past receipt breaks the chain, and
|
|
8
|
+
re-signing requires the private key.
|
|
9
|
+
* **Reconstructable authority path** — every receipt records the intent hash,
|
|
10
|
+
the action fingerprint, the matched rule, and the outcome, so you can replay
|
|
11
|
+
exactly *why* an action was allowed and *which instruction* authorised it.
|
|
12
|
+
|
|
13
|
+
This is the append-only ledger; ``verify()`` walks and checks the whole chain.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from cryptography.hazmat.primitives import serialization
|
|
25
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
26
|
+
Ed25519PrivateKey,
|
|
27
|
+
Ed25519PublicKey,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from .util import canonical_json, now_iso, sha256_hex
|
|
31
|
+
|
|
32
|
+
# Fields that make up the signed payload, in a fixed set. ``entry_hash`` and
|
|
33
|
+
# ``signature`` are derived from these and excluded from the hashed payload.
|
|
34
|
+
_PAYLOAD_KEYS = (
|
|
35
|
+
"seq",
|
|
36
|
+
"ts",
|
|
37
|
+
"phase",
|
|
38
|
+
"outcome",
|
|
39
|
+
"rule",
|
|
40
|
+
"reasons",
|
|
41
|
+
"intent_hash",
|
|
42
|
+
"action_fingerprint",
|
|
43
|
+
"action_summary",
|
|
44
|
+
"approval_id",
|
|
45
|
+
"prev_hash",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
GENESIS = "GENESIS"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ensure_keys(priv_path: Path, pub_path: Path) -> None:
|
|
52
|
+
"""Generate a local Ed25519 signing keypair if one doesn't exist."""
|
|
53
|
+
priv_path = Path(priv_path)
|
|
54
|
+
pub_path = Path(pub_path)
|
|
55
|
+
if priv_path.exists():
|
|
56
|
+
return
|
|
57
|
+
priv_path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
priv = Ed25519PrivateKey.generate()
|
|
59
|
+
priv_path.write_bytes(
|
|
60
|
+
priv.private_bytes(
|
|
61
|
+
encoding=serialization.Encoding.PEM,
|
|
62
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
63
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
os.chmod(priv_path, 0o600)
|
|
67
|
+
pub_path.write_bytes(
|
|
68
|
+
priv.public_key().public_bytes(
|
|
69
|
+
encoding=serialization.Encoding.PEM,
|
|
70
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AuditLog:
|
|
76
|
+
def __init__(self, path, private_key_path, public_key_path):
|
|
77
|
+
self.path = Path(path)
|
|
78
|
+
self.priv_path = Path(private_key_path)
|
|
79
|
+
self.pub_path = Path(public_key_path)
|
|
80
|
+
self._priv: Optional[Ed25519PrivateKey] = None
|
|
81
|
+
self._pub: Optional[Ed25519PublicKey] = None
|
|
82
|
+
|
|
83
|
+
# -- keys ------------------------------------------------------------- #
|
|
84
|
+
def _load_keys(self) -> None:
|
|
85
|
+
if self._priv is None:
|
|
86
|
+
self._priv = serialization.load_pem_private_key(
|
|
87
|
+
self.priv_path.read_bytes(), password=None
|
|
88
|
+
)
|
|
89
|
+
self._pub = serialization.load_pem_public_key(self.pub_path.read_bytes())
|
|
90
|
+
|
|
91
|
+
# -- read --------------------------------------------------------------#
|
|
92
|
+
def _entries(self) -> list[dict]:
|
|
93
|
+
if not self.path.exists():
|
|
94
|
+
return []
|
|
95
|
+
out = []
|
|
96
|
+
for line in self.path.read_text(encoding="utf-8").splitlines():
|
|
97
|
+
if line.strip():
|
|
98
|
+
out.append(json.loads(line))
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
def _last(self) -> Optional[dict]:
|
|
102
|
+
entries = self._entries()
|
|
103
|
+
return entries[-1] if entries else None
|
|
104
|
+
|
|
105
|
+
# -- write ------------------------------------------------------------ #
|
|
106
|
+
def append(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
phase: str,
|
|
110
|
+
outcome: str,
|
|
111
|
+
rule: Optional[str],
|
|
112
|
+
reasons: list[str],
|
|
113
|
+
intent_hash: str,
|
|
114
|
+
action_fingerprint: str,
|
|
115
|
+
action_summary: str,
|
|
116
|
+
approval_id: Optional[str] = None,
|
|
117
|
+
) -> dict:
|
|
118
|
+
self._load_keys()
|
|
119
|
+
last = self._last()
|
|
120
|
+
seq = (last["seq"] + 1) if last else 0
|
|
121
|
+
prev_hash = last["entry_hash"] if last else GENESIS
|
|
122
|
+
|
|
123
|
+
payload = {
|
|
124
|
+
"seq": seq,
|
|
125
|
+
"ts": now_iso(),
|
|
126
|
+
"phase": phase,
|
|
127
|
+
"outcome": outcome,
|
|
128
|
+
"rule": rule,
|
|
129
|
+
"reasons": reasons,
|
|
130
|
+
"intent_hash": intent_hash,
|
|
131
|
+
"action_fingerprint": action_fingerprint,
|
|
132
|
+
"action_summary": action_summary,
|
|
133
|
+
"approval_id": approval_id,
|
|
134
|
+
"prev_hash": prev_hash,
|
|
135
|
+
}
|
|
136
|
+
entry_hash = sha256_hex(canonical_json(payload))
|
|
137
|
+
signature = self._priv.sign(entry_hash.encode("utf-8")).hex()
|
|
138
|
+
entry = {**payload, "entry_hash": entry_hash, "signature": signature}
|
|
139
|
+
|
|
140
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
142
|
+
f.write(json.dumps(entry) + "\n")
|
|
143
|
+
return entry
|
|
144
|
+
|
|
145
|
+
# -- verify ----------------------------------------------------------- #
|
|
146
|
+
def verify(self) -> tuple[bool, list[str]]:
|
|
147
|
+
"""Walk the chain: recompute hashes, check linkage, verify signatures.
|
|
148
|
+
|
|
149
|
+
Tampering takes many forms — an edited field, a *removed* field, an
|
|
150
|
+
unparseable line — so every step is defensive: a malformed receipt is
|
|
151
|
+
reported as a problem, never allowed to crash the verification (a verifier
|
|
152
|
+
that throws is a verifier an attacker can silence by corrupting one line).
|
|
153
|
+
"""
|
|
154
|
+
self._load_keys()
|
|
155
|
+
problems: list[str] = []
|
|
156
|
+
prev = GENESIS
|
|
157
|
+
if not self.path.exists():
|
|
158
|
+
return True, []
|
|
159
|
+
for lineno, line in enumerate(self.path.read_text(encoding="utf-8").splitlines()):
|
|
160
|
+
if not line.strip():
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
e = json.loads(line)
|
|
164
|
+
except Exception:
|
|
165
|
+
problems.append(f"line {lineno}: unparseable receipt (corrupt)")
|
|
166
|
+
prev = None # the chain cannot link across a broken line
|
|
167
|
+
continue
|
|
168
|
+
where = f"seq {e['seq']}" if isinstance(e, dict) and "seq" in e else f"line {lineno}"
|
|
169
|
+
try:
|
|
170
|
+
payload = {k: e[k] for k in _PAYLOAD_KEYS}
|
|
171
|
+
except (KeyError, TypeError):
|
|
172
|
+
missing = [k for k in _PAYLOAD_KEYS if not (isinstance(e, dict) and k in e)]
|
|
173
|
+
problems.append(f"{where}: missing field(s) {missing} (tampered)")
|
|
174
|
+
prev = e.get("entry_hash") if isinstance(e, dict) else None
|
|
175
|
+
continue
|
|
176
|
+
recomputed = sha256_hex(canonical_json(payload))
|
|
177
|
+
if recomputed != e.get("entry_hash"):
|
|
178
|
+
problems.append(f"{where}: content hash mismatch (tampered)")
|
|
179
|
+
if e.get("prev_hash") != prev:
|
|
180
|
+
problems.append(f"{where}: broken chain link")
|
|
181
|
+
try:
|
|
182
|
+
self._pub.verify(bytes.fromhex(e["signature"]), e["entry_hash"].encode("utf-8"))
|
|
183
|
+
except Exception:
|
|
184
|
+
problems.append(f"{where}: bad signature")
|
|
185
|
+
prev = e.get("entry_hash")
|
|
186
|
+
return (len(problems) == 0), problems
|
|
187
|
+
|
|
188
|
+
# -- queries ---------------------------------------------------------- #
|
|
189
|
+
def tail(self, n: int = 20) -> list[dict]:
|
|
190
|
+
return self._entries()[-n:]
|
|
191
|
+
|
|
192
|
+
def count_allows(self, rule: str, within_seconds: int) -> int:
|
|
193
|
+
"""How many times ``rule`` has been allowed within the time window.
|
|
194
|
+
|
|
195
|
+
Used by the rate-limit constraint. Counts ``allow`` receipts (the moment
|
|
196
|
+
of authorisation) for the given rule.
|
|
197
|
+
"""
|
|
198
|
+
from time import time
|
|
199
|
+
|
|
200
|
+
cutoff = time() - within_seconds
|
|
201
|
+
count = 0
|
|
202
|
+
for e in self._entries():
|
|
203
|
+
if e.get("rule") != rule or e.get("outcome") != "allow":
|
|
204
|
+
continue
|
|
205
|
+
try:
|
|
206
|
+
ts = datetime.fromisoformat(e["ts"]).timestamp()
|
|
207
|
+
except Exception:
|
|
208
|
+
continue
|
|
209
|
+
if ts >= cutoff:
|
|
210
|
+
count += 1
|
|
211
|
+
return count
|
delego/brokers.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Broker adapters: where the *authorised* action actually gets executed.
|
|
2
|
+
|
|
3
|
+
This firewall does not hold credentials. Once it has authorised an action, it
|
|
4
|
+
hands the action to a broker that injects the user's credential and forwards the
|
|
5
|
+
request upstream. The agent — and this firewall — never see the secret.
|
|
6
|
+
|
|
7
|
+
That is the existing, crowded layer (Infisical Agent Vault, OneCLI, Browser Use,
|
|
8
|
+
etc.). The point of keeping it behind a thin ``BrokerAdapter`` interface is that
|
|
9
|
+
you ride that layer instead of rebuilding it: swap ``NullBroker`` for a real
|
|
10
|
+
adapter and the decision/audit logic above is unchanged.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
from .models import ProposedAction
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class BrokerAdapter(Protocol):
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
def execute(self, action: ProposedAction) -> dict[str, Any]:
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NullBroker:
|
|
29
|
+
"""Default stand-in broker. Holds NO credentials and makes NO real request.
|
|
30
|
+
|
|
31
|
+
It records what *would* have been sent so the full decision -> execution
|
|
32
|
+
loop is observable end to end. Use it for local development, demos, and
|
|
33
|
+
tests; replace it with a real adapter for anything that touches a live
|
|
34
|
+
service.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name = "null"
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self.sent: list[dict] = []
|
|
41
|
+
|
|
42
|
+
def execute(self, action: ProposedAction) -> dict[str, Any]:
|
|
43
|
+
record = {
|
|
44
|
+
"broker": self.name,
|
|
45
|
+
"would_send": action.summary(),
|
|
46
|
+
"note": "stub: no credential injected, no upstream request made",
|
|
47
|
+
}
|
|
48
|
+
self.sent.append(record)
|
|
49
|
+
return {"status": "simulated", "detail": record}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HTTPProxyBroker:
|
|
53
|
+
"""Sketch of a real adapter — NOT wired up in v0.1.
|
|
54
|
+
|
|
55
|
+
In production this points at a running credential broker (for example
|
|
56
|
+
OneCLI's gateway on ``localhost:10255``, or an Agent Vault proxy). The broker
|
|
57
|
+
matches a credential by host/path, injects it, and forwards the request. The
|
|
58
|
+
firewall has already decided the action is allowed; this adapter only carries
|
|
59
|
+
it through the component that actually holds the secret.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
name = "http_proxy"
|
|
63
|
+
|
|
64
|
+
def __init__(self, proxy_url: str) -> None:
|
|
65
|
+
self.proxy_url = proxy_url
|
|
66
|
+
|
|
67
|
+
def execute(self, action: ProposedAction) -> dict[str, Any]:
|
|
68
|
+
raise NotImplementedError(
|
|
69
|
+
"Wire this to your credential broker. Route the request through "
|
|
70
|
+
f"{self.proxy_url!r}; the broker injects the credential and forwards "
|
|
71
|
+
"upstream. The firewall has already authorised this action."
|
|
72
|
+
)
|
delego/cli.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""``delego`` command-line interface.
|
|
2
|
+
|
|
3
|
+
Covers the human side of the loop: initialise state and keys, inspect the
|
|
4
|
+
policy, review and decide pending approvals, and read/verify the audit ledger.
|
|
5
|
+
The agent side goes through the MCP server (``delego.mcp_server``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from .audit import AuditLog, ensure_keys
|
|
17
|
+
from .config import Paths, ensure_home_gitignore
|
|
18
|
+
from .policy import Policy
|
|
19
|
+
|
|
20
|
+
_EXAMPLE_POLICY = Path(__file__).resolve().parent.parent / "policy.example.yaml"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group()
|
|
24
|
+
@click.option("--home", default=None, help="Delego home dir (default: $DELEGO_HOME or ~/.delego)")
|
|
25
|
+
@click.pass_context
|
|
26
|
+
def cli(ctx: click.Context, home: str | None) -> None:
|
|
27
|
+
"""Delego — a policy & audit firewall for agent actions."""
|
|
28
|
+
ctx.obj = Paths.resolve(home)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@cli.command()
|
|
32
|
+
@click.pass_obj
|
|
33
|
+
def init(paths: Paths) -> None:
|
|
34
|
+
"""Create the home dir, generate signing keys, install an example policy."""
|
|
35
|
+
paths.home.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
ensure_home_gitignore(paths.home)
|
|
37
|
+
ensure_keys(paths.private_key, paths.public_key)
|
|
38
|
+
if not paths.policy.exists():
|
|
39
|
+
if _EXAMPLE_POLICY.exists():
|
|
40
|
+
shutil.copy(_EXAMPLE_POLICY, paths.policy)
|
|
41
|
+
click.echo(f"Installed example policy at {paths.policy}")
|
|
42
|
+
else:
|
|
43
|
+
click.echo("No example policy found; create policy.yaml yourself.")
|
|
44
|
+
click.echo(f"Initialised delego home at {paths.home}")
|
|
45
|
+
click.echo(f"Signing key: {paths.private_key}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@cli.command()
|
|
49
|
+
@click.pass_obj
|
|
50
|
+
def home(paths: Paths) -> None:
|
|
51
|
+
"""Print the resolved delego home (where state + policy live)."""
|
|
52
|
+
click.echo(str(paths.home))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@cli.command()
|
|
56
|
+
@click.pass_obj
|
|
57
|
+
def policy(paths: Paths) -> None:
|
|
58
|
+
"""Show the loaded policy summary."""
|
|
59
|
+
click.echo(f"home: {paths.home}")
|
|
60
|
+
p = Policy.load(paths.policy)
|
|
61
|
+
click.echo(f"policy v{p.version} | default: {p.default}")
|
|
62
|
+
click.echo("\nforbidden:")
|
|
63
|
+
for r in p.forbidden:
|
|
64
|
+
click.echo(f" - {r.name}: {r.match}")
|
|
65
|
+
click.echo("\nrules:")
|
|
66
|
+
for r in p.rules:
|
|
67
|
+
line = f" - {r.name} -> {r.decision} | {r.match}"
|
|
68
|
+
if r.constraints:
|
|
69
|
+
line += f" | constraints: {r.constraints}"
|
|
70
|
+
click.echo(line)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@cli.command()
|
|
74
|
+
@click.pass_obj
|
|
75
|
+
def pending(paths: Paths) -> None:
|
|
76
|
+
"""List actions awaiting human approval."""
|
|
77
|
+
from .approval import ApprovalStore
|
|
78
|
+
|
|
79
|
+
items = ApprovalStore(paths.approvals).pending()
|
|
80
|
+
if not items:
|
|
81
|
+
click.echo("No pending approvals.")
|
|
82
|
+
return
|
|
83
|
+
for rec in items:
|
|
84
|
+
click.echo(f"{rec['id']} {rec['summary']}")
|
|
85
|
+
if rec.get("instruction"):
|
|
86
|
+
click.echo(f" instruction: {rec['instruction']!r}")
|
|
87
|
+
click.echo(f" requested {rec['created_at']}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@cli.command()
|
|
91
|
+
@click.argument("approval_id")
|
|
92
|
+
@click.option("--as", "approver", default="cli", help="Name recorded as approver")
|
|
93
|
+
@click.pass_obj
|
|
94
|
+
def approve(paths: Paths, approval_id: str, approver: str) -> None:
|
|
95
|
+
"""Approve a pending action."""
|
|
96
|
+
_decide(paths, approval_id, True, approver)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@cli.command()
|
|
100
|
+
@click.argument("approval_id")
|
|
101
|
+
@click.option("--as", "approver", default="cli", help="Name recorded as approver")
|
|
102
|
+
@click.pass_obj
|
|
103
|
+
def deny(paths: Paths, approval_id: str, approver: str) -> None:
|
|
104
|
+
"""Deny a pending action."""
|
|
105
|
+
_decide(paths, approval_id, False, approver)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _decide(paths: Paths, approval_id: str, approved: bool, approver: str) -> None:
|
|
109
|
+
from .approval import ApprovalStore
|
|
110
|
+
|
|
111
|
+
rec = ApprovalStore(paths.approvals).decide(approval_id, approved, approver)
|
|
112
|
+
if rec is None:
|
|
113
|
+
click.echo(f"No such approval: {approval_id}")
|
|
114
|
+
raise SystemExit(1)
|
|
115
|
+
click.echo(f"{approval_id}: {rec['status']}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@cli.command()
|
|
119
|
+
@click.option("-n", "--lines", default=20, help="Number of receipts to show")
|
|
120
|
+
@click.pass_obj
|
|
121
|
+
def log(paths: Paths, lines: int) -> None:
|
|
122
|
+
"""Show the most recent audit receipts."""
|
|
123
|
+
audit = AuditLog(paths.audit_log, paths.private_key, paths.public_key)
|
|
124
|
+
for e in audit.tail(lines):
|
|
125
|
+
click.echo(
|
|
126
|
+
f"#{e['seq']} [{e['phase']}/{e['outcome']}] {e['action_summary']}"
|
|
127
|
+
+ (f" rule={e['rule']}" if e["rule"] else "")
|
|
128
|
+
)
|
|
129
|
+
for r in e["reasons"]:
|
|
130
|
+
click.echo(f" - {r}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@cli.command()
|
|
134
|
+
@click.pass_obj
|
|
135
|
+
def verify(paths: Paths) -> None:
|
|
136
|
+
"""Verify the audit chain (hashes, linkage, signatures)."""
|
|
137
|
+
click.echo(f"home: {paths.home}")
|
|
138
|
+
audit = AuditLog(paths.audit_log, paths.private_key, paths.public_key)
|
|
139
|
+
ok, problems = audit.verify()
|
|
140
|
+
if ok:
|
|
141
|
+
click.echo("Audit chain OK: all receipts intact and signed.")
|
|
142
|
+
else:
|
|
143
|
+
click.echo("AUDIT CHAIN FAILED:")
|
|
144
|
+
for p in problems:
|
|
145
|
+
click.echo(f" - {p}")
|
|
146
|
+
raise SystemExit(1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
cli()
|
delego/config.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Where state lives on disk, and how to assemble a ``Firewall`` from it.
|
|
2
|
+
|
|
3
|
+
The delego home directory holds the signing keys, the policy, the audit ledger,
|
|
4
|
+
and the approval queue. ``build_firewall`` is the single wiring point used by
|
|
5
|
+
both the CLI and the MCP server, so they operate on the same state.
|
|
6
|
+
|
|
7
|
+
Home resolution (see ``Paths.resolve``) lets state be per-user (``~/.delego``)
|
|
8
|
+
or project-scoped and co-located with Claude Code config (``.claude/.delego``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .approval import ApprovalStore
|
|
18
|
+
from .audit import AuditLog, ensure_keys
|
|
19
|
+
from .brokers import BrokerAdapter
|
|
20
|
+
from .engine import Firewall
|
|
21
|
+
from .policy import Policy
|
|
22
|
+
|
|
23
|
+
# State that must never be committed when the home lives inside a repo
|
|
24
|
+
# (e.g. ``.claude/.delego``). ``policy.yaml`` is intentionally left trackable so
|
|
25
|
+
# a team can version and share the policy; secrets and the ledger are not.
|
|
26
|
+
_STATE_GITIGNORE = """\
|
|
27
|
+
# delego runtime state — do not commit secrets or the audit ledger.
|
|
28
|
+
signing_key.pem
|
|
29
|
+
audit.log.jsonl
|
|
30
|
+
approvals.jsonl
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Paths:
|
|
36
|
+
home: Path
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def resolve(cls, home: str | os.PathLike | None = None) -> "Paths":
|
|
40
|
+
"""Resolve the delego home, in precedence order:
|
|
41
|
+
|
|
42
|
+
1. an explicit ``home`` argument (the CLI ``--home`` flag);
|
|
43
|
+
2. the ``DELEGO_HOME`` environment variable;
|
|
44
|
+
3. a project-local ``./.claude/.delego`` directory **if it already
|
|
45
|
+
exists** — only the current directory is checked, never a parent, so
|
|
46
|
+
the home can't be silently picked up from an ancestor;
|
|
47
|
+
4. the per-user default ``~/.delego``.
|
|
48
|
+
|
|
49
|
+
The MCP server and CLI both resolve through here. Set ``DELEGO_HOME``
|
|
50
|
+
(the MCP config does) to pin the home explicitly and not depend on the
|
|
51
|
+
working directory.
|
|
52
|
+
"""
|
|
53
|
+
if home is not None:
|
|
54
|
+
return cls(home=Path(home))
|
|
55
|
+
|
|
56
|
+
env = os.environ.get("DELEGO_HOME")
|
|
57
|
+
if env:
|
|
58
|
+
return cls(home=Path(env))
|
|
59
|
+
|
|
60
|
+
project_local = Path.cwd() / ".claude" / ".delego"
|
|
61
|
+
if project_local.is_dir():
|
|
62
|
+
return cls(home=project_local)
|
|
63
|
+
|
|
64
|
+
return cls(home=Path.home() / ".delego")
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def private_key(self) -> Path:
|
|
68
|
+
return self.home / "signing_key.pem"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def public_key(self) -> Path:
|
|
72
|
+
return self.home / "signing_key.pub"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def audit_log(self) -> Path:
|
|
76
|
+
return self.home / "audit.log.jsonl"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def approvals(self) -> Path:
|
|
80
|
+
return self.home / "approvals.jsonl"
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def policy(self) -> Path:
|
|
84
|
+
return self.home / "policy.yaml"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ensure_home_gitignore(home: str | os.PathLike) -> None:
|
|
88
|
+
"""Drop a ``.gitignore`` in the home so keys/ledger aren't committed when the
|
|
89
|
+
home lives inside a repo (e.g. ``.claude/.delego``). No-op if one exists."""
|
|
90
|
+
home = Path(home)
|
|
91
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
gitignore = home / ".gitignore"
|
|
93
|
+
if not gitignore.exists():
|
|
94
|
+
gitignore.write_text(_STATE_GITIGNORE, encoding="utf-8")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_firewall(paths: Paths, broker: BrokerAdapter | None = None) -> Firewall:
|
|
98
|
+
# Load policy first: a missing/invalid policy fails closed with a clear
|
|
99
|
+
# message before any state (keys, ledger) is created.
|
|
100
|
+
policy = Policy.load(paths.policy)
|
|
101
|
+
ensure_home_gitignore(paths.home)
|
|
102
|
+
ensure_keys(paths.private_key, paths.public_key)
|
|
103
|
+
audit = AuditLog(paths.audit_log, paths.private_key, paths.public_key)
|
|
104
|
+
approvals = ApprovalStore(paths.approvals)
|
|
105
|
+
return Firewall(policy, audit, approvals, broker=broker)
|