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/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""wardn — bounded, auditable action governor for LLM agents.
|
|
2
|
+
|
|
3
|
+
The MCP stdio proxy (`wardn proxy`) is the primary surface. The engine API
|
|
4
|
+
below is public: `govern_call` + `Engine` + `LedgerWriter` are the whole
|
|
5
|
+
enforcement loop for embedding wardn in front of any tool-calling boundary,
|
|
6
|
+
MCP or not (see docs/embedding.md). Compatibility promises attach to the
|
|
7
|
+
ledger format and the proxy CLI; the Python API aims for the same but may
|
|
8
|
+
still move in minor versions before 1.0.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
from .approvals import QueueError
|
|
13
|
+
from .diff import LedgerDiff, RunSummary, diff_ledgers, render_diff
|
|
14
|
+
from .engine import Engine, classify
|
|
15
|
+
from .ledger import (LEDGER_FORMAT, Folded, LedgerError, LedgerWriter,
|
|
16
|
+
ReplayDivergence, fold, read_ledger, replay)
|
|
17
|
+
from .orchestrator import govern_call, refusal_result, text_of
|
|
18
|
+
from .policy import (POLICY_FORMAT, Budget, Policy, PolicyError, Rule,
|
|
19
|
+
policy_from_json)
|
|
20
|
+
from .revert import RevertError, RevertMismatch, revert
|
|
21
|
+
from .types import Decision, ToolAnnotations, ToolRequest, WardnError
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"__version__",
|
|
25
|
+
"Budget", "Decision", "Engine", "Folded", "LEDGER_FORMAT", "LedgerDiff",
|
|
26
|
+
"LedgerError", "LedgerWriter", "POLICY_FORMAT", "Policy", "PolicyError",
|
|
27
|
+
"QueueError", "ReplayDivergence", "RevertError", "RevertMismatch",
|
|
28
|
+
"Rule", "RunSummary", "ToolAnnotations", "ToolRequest", "WardnError",
|
|
29
|
+
"classify", "diff_ledgers", "fold", "govern_call", "policy_from_json",
|
|
30
|
+
"read_ledger", "refusal_result", "render_diff", "replay", "revert",
|
|
31
|
+
"text_of",
|
|
32
|
+
]
|
wardn/__main__.py
ADDED
wardn/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0.dev0"
|
wardn/approvals.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The approval queue: one JSON file per run, shared between the proxy
|
|
2
|
+
(which parks and polls) and `wardn review` (which lists and decides).
|
|
3
|
+
|
|
4
|
+
Writes are atomic (temp file + os.replace) and reads retry briefly, since
|
|
5
|
+
the two sides race on Windows file locks. Verdicts land in the ledger as
|
|
6
|
+
approval events; the queue file itself is working state, not the record.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .types import WardnError
|
|
17
|
+
|
|
18
|
+
QUEUE_FORMAT = "wardn-queue/1"
|
|
19
|
+
|
|
20
|
+
_RETRIES = 40
|
|
21
|
+
_RETRY_SLEEP = 0.05
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class QueueError(WardnError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _read(path: Path) -> dict:
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return {"format": QUEUE_FORMAT, "items": []}
|
|
31
|
+
for attempt in range(_RETRIES):
|
|
32
|
+
try:
|
|
33
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
if doc.get("format") != QUEUE_FORMAT:
|
|
35
|
+
raise QueueError(f"{path}: not a {QUEUE_FORMAT} file")
|
|
36
|
+
return doc
|
|
37
|
+
except (json.JSONDecodeError, PermissionError, OSError):
|
|
38
|
+
if attempt == _RETRIES - 1:
|
|
39
|
+
raise
|
|
40
|
+
time.sleep(_RETRY_SLEEP)
|
|
41
|
+
raise QueueError(f"{path}: unreadable")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write(path: Path, doc: dict) -> None:
|
|
45
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
46
|
+
for attempt in range(_RETRIES):
|
|
47
|
+
try:
|
|
48
|
+
tmp.write_text(json.dumps(doc, indent=2), encoding="utf-8")
|
|
49
|
+
os.replace(tmp, path)
|
|
50
|
+
return
|
|
51
|
+
except (PermissionError, OSError):
|
|
52
|
+
if attempt == _RETRIES - 1:
|
|
53
|
+
raise
|
|
54
|
+
time.sleep(_RETRY_SLEEP)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def park(path, item_id: str, seq: int, server: str, tool: str, args: dict,
|
|
58
|
+
rule: str, reason: str) -> None:
|
|
59
|
+
path = Path(path)
|
|
60
|
+
doc = _read(path)
|
|
61
|
+
if any(item["id"] == item_id for item in doc["items"]):
|
|
62
|
+
raise QueueError(
|
|
63
|
+
f"{path}: item {item_id!r} already queued — one queue file per "
|
|
64
|
+
"run; point --queue somewhere fresh")
|
|
65
|
+
doc["items"].append({"id": item_id, "seq": seq, "server": server,
|
|
66
|
+
"tool": tool, "args": args, "rule": rule,
|
|
67
|
+
"reason": reason, "verdict": None, "by": None})
|
|
68
|
+
_write(path, doc)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pending(path) -> list:
|
|
72
|
+
return [item for item in _read(Path(path))["items"]
|
|
73
|
+
if item["verdict"] is None]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def verdict(path, item_id: str):
|
|
77
|
+
"""(verdict, by) once decided, else None."""
|
|
78
|
+
for item in _read(Path(path))["items"]:
|
|
79
|
+
if item["id"] == item_id:
|
|
80
|
+
if item["verdict"] is None:
|
|
81
|
+
return None
|
|
82
|
+
return item["verdict"], item["by"]
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def decide(path, item_id: str, decision: str, by: str) -> dict:
|
|
87
|
+
if decision not in ("approve", "deny"):
|
|
88
|
+
raise QueueError(f"verdict must be approve or deny, not {decision!r}")
|
|
89
|
+
if not by:
|
|
90
|
+
raise QueueError("an approver name is required (--by) — verdicts "
|
|
91
|
+
"are recorded, anonymous ones are worthless")
|
|
92
|
+
path = Path(path)
|
|
93
|
+
doc = _read(path)
|
|
94
|
+
for item in doc["items"]:
|
|
95
|
+
if item["id"] == item_id:
|
|
96
|
+
if item["verdict"] is not None:
|
|
97
|
+
raise QueueError(f"{item_id!r} already decided: "
|
|
98
|
+
f"{item['verdict']} by {item['by']}")
|
|
99
|
+
item["verdict"] = decision
|
|
100
|
+
item["by"] = by
|
|
101
|
+
_write(path, doc)
|
|
102
|
+
return item
|
|
103
|
+
raise QueueError(f"{path}: no queued item {item_id!r}")
|
wardn/cli.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""wardn CLI: proxy, review, replay, revert, diff.
|
|
2
|
+
|
|
3
|
+
Exit codes, family convention: 0 ok, 2 refusal/error (bad policy, replay
|
|
4
|
+
divergence, revert mismatch, ...), 4 drift from `wardn diff`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import approvals
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .diff import diff_ledgers, render_diff
|
|
17
|
+
from .ledger import replay
|
|
18
|
+
from .policy import policy_from_json
|
|
19
|
+
from .proxy import Proxy, ServerSpec, servers_from_json, stdio_caller
|
|
20
|
+
from .revert import revert
|
|
21
|
+
from .types import WardnError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _trailing_cmd(args_list: list) -> list:
|
|
25
|
+
cmd = list(args_list)
|
|
26
|
+
if cmd and cmd[0] == "--":
|
|
27
|
+
cmd = cmd[1:]
|
|
28
|
+
return cmd
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv=None) -> int:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="wardn",
|
|
34
|
+
description="bounded, auditable action governor for LLM agents")
|
|
35
|
+
parser.add_argument("--version", action="version",
|
|
36
|
+
version=f"wardn {__version__}")
|
|
37
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
38
|
+
|
|
39
|
+
p = sub.add_parser("proxy",
|
|
40
|
+
help="govern one or more MCP servers over stdio")
|
|
41
|
+
p.add_argument("--policy", required=True, help="policy JSON file")
|
|
42
|
+
p.add_argument("--ledger", required=True, help="ledger file to write")
|
|
43
|
+
p.add_argument("--queue", default=None,
|
|
44
|
+
help="approval queue file (default: <ledger>.queue.json)")
|
|
45
|
+
p.add_argument("--servers", default=None, metavar="JSON",
|
|
46
|
+
help="JSON file naming several downstream servers "
|
|
47
|
+
"(MCP client config shape: {\"mcpServers\": {name: "
|
|
48
|
+
"{command, args}}}); instead of a trailing command")
|
|
49
|
+
p.add_argument("--server-name", default="server",
|
|
50
|
+
help="name for the trailing-command server, for policy "
|
|
51
|
+
"matching (default: server)")
|
|
52
|
+
p.add_argument("--run-id", default="run",
|
|
53
|
+
help="workload label in the ledger header — keep it "
|
|
54
|
+
"identical across runs you intend to compare")
|
|
55
|
+
p.add_argument("server_cmd", nargs=argparse.REMAINDER,
|
|
56
|
+
help="-- followed by the downstream MCP server command")
|
|
57
|
+
|
|
58
|
+
r = sub.add_parser("review", help="list or decide quarantined calls")
|
|
59
|
+
r.add_argument("action", choices=["list", "approve", "deny"])
|
|
60
|
+
r.add_argument("item_id", nargs="?")
|
|
61
|
+
r.add_argument("--queue", required=True, help="the run's queue file")
|
|
62
|
+
r.add_argument("--by", default="",
|
|
63
|
+
help="who is deciding (recorded in the ledger)")
|
|
64
|
+
|
|
65
|
+
rp = sub.add_parser("replay",
|
|
66
|
+
help="re-derive every decision from policy + "
|
|
67
|
+
"ledger, offline; no server needed")
|
|
68
|
+
rp.add_argument("--policy", required=True, help="the policy that ran")
|
|
69
|
+
rp.add_argument("--ledger", required=True, help="the run's ledger")
|
|
70
|
+
|
|
71
|
+
rv = sub.add_parser("revert",
|
|
72
|
+
help="undo the run's recorded effects against a "
|
|
73
|
+
"live server, from the ledger alone")
|
|
74
|
+
rv.add_argument("--ledger", required=True, help="the run's ledger")
|
|
75
|
+
rv.add_argument("--out", default=None,
|
|
76
|
+
help="revert ledger to write "
|
|
77
|
+
"(default: <ledger>.revert.jsonl)")
|
|
78
|
+
rv.add_argument("server_cmd", nargs=argparse.REMAINDER,
|
|
79
|
+
help="-- followed by the MCP server command to undo "
|
|
80
|
+
"against")
|
|
81
|
+
|
|
82
|
+
d = sub.add_parser("diff",
|
|
83
|
+
help="compare two ledgers' decisions; exit 4 on drift")
|
|
84
|
+
d.add_argument("ledger_a", help="ledger JSONL (e.g. yesterday's run)")
|
|
85
|
+
d.add_argument("ledger_b", help="ledger JSONL (e.g. today's)")
|
|
86
|
+
d.add_argument("--show-attempts", type=int, default=0, metavar="N",
|
|
87
|
+
help="also list up to N attempts unique to each side")
|
|
88
|
+
|
|
89
|
+
args = parser.parse_args(argv)
|
|
90
|
+
try:
|
|
91
|
+
if args.command == "proxy":
|
|
92
|
+
cmd = _trailing_cmd(args.server_cmd)
|
|
93
|
+
if args.servers and cmd:
|
|
94
|
+
parser.error("give either --servers or a trailing command, "
|
|
95
|
+
"not both")
|
|
96
|
+
if args.servers:
|
|
97
|
+
specs = servers_from_json(Path(args.servers))
|
|
98
|
+
elif cmd:
|
|
99
|
+
specs = [ServerSpec(name=args.server_name, cmd=tuple(cmd))]
|
|
100
|
+
else:
|
|
101
|
+
parser.error("no downstream server (give --servers, or the "
|
|
102
|
+
"command after --)")
|
|
103
|
+
queue = args.queue or (str(args.ledger) + ".queue.json")
|
|
104
|
+
return Proxy(policy=policy_from_json(Path(args.policy)),
|
|
105
|
+
servers=specs, ledger_path=args.ledger,
|
|
106
|
+
queue_path=queue, run_id=args.run_id).run()
|
|
107
|
+
|
|
108
|
+
if args.command == "review":
|
|
109
|
+
if args.action == "list":
|
|
110
|
+
items = approvals.pending(args.queue)
|
|
111
|
+
if not items:
|
|
112
|
+
print("nothing pending")
|
|
113
|
+
return 0
|
|
114
|
+
for item in items:
|
|
115
|
+
print(f"{item['id']} {item['tool']} "
|
|
116
|
+
f"{json.dumps(item['args'], separators=(',', ':'))}"
|
|
117
|
+
f" rule={item['rule']} — {item['reason']}")
|
|
118
|
+
return 0
|
|
119
|
+
if not args.item_id:
|
|
120
|
+
parser.error("approve/deny take the item id (see: review list)")
|
|
121
|
+
item = approvals.decide(args.queue, args.item_id, args.action,
|
|
122
|
+
args.by)
|
|
123
|
+
print(f"{item['id']}: {item['verdict']} by {item['by']}")
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
if args.command == "replay":
|
|
127
|
+
count = replay(policy_from_json(Path(args.policy)), args.ledger)
|
|
128
|
+
print(f"replayed {count} attempt(s) — every decision reproduced "
|
|
129
|
+
"by this policy")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
if args.command == "revert":
|
|
133
|
+
cmd = _trailing_cmd(args.server_cmd)
|
|
134
|
+
if not cmd:
|
|
135
|
+
parser.error("no server command (give it after --)")
|
|
136
|
+
out = args.out or (str(args.ledger) + ".revert.jsonl")
|
|
137
|
+
with stdio_caller(cmd) as caller:
|
|
138
|
+
applied = revert(args.ledger, caller, out)
|
|
139
|
+
print(f"applied {applied} undo(s) — revert ledger: {out}")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
if args.command == "diff":
|
|
143
|
+
result = diff_ledgers(args.ledger_a, args.ledger_b)
|
|
144
|
+
print(render_diff(result, show_attempts=args.show_attempts))
|
|
145
|
+
return 0 if result.same else 4
|
|
146
|
+
except WardnError as exc:
|
|
147
|
+
print(f"wardn: {exc}", file=sys.stderr)
|
|
148
|
+
return 2
|
|
149
|
+
return 0
|
wardn/diff.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""wardn diff: run-to-run drift in the decision surface.
|
|
2
|
+
|
|
3
|
+
Two ledgers from the same workload should describe the same governed
|
|
4
|
+
activity: same policy, same attempts, same decisions. `diff_ledgers`
|
|
5
|
+
compares exactly that — every attempt with its decision and (for
|
|
6
|
+
quarantined calls) the recorded verdict — and never the server's result
|
|
7
|
+
content: what came back is the server's business, what was attempted and
|
|
8
|
+
allowed is the gate's. Comparison is an order-insensitive multiset, mendr
|
|
9
|
+
style: a run that made the same attempts in a different order with
|
|
10
|
+
identical decisions is not drift. (Bounds are order-dependent, so a
|
|
11
|
+
reorder that changes WHICH call gets refused changes the multiset and is
|
|
12
|
+
drift.)
|
|
13
|
+
|
|
14
|
+
`LedgerDiff.same` is the CI verdict; the CLI turns False into exit 4 —
|
|
15
|
+
the family convention (mendr's diff exits 4 on drift too). Everything
|
|
16
|
+
else is the explanation: per-rule and per-effect counts A -> B, declared
|
|
17
|
+
spend, and the exact attempts unique to each side.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
from .ledger import fold, read_ledger
|
|
26
|
+
|
|
27
|
+
# The decision surface: what the diff compares, per attempt. seq is order
|
|
28
|
+
# (excluded — the multiset is order-insensitive); the approver's name is
|
|
29
|
+
# audit detail, not a decision (excluded); capture and result events are
|
|
30
|
+
# the world's side of the story (excluded).
|
|
31
|
+
_DECISION_FIELDS = ("server", "tool", "args", "matched_rule", "effect",
|
|
32
|
+
"effect_source", "disposition", "reason", "cost",
|
|
33
|
+
"refusal")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class RunSummary:
|
|
38
|
+
"""One ledger's governed activity, reduced to what diff compares."""
|
|
39
|
+
run_id: str
|
|
40
|
+
policy_hash: str
|
|
41
|
+
attempts: int
|
|
42
|
+
per_rule: dict # rule id -> {"attempts": n, "committed": n}
|
|
43
|
+
per_effect: dict # effect -> committed count
|
|
44
|
+
spend: float # declared cost of committed calls
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class LedgerDiff:
|
|
49
|
+
"""What changed between two runs of (presumably) the same workload.
|
|
50
|
+
|
|
51
|
+
`same` is the CI verdict: identical policy, identical decision
|
|
52
|
+
surface. The count fields are the explanation a human reads when it
|
|
53
|
+
is False."""
|
|
54
|
+
a: RunSummary
|
|
55
|
+
b: RunSummary
|
|
56
|
+
only_in_a: tuple # canonical JSON strings, one per unmatched attempt
|
|
57
|
+
only_in_b: tuple
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def policies_match(self) -> bool:
|
|
61
|
+
return self.a.policy_hash == self.b.policy_hash
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def same(self) -> bool:
|
|
65
|
+
return self.policies_match and not self.only_in_a \
|
|
66
|
+
and not self.only_in_b
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _summarize(path) -> tuple:
|
|
70
|
+
"""(RunSummary, decision-surface multiset) for one ledger."""
|
|
71
|
+
header, events = read_ledger(path)
|
|
72
|
+
per_rule: dict = {}
|
|
73
|
+
per_effect: dict = {}
|
|
74
|
+
spend = 0.0
|
|
75
|
+
multiset: dict = {}
|
|
76
|
+
folded = fold(events)
|
|
77
|
+
for entry in folded:
|
|
78
|
+
attempt = entry.attempt
|
|
79
|
+
rule = per_rule.setdefault(attempt["matched_rule"],
|
|
80
|
+
{"attempts": 0, "committed": 0})
|
|
81
|
+
rule["attempts"] += 1
|
|
82
|
+
if entry.committed:
|
|
83
|
+
rule["committed"] += 1
|
|
84
|
+
per_effect[attempt["effect"]] = \
|
|
85
|
+
per_effect.get(attempt["effect"], 0) + 1
|
|
86
|
+
spend = round(spend + attempt["cost"], 10)
|
|
87
|
+
record = {field: attempt[field] for field in _DECISION_FIELDS}
|
|
88
|
+
record["verdict"] = \
|
|
89
|
+
entry.approval["verdict"] if entry.approval else None
|
|
90
|
+
key = json.dumps(record, sort_keys=True, separators=(",", ":"))
|
|
91
|
+
multiset[key] = multiset.get(key, 0) + 1
|
|
92
|
+
summary = RunSummary(
|
|
93
|
+
run_id=header["run_id"],
|
|
94
|
+
policy_hash=header["provenance"]["policy_hash"],
|
|
95
|
+
attempts=len(folded), per_rule=per_rule, per_effect=per_effect,
|
|
96
|
+
spend=spend)
|
|
97
|
+
return summary, multiset
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _only_in(mine: dict, theirs: dict) -> tuple:
|
|
101
|
+
extra = []
|
|
102
|
+
for key, count in mine.items():
|
|
103
|
+
extra.extend([key] * max(0, count - theirs.get(key, 0)))
|
|
104
|
+
return tuple(sorted(extra))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def diff_ledgers(path_a, path_b) -> LedgerDiff:
|
|
108
|
+
"""Compare two ledger files' decision surfaces."""
|
|
109
|
+
a, multiset_a = _summarize(path_a)
|
|
110
|
+
b, multiset_b = _summarize(path_b)
|
|
111
|
+
return LedgerDiff(a=a, b=b,
|
|
112
|
+
only_in_a=_only_in(multiset_a, multiset_b),
|
|
113
|
+
only_in_b=_only_in(multiset_b, multiset_a))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def render_diff(diff: LedgerDiff, show_attempts: int = 0) -> str:
|
|
117
|
+
"""Human/CI-readable drift report. `show_attempts` > 0 also lists up
|
|
118
|
+
to that many attempts unique to each side."""
|
|
119
|
+
a, b = diff.a, diff.b
|
|
120
|
+
lines = ["wardn ledger diff"]
|
|
121
|
+
if not diff.policies_match:
|
|
122
|
+
lines.append(f" POLICY DRIFT: {a.policy_hash} vs {b.policy_hash}")
|
|
123
|
+
if a.run_id != b.run_id:
|
|
124
|
+
lines.append(f" note: run_id {a.run_id!r} vs {b.run_id!r} — "
|
|
125
|
+
"comparable runs should share the label")
|
|
126
|
+
lines.append(f" attempts: {a.attempts} -> {b.attempts}")
|
|
127
|
+
for rid in sorted(set(a.per_rule) | set(b.per_rule)):
|
|
128
|
+
ra = a.per_rule.get(rid, {"attempts": 0, "committed": 0})
|
|
129
|
+
rb = b.per_rule.get(rid, {"attempts": 0, "committed": 0})
|
|
130
|
+
marker = " " if ra == rb else "~ "
|
|
131
|
+
lines.append(
|
|
132
|
+
f" {marker}{rid}: attempts {ra['attempts']} -> {rb['attempts']}"
|
|
133
|
+
f", committed {ra['committed']} -> {rb['committed']}"
|
|
134
|
+
f", refused {ra['attempts'] - ra['committed']} -> "
|
|
135
|
+
f"{rb['attempts'] - rb['committed']}")
|
|
136
|
+
for effect in sorted(set(a.per_effect) | set(b.per_effect)):
|
|
137
|
+
na = a.per_effect.get(effect, 0)
|
|
138
|
+
nb = b.per_effect.get(effect, 0)
|
|
139
|
+
marker = " " if na == nb else "~ "
|
|
140
|
+
lines.append(f" {marker}effect {effect}: committed {na} -> {nb}")
|
|
141
|
+
if a.spend or b.spend:
|
|
142
|
+
marker = " " if a.spend == b.spend else "~ "
|
|
143
|
+
lines.append(f" {marker}declared spend: "
|
|
144
|
+
f"{json.dumps(a.spend)} -> {json.dumps(b.spend)}")
|
|
145
|
+
if diff.same:
|
|
146
|
+
lines.append(" ledgers match: same policy, same attempts, "
|
|
147
|
+
"same decisions")
|
|
148
|
+
else:
|
|
149
|
+
lines.append(f" attempts only in A: {len(diff.only_in_a)}; "
|
|
150
|
+
f"only in B: {len(diff.only_in_b)}")
|
|
151
|
+
for label, extras in (("A", diff.only_in_a), ("B", diff.only_in_b)):
|
|
152
|
+
for key in extras[:show_attempts]:
|
|
153
|
+
lines.append(f" only in {label}: {key}")
|
|
154
|
+
return "\n".join(lines)
|
wardn/engine.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The decision engine. Pure bookkeeping, no I/O, no clock.
|
|
2
|
+
|
|
3
|
+
A decision is a pure function of policy + request + annotations + the
|
|
4
|
+
counters accumulated so far; the counters advance only when a call is
|
|
5
|
+
actually committed for forwarding (`note_forwarded`). Offline replay drives
|
|
6
|
+
this same class from the ledger and must land on identical decisions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .matchers import match_matches
|
|
12
|
+
from .policy import Policy, Rule
|
|
13
|
+
from .types import EFFECT_RANK, Decision, ToolAnnotations, ToolRequest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _annotation_effect(ann: ToolAnnotations | None) -> str | None:
|
|
17
|
+
if ann is None:
|
|
18
|
+
return None
|
|
19
|
+
if ann.read_only:
|
|
20
|
+
return "read"
|
|
21
|
+
if ann.destructive:
|
|
22
|
+
return "destructive"
|
|
23
|
+
if ann.read_only is None and ann.destructive is None:
|
|
24
|
+
return None
|
|
25
|
+
return "write"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def classify(rule: Rule | None, ann: ToolAnnotations | None) -> tuple[str, str]:
|
|
29
|
+
"""(effect, source). Declared wins; an annotation may tighten a
|
|
30
|
+
declaration but never loosen it; with neither, a tool is a write."""
|
|
31
|
+
declared = rule.effect if rule else None
|
|
32
|
+
derived = _annotation_effect(ann)
|
|
33
|
+
if declared is not None:
|
|
34
|
+
if derived is not None and EFFECT_RANK[derived] > EFFECT_RANK[declared]:
|
|
35
|
+
return derived, "declared+tightened"
|
|
36
|
+
return declared, "declared"
|
|
37
|
+
if derived is not None:
|
|
38
|
+
return derived, "annotation"
|
|
39
|
+
return "write", "unclassified"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Engine:
|
|
43
|
+
def __init__(self, policy: Policy):
|
|
44
|
+
self.policy = policy
|
|
45
|
+
self.rule_calls: dict = {} # rule id -> forwarded calls
|
|
46
|
+
self.run_calls = 0
|
|
47
|
+
self.run_writes = 0
|
|
48
|
+
self.run_spend = 0.0
|
|
49
|
+
|
|
50
|
+
# -- deciding -----------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def decide(self, req: ToolRequest,
|
|
53
|
+
ann: ToolAnnotations | None = None) -> Decision:
|
|
54
|
+
rule = self._match(req)
|
|
55
|
+
effect, effect_source = classify(rule, ann)
|
|
56
|
+
cost = rule.cost_per_call if rule else 0.0
|
|
57
|
+
disposition = rule.disposition if rule else self.policy.default
|
|
58
|
+
matched = rule.id if rule else "default"
|
|
59
|
+
reason = rule.reason if rule else ""
|
|
60
|
+
|
|
61
|
+
if disposition == "deny":
|
|
62
|
+
refusal = f"rule {rule.id}: deny" if rule else "default: no rule matched"
|
|
63
|
+
else:
|
|
64
|
+
# allow and quarantine both eventually forward, so bounds apply
|
|
65
|
+
# to both — and bounds precede approval: nobody gets paged about
|
|
66
|
+
# a call the budget refuses anyway.
|
|
67
|
+
refusal = self._check_bounds(rule, effect, cost)
|
|
68
|
+
|
|
69
|
+
return Decision(matched_rule=matched, effect=effect,
|
|
70
|
+
effect_source=effect_source, disposition=disposition,
|
|
71
|
+
reason=reason, cost=cost, refusal=refusal)
|
|
72
|
+
|
|
73
|
+
def _match(self, req: ToolRequest) -> Rule | None:
|
|
74
|
+
for rule in self.policy.rules:
|
|
75
|
+
if match_matches(rule.match, req.server, req.tool, req.args):
|
|
76
|
+
return rule
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def _check_bounds(self, rule: Rule | None, effect: str, cost: float) -> str:
|
|
80
|
+
budget = self.policy.budget
|
|
81
|
+
if rule is not None and rule.max_calls is not None \
|
|
82
|
+
and self.rule_calls.get(rule.id, 0) + 1 > rule.max_calls:
|
|
83
|
+
return f"rule {rule.id}: bound max_calls={rule.max_calls} exceeded"
|
|
84
|
+
if budget.max_calls is not None \
|
|
85
|
+
and self.run_calls + 1 > budget.max_calls:
|
|
86
|
+
return f"budget: max_calls={budget.max_calls} exceeded"
|
|
87
|
+
if effect in ("write", "destructive") and budget.max_writes is not None \
|
|
88
|
+
and self.run_writes + 1 > budget.max_writes:
|
|
89
|
+
return f"budget: max_writes={budget.max_writes} exceeded"
|
|
90
|
+
if budget.max_spend is not None \
|
|
91
|
+
and round(self.run_spend + cost, 10) > budget.max_spend:
|
|
92
|
+
return f"budget: max_spend={budget.max_spend} exceeded"
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
# -- bookkeeping --------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def note_forwarded(self, decision: Decision) -> None:
|
|
98
|
+
"""Advance the counters for a call committed for forwarding."""
|
|
99
|
+
if decision.matched_rule != "default":
|
|
100
|
+
self.rule_calls[decision.matched_rule] = \
|
|
101
|
+
self.rule_calls.get(decision.matched_rule, 0) + 1
|
|
102
|
+
self.run_calls += 1
|
|
103
|
+
if decision.effect in ("write", "destructive"):
|
|
104
|
+
self.run_writes += 1
|
|
105
|
+
self.run_spend = round(self.run_spend + decision.cost, 10)
|