wardn 0.1.0.dev0__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.
wardn/ledger.py ADDED
@@ -0,0 +1,237 @@
1
+ """The ledger: append-only JSON Lines, timestamp-free, byte-deterministic.
2
+
3
+ One header line, then a closed set of five event kinds:
4
+
5
+ attempt — a tool call reached the gate; written BEFORE forwarding, so a
6
+ crash mid-call leaves "committed, no result recorded" instead
7
+ of silence. Carries the full decision.
8
+ approval — the human verdict for a quarantined attempt (an INPUT: replay
9
+ feeds it back in; it is not recomputed).
10
+ capture — the pre-state wardn read before forwarding, plus the resolved
11
+ undo call and revert-time check. Revert needs the file alone.
12
+ result — what the server returned for a forwarded call.
13
+ tools — the annotation snapshot from a tools/list response passing
14
+ through (what the server CLAIMED; classification input).
15
+
16
+ Determinism: no timestamps, no PIDs, no nonces. `run_id` is a workload
17
+ label — identical across runs you intend to compare; uniqueness lives in
18
+ the file name. Two identical runs (same policy, same requests, same
19
+ approvals, same server behavior) emit byte-identical files.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from pathlib import Path
26
+
27
+ from ._version import __version__
28
+ from .engine import Engine
29
+ from .policy import Policy
30
+ from .types import Decision, ToolAnnotations, ToolRequest, WardnError
31
+
32
+ LEDGER_FORMAT = "wardn-ledger/1"
33
+
34
+ EVENT_KINDS = ("attempt", "approval", "capture", "result", "tools")
35
+
36
+
37
+ class LedgerError(WardnError):
38
+ pass
39
+
40
+
41
+ class ReplayDivergence(WardnError):
42
+ pass
43
+
44
+
45
+ def _line(obj: dict) -> bytes:
46
+ return (json.dumps(obj, separators=(",", ":"), ensure_ascii=True) + "\n") \
47
+ .encode("utf-8")
48
+
49
+
50
+ class LedgerWriter:
51
+ """Appends one event per line, flushed per event (crash honesty)."""
52
+
53
+ def __init__(self, path, run_id: str, policy: Policy, servers: list):
54
+ self.path = Path(path)
55
+ self.path.parent.mkdir(parents=True, exist_ok=True)
56
+ header = {
57
+ "format": LEDGER_FORMAT,
58
+ "run_id": run_id,
59
+ "provenance": {
60
+ "wardn_version": __version__,
61
+ "servers": list(servers),
62
+ "policy": policy.provenance_lines(),
63
+ "policy_hash": policy.hash(),
64
+ },
65
+ }
66
+ self._fh = self.path.open("wb")
67
+ self._emit(header)
68
+
69
+ def _emit(self, obj: dict) -> None:
70
+ self._fh.write(_line(obj))
71
+ self._fh.flush()
72
+
73
+ def attempt(self, seq: int, req: ToolRequest, decision: Decision) -> None:
74
+ self._emit({"kind": "attempt", "seq": seq, "server": req.server,
75
+ "tool": req.tool, "args": req.args,
76
+ "matched_rule": decision.matched_rule,
77
+ "effect": decision.effect,
78
+ "effect_source": decision.effect_source,
79
+ "disposition": decision.disposition,
80
+ "reason": decision.reason, "cost": decision.cost,
81
+ "refusal": decision.refusal})
82
+
83
+ def approval(self, seq: int, verdict: str, by: str) -> None:
84
+ self._emit({"kind": "approval", "seq": seq, "verdict": verdict,
85
+ "by": by})
86
+
87
+ def capture(self, seq: int, tool: str, args: dict, state: dict,
88
+ undo: dict | None, check: dict | None) -> None:
89
+ self._emit({"kind": "capture", "seq": seq, "tool": tool, "args": args,
90
+ "state": state, "undo": undo, "check": check})
91
+
92
+ def result(self, seq: int, is_error: bool, content: list) -> None:
93
+ self._emit({"kind": "result", "seq": seq, "is_error": is_error,
94
+ "content": content})
95
+
96
+ def tools(self, server: str, hints: dict) -> None:
97
+ """hints: tool name -> {"readOnlyHint": ..., "destructiveHint": ...}"""
98
+ self._emit({"kind": "tools", "server": server,
99
+ "tools": {name: hints[name] for name in sorted(hints)}})
100
+
101
+ def close(self) -> None:
102
+ self._fh.close()
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Reading
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def read_ledger(path) -> tuple:
110
+ """(header, events). Refuses any format other than wardn-ledger/1."""
111
+ if not Path(path).exists():
112
+ raise LedgerError(f"{path}: no such file")
113
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
114
+ if not lines:
115
+ raise LedgerError(f"{path}: empty file")
116
+ header = json.loads(lines[0])
117
+ if header.get("format") != LEDGER_FORMAT:
118
+ raise LedgerError(
119
+ f"{path}: format {header.get('format')!r} is not {LEDGER_FORMAT!r}")
120
+ events = [json.loads(line) for line in lines[1:]]
121
+ for event in events:
122
+ if event.get("kind") not in EVENT_KINDS:
123
+ raise LedgerError(f"{path}: unknown event kind {event.get('kind')!r}")
124
+ return header, events
125
+
126
+
127
+ class Folded:
128
+ """One attempt with its approval / capture / result gathered up."""
129
+
130
+ def __init__(self, attempt: dict):
131
+ self.attempt = attempt
132
+ self.approval: dict | None = None
133
+ self.capture: dict | None = None
134
+ self.result: dict | None = None
135
+
136
+ @property
137
+ def seq(self) -> int:
138
+ return self.attempt["seq"]
139
+
140
+ @property
141
+ def committed(self) -> bool:
142
+ """Was this call committed for forwarding? (It may still have no
143
+ result — a crash between commit and response is exactly the case
144
+ the event split exists for.)"""
145
+ if self.attempt["refusal"]:
146
+ return False
147
+ if self.attempt["disposition"] == "quarantine":
148
+ return self.approval is not None \
149
+ and self.approval["verdict"] == "approve"
150
+ return True
151
+
152
+ @property
153
+ def executed(self) -> bool:
154
+ """Committed, forwarded, and the server reported success."""
155
+ return self.committed and self.result is not None \
156
+ and not self.result["is_error"]
157
+
158
+
159
+ def fold(events: list) -> list:
160
+ """Events -> Folded attempts, in seq order."""
161
+ by_seq: dict = {}
162
+ for event in events:
163
+ kind = event["kind"]
164
+ if kind == "tools":
165
+ continue
166
+ if kind == "attempt":
167
+ by_seq[event["seq"]] = Folded(event)
168
+ else:
169
+ folded = by_seq.get(event["seq"])
170
+ if folded is None:
171
+ raise LedgerError(
172
+ f"{kind} event for seq {event['seq']} without an attempt")
173
+ setattr(folded, kind, event)
174
+ return [by_seq[seq] for seq in sorted(by_seq)]
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Offline replay: policy + file -> the same decisions. No server, no queue.
179
+ # ---------------------------------------------------------------------------
180
+
181
+ def replay(policy: Policy, path) -> int:
182
+ """Re-derives every decision and compares it with what the ledger
183
+ recorded. Approvals and annotation snapshots are read back as inputs.
184
+ Returns the number of attempts replayed; raises ReplayDivergence on the
185
+ first mismatch."""
186
+ header, events = read_ledger(path)
187
+ recorded_hash = header["provenance"]["policy_hash"]
188
+ if recorded_hash != policy.hash():
189
+ raise ReplayDivergence(
190
+ f"policy hash mismatch: ledger has {recorded_hash}, "
191
+ f"this policy is {policy.hash()} — not the policy that ran")
192
+
193
+ engine = Engine(policy)
194
+ annotations: dict = {} # server name -> {tool name -> ToolAnnotations}
195
+ pending: dict = {} # seq -> Decision awaiting its approval event
196
+ attempts = 0
197
+
198
+ for event in events:
199
+ kind = event["kind"]
200
+ if kind == "tools":
201
+ # wholesale replacement per server, mirroring the proxy: a
202
+ # fresh snapshot forgets tools the server no longer lists
203
+ annotations[event["server"]] = {
204
+ name: ToolAnnotations(read_only=h.get("readOnlyHint"),
205
+ destructive=h.get("destructiveHint"))
206
+ for name, h in event["tools"].items()}
207
+ elif kind == "attempt":
208
+ req = ToolRequest(server=event["server"], tool=event["tool"],
209
+ args=event["args"])
210
+ decision = engine.decide(
211
+ req, annotations.get(req.server, {}).get(req.tool))
212
+ recorded = tuple(event[f] for f in
213
+ ("matched_rule", "effect", "effect_source",
214
+ "disposition", "reason", "cost", "refusal"))
215
+ derived = (decision.matched_rule, decision.effect,
216
+ decision.effect_source, decision.disposition,
217
+ decision.reason, decision.cost, decision.refusal)
218
+ if recorded != derived:
219
+ raise ReplayDivergence(
220
+ f"seq {event['seq']}: ledger says {recorded}, "
221
+ f"policy derives {derived}")
222
+ attempts += 1
223
+ if not decision.refusal:
224
+ if decision.disposition == "quarantine":
225
+ pending[event["seq"]] = decision
226
+ else:
227
+ engine.note_forwarded(decision)
228
+ elif kind == "approval":
229
+ decision = pending.pop(event["seq"], None)
230
+ if decision is None:
231
+ raise ReplayDivergence(
232
+ f"seq {event['seq']}: approval without pending quarantine")
233
+ if event["verdict"] == "approve":
234
+ engine.note_forwarded(decision)
235
+ # capture / result events carry no decision to re-derive
236
+
237
+ return attempts
wardn/matchers.py ADDED
@@ -0,0 +1,62 @@
1
+ """Matching: server exact, tool glob, argument constraints on dotted paths.
2
+
3
+ Argument constraints are STRING and NUMBER constraints, not path resolution:
4
+ `path_prefix: "out/"` happily matches `"out/../secret.txt"`. wardn does not
5
+ normalize `..` or resolve symlinks — that is a sandbox's job, and wardn is
6
+ not a sandbox.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import fnmatch
12
+
13
+ from .policy import Match
14
+
15
+ _MISSING = object()
16
+
17
+
18
+ def lookup(args: dict, dotted: str):
19
+ current = args
20
+ for part in dotted.split("."):
21
+ if isinstance(current, dict) and part in current:
22
+ current = current[part]
23
+ else:
24
+ return _MISSING
25
+ return current
26
+
27
+
28
+ def arg_matches(constraint: dict, value) -> bool:
29
+ for op, want in constraint.items():
30
+ if op == "equals":
31
+ if value != want:
32
+ return False
33
+ elif op == "glob":
34
+ if not (isinstance(value, str)
35
+ and fnmatch.fnmatchcase(value, want)):
36
+ return False
37
+ elif op == "path_prefix":
38
+ if not (isinstance(value, str) and value.startswith(want)):
39
+ return False
40
+ elif op == "lte":
41
+ if not (isinstance(value, (int, float))
42
+ and not isinstance(value, bool) and value <= want):
43
+ return False
44
+ elif op == "gte":
45
+ if not (isinstance(value, (int, float))
46
+ and not isinstance(value, bool) and value >= want):
47
+ return False
48
+ else: # unreachable after strict policy loading; loud anyway
49
+ raise ValueError(f"unknown arg operator: {op}")
50
+ return True
51
+
52
+
53
+ def match_matches(match: Match, server: str, tool: str, args: dict) -> bool:
54
+ if match.server is not None and match.server != server:
55
+ return False
56
+ if match.tool is not None and not fnmatch.fnmatchcase(tool, match.tool):
57
+ return False
58
+ for path, constraint in match.args.items():
59
+ value = lookup(args, path)
60
+ if value is _MISSING or not arg_matches(constraint, value):
61
+ return False
62
+ return True
wardn/orchestrator.py ADDED
@@ -0,0 +1,122 @@
1
+ """Governing one tool call, independent of transport.
2
+
3
+ `govern_call` is the whole loop for a single attempt: decide → (approval) →
4
+ (capture) → forward → record. The MCP proxy wires it to JSON-RPC pipes;
5
+ tests wire it to an in-process fake. Both get identical semantics because
6
+ this is the only implementation of them.
7
+
8
+ The `caller` contract: caller(tool, args) -> {"content": [...], "isError": bool}
9
+ — an MCP-shaped tool result. The `approver` contract:
10
+ approver(seq, req, decision) -> (verdict, by) with verdict in
11
+ ("approve", "deny", "timeout").
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .engine import Engine
17
+ from .ledger import LedgerWriter
18
+ from .policy import Rule
19
+ from .types import Decision, ToolAnnotations, ToolRequest
20
+
21
+ _UNRESOLVED = object()
22
+
23
+
24
+ def refusal_result(message: str) -> dict:
25
+ """The loud refusal the client sees: an MCP tool error naming the rule
26
+ and bound, so the agent can read why and adapt."""
27
+ return {"content": [{"type": "text", "text": f"wardn refused: {message}"}],
28
+ "isError": True}
29
+
30
+
31
+ def text_of(content: list) -> str:
32
+ return "".join(part.get("text", "") for part in content
33
+ if isinstance(part, dict) and part.get("type") == "text")
34
+
35
+
36
+ def _resolve(template, args: dict, capture_value):
37
+ if isinstance(template, str) and template.startswith("$"):
38
+ if template == "$capture.value":
39
+ return capture_value if capture_value is not None else _UNRESOLVED
40
+ if template.startswith("$args."):
41
+ return args.get(template[len("$args."):], _UNRESOLVED)
42
+ return template
43
+
44
+
45
+ def _resolve_args(templates: dict, args: dict, capture_value) -> dict | None:
46
+ resolved = {}
47
+ for key, template in templates.items():
48
+ value = _resolve(template, args, capture_value)
49
+ if value is _UNRESOLVED:
50
+ return None
51
+ resolved[key] = value
52
+ return resolved
53
+
54
+
55
+ def _build_capture(rule: Rule, req: ToolRequest, caller):
56
+ """Take the declared pre-state read and resolve the undo NOW, so the
57
+ ledger file alone suffices to revert — no policy needed at revert time."""
58
+ capture = rule.capture
59
+ if any(source not in req.args for source in capture.args_from.values()):
60
+ # The request lacks an arg the capture needs: no pre-state, no undo.
61
+ # Recorded as irreversible rather than guessed at.
62
+ return {}, {"unresolved": True}, None, None
63
+ cap_args = {to: req.args[source]
64
+ for to, source in capture.args_from.items()}
65
+ read = caller(capture.tool, cap_args)
66
+ if read.get("isError"):
67
+ state = {"exists": False}
68
+ value = None
69
+ else:
70
+ value = text_of(read.get("content", []))
71
+ state = {"exists": True, "value": value}
72
+
73
+ undo = None
74
+ check = None
75
+ inverse = rule.inverse
76
+ if inverse is not None:
77
+ if state["exists"]:
78
+ undo_args = _resolve_args(inverse.args, req.args, value)
79
+ if undo_args is not None:
80
+ undo = {"tool": inverse.tool, "args": undo_args}
81
+ elif inverse.when_absent_tool is not None:
82
+ undo_args = _resolve_args(inverse.when_absent_args or {},
83
+ req.args, None)
84
+ if undo_args is not None:
85
+ undo = {"tool": inverse.when_absent_tool, "args": undo_args}
86
+ if inverse.expect == "$absent":
87
+ check = {"expect_absent": True}
88
+ elif inverse.expect is not None:
89
+ expected = _resolve(inverse.expect, req.args, value)
90
+ if expected is not _UNRESOLVED:
91
+ check = {"expect": expected}
92
+ return cap_args, state, undo, check
93
+
94
+
95
+ def govern_call(engine: Engine, writer: LedgerWriter, seq: int,
96
+ req: ToolRequest, ann: ToolAnnotations | None,
97
+ caller, approver) -> dict:
98
+ """Returns the MCP-shaped result to hand back to the client — the
99
+ server's own result when forwarded, a refusal result otherwise. Every
100
+ path lands in the ledger."""
101
+ decision = engine.decide(req, ann)
102
+ writer.attempt(seq, req, decision)
103
+
104
+ if decision.refusal:
105
+ return refusal_result(decision.refusal)
106
+
107
+ if decision.disposition == "quarantine":
108
+ verdict, by = approver(seq, req, decision)
109
+ writer.approval(seq, verdict, by)
110
+ if verdict != "approve":
111
+ return refusal_result(
112
+ f"quarantine verdict {verdict} (rule {decision.matched_rule})")
113
+
114
+ rule = engine.policy.rule_by_id(decision.matched_rule)
115
+ if rule is not None and rule.capture is not None:
116
+ cap_args, state, undo, check = _build_capture(rule, req, caller)
117
+ writer.capture(seq, rule.capture.tool, cap_args, state, undo, check)
118
+
119
+ engine.note_forwarded(decision)
120
+ result = caller(req.tool, req.args)
121
+ writer.result(seq, bool(result.get("isError")), result.get("content", []))
122
+ return result