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/__init__.py +32 -0
- wardn/__main__.py +6 -0
- wardn/_version.py +1 -0
- wardn/approvals.py +103 -0
- wardn/cli.py +149 -0
- wardn/diff.py +154 -0
- wardn/engine.py +105 -0
- wardn/ledger.py +237 -0
- wardn/matchers.py +62 -0
- wardn/orchestrator.py +122 -0
- wardn/policy.py +313 -0
- wardn/proxy.py +478 -0
- wardn/py.typed +1 -0
- wardn/revert.py +101 -0
- wardn/types.py +49 -0
- wardn-0.1.0.dev0.dist-info/METADATA +213 -0
- wardn-0.1.0.dev0.dist-info/RECORD +20 -0
- wardn-0.1.0.dev0.dist-info/WHEEL +4 -0
- wardn-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- wardn-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
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
|