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