toolwrit 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.
- toolwrit/__init__.py +95 -0
- toolwrit/_js.py +262 -0
- toolwrit/audit/__init__.py +1 -0
- toolwrit/audit/chain.py +251 -0
- toolwrit/audit/verify.py +164 -0
- toolwrit/budget/__init__.py +1 -0
- toolwrit/budget/ledger.py +141 -0
- toolwrit/cli.py +311 -0
- toolwrit/policy/__init__.py +1 -0
- toolwrit/policy/constraints.py +445 -0
- toolwrit/policy/engine.py +208 -0
- toolwrit/policy/load.py +418 -0
- toolwrit/policy/match.py +110 -0
- toolwrit/toolwrit.py +428 -0
- toolwrit/types.py +262 -0
- toolwrit-0.2.0.dist-info/METADATA +267 -0
- toolwrit-0.2.0.dist-info/RECORD +20 -0
- toolwrit-0.2.0.dist-info/WHEEL +5 -0
- toolwrit-0.2.0.dist-info/entry_points.txt +2 -0
- toolwrit-0.2.0.dist-info/top_level.txt +1 -0
toolwrit/audit/verify.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chain verification.
|
|
3
|
+
|
|
4
|
+
Verification is the product. Anyone can write a log; the value is being able
|
|
5
|
+
to hand a regulator, a customer or an acquirer a file and a one-line command
|
|
6
|
+
that proves the file was not edited after the fact.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal, Sequence
|
|
14
|
+
|
|
15
|
+
from .chain import GENESIS, AuditEntry, entry_from_dict, hash_entry
|
|
16
|
+
|
|
17
|
+
FailureReason = Literal["bad-hash", "broken-link", "bad-sequence", "malformed"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class VerifyFailure:
|
|
22
|
+
"""Identifies the first entry that failed."""
|
|
23
|
+
|
|
24
|
+
seq: int
|
|
25
|
+
reason: FailureReason
|
|
26
|
+
detail: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class VerifyResult:
|
|
31
|
+
ok: bool
|
|
32
|
+
#: Number of entries examined.
|
|
33
|
+
count: int
|
|
34
|
+
#: Hash of the final entry, or GENESIS for an empty log.
|
|
35
|
+
head: str
|
|
36
|
+
#: Populated when ok is False.
|
|
37
|
+
failure: VerifyFailure | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def verify_chain(entries: Sequence[AuditEntry]) -> VerifyResult:
|
|
41
|
+
"""Verify an in-memory chain. Stops at the first inconsistency."""
|
|
42
|
+
prev = GENESIS
|
|
43
|
+
|
|
44
|
+
for i, entry in enumerate(entries):
|
|
45
|
+
seq = i + 1
|
|
46
|
+
|
|
47
|
+
def fail(reason: FailureReason, detail: str) -> VerifyResult:
|
|
48
|
+
return VerifyResult(
|
|
49
|
+
ok=False,
|
|
50
|
+
count=len(entries),
|
|
51
|
+
head=prev,
|
|
52
|
+
failure=VerifyFailure(seq=seq, reason=reason, detail=detail),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if entry.seq != seq:
|
|
56
|
+
return fail(
|
|
57
|
+
"bad-sequence",
|
|
58
|
+
f"entry declares seq {entry.seq} but sits at position {seq}",
|
|
59
|
+
)
|
|
60
|
+
if entry.prev != prev:
|
|
61
|
+
return fail(
|
|
62
|
+
"broken-link",
|
|
63
|
+
f"entry links to {_short(entry.prev)} but predecessor hashes to "
|
|
64
|
+
f"{_short(prev)}",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
recomputed = hash_entry(entry.body())
|
|
68
|
+
if recomputed != entry.hash:
|
|
69
|
+
return fail(
|
|
70
|
+
"bad-hash",
|
|
71
|
+
f"entry hashes to {_short(recomputed)} but claims {_short(entry.hash)}",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
prev = entry.hash
|
|
75
|
+
|
|
76
|
+
return VerifyResult(ok=True, count=len(entries), head=prev)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def verify_raw(raw_entries: Sequence[dict], count: int | None = None) -> VerifyResult:
|
|
80
|
+
"""
|
|
81
|
+
Verify entries exactly as they were parsed from JSON.
|
|
82
|
+
|
|
83
|
+
This hashes the parsed object itself rather than a dataclass rebuilt from
|
|
84
|
+
the fields we recognise, and that distinction is the whole guarantee.
|
|
85
|
+
Rebuilding silently DROPS any key the reader does not know about, so a
|
|
86
|
+
field appended to an entry -- a top-level "note", a forged
|
|
87
|
+
decision.approvedBy -- disappeared before the hash was recomputed and the
|
|
88
|
+
log verified clean, while the TypeScript verifier, which hashes what it
|
|
89
|
+
parsed, reported the same file as tampered. A verifier that only notices
|
|
90
|
+
edits to fields it already expected is not a tamper detector.
|
|
91
|
+
"""
|
|
92
|
+
total = len(raw_entries) if count is None else count
|
|
93
|
+
prev = GENESIS
|
|
94
|
+
|
|
95
|
+
for i, raw in enumerate(raw_entries):
|
|
96
|
+
seq = i + 1
|
|
97
|
+
|
|
98
|
+
def fail(reason: FailureReason, detail: str) -> VerifyResult:
|
|
99
|
+
return VerifyResult(
|
|
100
|
+
ok=False,
|
|
101
|
+
count=total,
|
|
102
|
+
head=prev,
|
|
103
|
+
failure=VerifyFailure(seq=seq, reason=reason, detail=detail),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if not isinstance(raw, dict):
|
|
107
|
+
return fail("malformed", f"entry {seq} is not a JSON object")
|
|
108
|
+
|
|
109
|
+
claimed_hash = raw.get("hash")
|
|
110
|
+
if not isinstance(claimed_hash, str):
|
|
111
|
+
return fail("malformed", f"entry {seq} has no hash")
|
|
112
|
+
|
|
113
|
+
if raw.get("seq") != seq:
|
|
114
|
+
return fail(
|
|
115
|
+
"bad-sequence",
|
|
116
|
+
f"entry declares seq {raw.get('seq')} but sits at position {seq}",
|
|
117
|
+
)
|
|
118
|
+
if raw.get("prev") != prev:
|
|
119
|
+
return fail(
|
|
120
|
+
"broken-link",
|
|
121
|
+
f"entry links to {_short(str(raw.get('prev')))} but predecessor "
|
|
122
|
+
f"hashes to {_short(prev)}",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
body = {key: value for key, value in raw.items() if key != "hash"}
|
|
126
|
+
recomputed = hash_entry(body)
|
|
127
|
+
if recomputed != claimed_hash:
|
|
128
|
+
return fail(
|
|
129
|
+
"bad-hash",
|
|
130
|
+
f"entry hashes to {_short(recomputed)} but claims {_short(claimed_hash)}",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
prev = claimed_hash
|
|
134
|
+
|
|
135
|
+
return VerifyResult(ok=True, count=total, head=prev)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def verify_file(file: str) -> VerifyResult:
|
|
139
|
+
"""Verify a JSONL audit file written by AuditLog."""
|
|
140
|
+
with open(file, "r", encoding="utf-8") as handle:
|
|
141
|
+
lines = [line for line in handle.read().split("\n") if line.strip()]
|
|
142
|
+
|
|
143
|
+
raw_entries: list[dict] = []
|
|
144
|
+
for i, line in enumerate(lines):
|
|
145
|
+
try:
|
|
146
|
+
raw_entries.append(json.loads(line))
|
|
147
|
+
except ValueError as err:
|
|
148
|
+
return VerifyResult(
|
|
149
|
+
ok=False,
|
|
150
|
+
count=len(lines),
|
|
151
|
+
head=GENESIS,
|
|
152
|
+
failure=VerifyFailure(
|
|
153
|
+
seq=i + 1,
|
|
154
|
+
reason="malformed",
|
|
155
|
+
detail=f"line {i + 1} is not valid JSON ({err})",
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Verified as parsed, not as reconstructed: see verify_raw.
|
|
160
|
+
return verify_raw(raw_entries, count=len(lines))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _short(value: str) -> str:
|
|
164
|
+
return str(value)[:12]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Run-scoped consumption tracking."""
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run-scoped consumption tracking.
|
|
3
|
+
|
|
4
|
+
The ledger is the "capacity" half of Toolwrit: rules decide *what* an agent may
|
|
5
|
+
do, the ledger decides *how much*. It is deliberately dumb -- a set of
|
|
6
|
+
counters -- because the interesting logic belongs in the engine, and because
|
|
7
|
+
a counter is trivially auditable.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass, replace
|
|
16
|
+
|
|
17
|
+
from ..types import BudgetUsage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class TokenPrice:
|
|
22
|
+
"""Per-thousand-token prices used to turn token counts into dollars."""
|
|
23
|
+
|
|
24
|
+
#: USD per 1,000 input tokens.
|
|
25
|
+
input: float = 0
|
|
26
|
+
#: USD per 1,000 output tokens.
|
|
27
|
+
output: float = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _require_consumption(name: str, value: float) -> float:
|
|
31
|
+
"""
|
|
32
|
+
Reject a consumption figure that would corrupt the ledger.
|
|
33
|
+
|
|
34
|
+
A single NaN is permanent and silent: every ceiling check is
|
|
35
|
+
``used >= limit``, and ``nan >= n`` is False, so one bad metering call
|
|
36
|
+
disables that budget for the rest of the run with no error anywhere. A
|
|
37
|
+
negative figure is as bad in the other direction -- it walks a spent budget
|
|
38
|
+
back under its ceiling and reopens it. A metering bug has to surface as an
|
|
39
|
+
exception, not as a limit that quietly stops applying.
|
|
40
|
+
"""
|
|
41
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
42
|
+
raise TypeError(f"toolwrit: {name} must be a finite number, got {value!r}")
|
|
43
|
+
if not math.isfinite(value):
|
|
44
|
+
raise TypeError(f"toolwrit: {name} must be a finite number, got {value!r}")
|
|
45
|
+
if value < 0:
|
|
46
|
+
raise ValueError(f"toolwrit: {name} must not be negative, got {value}")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def as_token_price(price: "TokenPrice | Mapping[str, float] | None") -> TokenPrice:
|
|
51
|
+
"""Coerce a price table given as a mapping, a TokenPrice, or nothing."""
|
|
52
|
+
if price is None:
|
|
53
|
+
return TokenPrice()
|
|
54
|
+
if isinstance(price, TokenPrice):
|
|
55
|
+
return price
|
|
56
|
+
if isinstance(price, Mapping):
|
|
57
|
+
unknown = set(price) - {"input", "output"}
|
|
58
|
+
if unknown:
|
|
59
|
+
raise TypeError(
|
|
60
|
+
f"unknown price key(s) {sorted(unknown)}; expected 'input' and 'output'"
|
|
61
|
+
)
|
|
62
|
+
return TokenPrice(input=price.get("input", 0), output=price.get("output", 0))
|
|
63
|
+
raise TypeError(f"price must be a TokenPrice or a mapping, got {type(price).__name__}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def now_ms() -> int:
|
|
67
|
+
"""Milliseconds since the epoch, matching JavaScript's ``Date.now()``."""
|
|
68
|
+
return int(time.time() * 1000)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Ledger:
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self._usage = BudgetUsage()
|
|
74
|
+
|
|
75
|
+
def snapshot(self) -> BudgetUsage:
|
|
76
|
+
"""Snapshot for the engine. Returned by value so callers cannot mutate state."""
|
|
77
|
+
return replace(self._usage)
|
|
78
|
+
|
|
79
|
+
def count_call(self, at: int) -> None:
|
|
80
|
+
"""Record that a call was permitted and executed."""
|
|
81
|
+
self._start(at)
|
|
82
|
+
self._usage.calls += 1
|
|
83
|
+
|
|
84
|
+
def add_tokens(self, tokens: float, usd: float = 0, at: int | None = None) -> None:
|
|
85
|
+
"""Record raw token consumption with a directly known cost."""
|
|
86
|
+
_require_consumption("tokens", tokens)
|
|
87
|
+
_require_consumption("usd", usd)
|
|
88
|
+
self._start(now_ms() if at is None else at)
|
|
89
|
+
self._usage.tokens += tokens
|
|
90
|
+
self._usage.usd += usd
|
|
91
|
+
|
|
92
|
+
def add_usage(
|
|
93
|
+
self,
|
|
94
|
+
input_tokens: float,
|
|
95
|
+
output_tokens: float,
|
|
96
|
+
price: TokenPrice | Mapping[str, float] | None = None,
|
|
97
|
+
at: int | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Record a model turn, deriving cost from a price table.
|
|
101
|
+
Kept separate from add_tokens so the pricing assumption is visible in the
|
|
102
|
+
caller rather than buried in a magic number.
|
|
103
|
+
|
|
104
|
+
A plain mapping is accepted as well as a TokenPrice. The reference
|
|
105
|
+
documentation shows an object literal, so a reader porting that example
|
|
106
|
+
reaches for ``{"input": 0.003, "output": 0.015}``; rejecting it with an
|
|
107
|
+
AttributeError deep in the ledger would be a poor welcome.
|
|
108
|
+
"""
|
|
109
|
+
price = as_token_price(price)
|
|
110
|
+
_require_consumption("input tokens", input_tokens)
|
|
111
|
+
_require_consumption("output tokens", output_tokens)
|
|
112
|
+
_require_consumption("price.input", price.input)
|
|
113
|
+
_require_consumption("price.output", price.output)
|
|
114
|
+
usd = (input_tokens / 1000) * price.input + (output_tokens / 1000) * price.output
|
|
115
|
+
self.add_tokens(input_tokens + output_tokens, usd, at)
|
|
116
|
+
|
|
117
|
+
def add_bytes(self, size: float, at: int | None = None) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Record the size of a tool result.
|
|
120
|
+
|
|
121
|
+
Called after the tool has run, because the size of what comes back is
|
|
122
|
+
not knowable before it does.
|
|
123
|
+
"""
|
|
124
|
+
_require_consumption("bytes", size)
|
|
125
|
+
self._start(at if at is not None else now_ms())
|
|
126
|
+
self._usage.bytes += size
|
|
127
|
+
|
|
128
|
+
def add_spend(self, usd: float, at: int | None = None) -> None:
|
|
129
|
+
"""Record spend that is not token-denominated, e.g. a paid API call."""
|
|
130
|
+
_require_consumption("usd", usd)
|
|
131
|
+
self._start(now_ms() if at is None else at)
|
|
132
|
+
self._usage.usd += usd
|
|
133
|
+
|
|
134
|
+
def reset(self) -> None:
|
|
135
|
+
"""Reset every counter. Used between runs when a process is long-lived."""
|
|
136
|
+
self._usage = BudgetUsage()
|
|
137
|
+
|
|
138
|
+
def _start(self, at: int) -> None:
|
|
139
|
+
"""The time budget starts at the first metered event, not at construction."""
|
|
140
|
+
if self._usage.started_at is None:
|
|
141
|
+
self._usage.started_at = at
|
toolwrit/cli.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The ``toolwrit`` binary.
|
|
3
|
+
|
|
4
|
+
A policy is only useful if it can be reviewed (``explain``), tested in CI
|
|
5
|
+
(``check``) and defended afterwards (``verify``) without writing any code.
|
|
6
|
+
Those three subcommands are what this file provides, with the same grammar,
|
|
7
|
+
the same output and the same exit codes as the TypeScript CLI, so a chain or a
|
|
8
|
+
policy can move between the two implementations without anyone relearning
|
|
9
|
+
anything.
|
|
10
|
+
|
|
11
|
+
``toolwrit run`` -- the MCP proxy -- is deliberately absent here; see the README.
|
|
12
|
+
|
|
13
|
+
Argument parsing is hand-rolled rather than argparse-driven because the
|
|
14
|
+
TypeScript grammar it has to match is not argparse's, in particular the bare
|
|
15
|
+
``--`` hard stop and the ``--flag`` with no value.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Sequence
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
from .audit.verify import verify_file
|
|
26
|
+
from .toolwrit import Toolwrit
|
|
27
|
+
from .policy.load import PolicyError, load_policy_file
|
|
28
|
+
from .types import ArgConstraint, Decision, Policy
|
|
29
|
+
from ._js import js_json, js_number
|
|
30
|
+
|
|
31
|
+
USAGE = """toolwrit — a written authority for AI agents.
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
toolwrit verify <audit.jsonl>
|
|
35
|
+
toolwrit check --policy <file> --tool <name> [--args <json>]
|
|
36
|
+
toolwrit explain --policy <file>
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--help Show this text.
|
|
40
|
+
--version Print the toolwrit version.
|
|
41
|
+
|
|
42
|
+
Exit codes:
|
|
43
|
+
verify 0 when the chain is intact, 1 when it is not.
|
|
44
|
+
check 0 allow, 1 deny, 2 ask.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ParsedArgs:
|
|
50
|
+
flags: dict[str, str] = field(default_factory=dict)
|
|
51
|
+
positional: list[str] = field(default_factory=list)
|
|
52
|
+
#: Everything after a bare ``--``, passed through untouched.
|
|
53
|
+
rest: list[str] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_args(argv: Sequence[str]) -> ParsedArgs:
|
|
57
|
+
"""
|
|
58
|
+
Parse ``--key value`` / ``--key=value`` flags plus positionals.
|
|
59
|
+
|
|
60
|
+
``--`` is a hard stop: the remainder is a downstream command line and must
|
|
61
|
+
never be interpreted, or a server's own ``--policy`` flag would be stolen.
|
|
62
|
+
"""
|
|
63
|
+
parsed = ParsedArgs()
|
|
64
|
+
i = 0
|
|
65
|
+
while i < len(argv):
|
|
66
|
+
token = argv[i]
|
|
67
|
+
if token == "--":
|
|
68
|
+
parsed.rest.extend(argv[i + 1:])
|
|
69
|
+
break
|
|
70
|
+
if token.startswith("--"):
|
|
71
|
+
body = token[2:]
|
|
72
|
+
if "=" in body:
|
|
73
|
+
key, _, value = body.partition("=")
|
|
74
|
+
parsed.flags[key] = value
|
|
75
|
+
elif i + 1 < len(argv) and not argv[i + 1].startswith("--"):
|
|
76
|
+
parsed.flags[body] = argv[i + 1]
|
|
77
|
+
i += 1
|
|
78
|
+
else:
|
|
79
|
+
parsed.flags[body] = "true"
|
|
80
|
+
else:
|
|
81
|
+
parsed.positional.append(token)
|
|
82
|
+
i += 1
|
|
83
|
+
return parsed
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CliError(Exception):
|
|
87
|
+
"""Errors we raise ourselves, and can therefore report without a traceback."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _reject_unknown_flags(
|
|
91
|
+
parsed: ParsedArgs, allowed: Sequence[str], subcommand: str
|
|
92
|
+
) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Refuse a flag this subcommand does not implement.
|
|
95
|
+
|
|
96
|
+
A mistyped or unsupported flag that is quietly dropped turns into a check
|
|
97
|
+
the operator believes ran and did not, which is the same class of mistake
|
|
98
|
+
the policy loader refuses to make with an unknown policy field.
|
|
99
|
+
"""
|
|
100
|
+
unknown = sorted(set(parsed.flags) - set(allowed) - {"help", "version"})
|
|
101
|
+
if unknown:
|
|
102
|
+
names = ", ".join(f'"--{flag}"' for flag in unknown)
|
|
103
|
+
raise CliError(f"unknown flag(s) {names} for `toolwrit {subcommand}`")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
107
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
108
|
+
parsed = parse_args(argv)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
if not argv or "help" in parsed.flags or parsed.positional[:1] == ["help"]:
|
|
112
|
+
sys.stdout.write(USAGE)
|
|
113
|
+
return 0
|
|
114
|
+
if "version" in parsed.flags:
|
|
115
|
+
sys.stdout.write(f"{__version__}\n")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
subcommand = parsed.positional[0] if parsed.positional else ""
|
|
119
|
+
if subcommand == "verify":
|
|
120
|
+
return _cmd_verify(parsed)
|
|
121
|
+
if subcommand == "check":
|
|
122
|
+
return _cmd_check(parsed)
|
|
123
|
+
if subcommand == "explain":
|
|
124
|
+
return _cmd_explain(parsed)
|
|
125
|
+
if subcommand == "run":
|
|
126
|
+
raise CliError(
|
|
127
|
+
"`toolwrit run` (the MCP proxy) is not part of the Python port; "
|
|
128
|
+
"use the TypeScript CLI for it"
|
|
129
|
+
)
|
|
130
|
+
raise CliError(f'unknown subcommand "{subcommand}"\n\n{USAGE}')
|
|
131
|
+
except CliError as err:
|
|
132
|
+
sys.stderr.write(f"toolwrit: {err}\n")
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _cmd_verify(parsed: ParsedArgs) -> int:
|
|
137
|
+
file = parsed.positional[1] if len(parsed.positional) > 1 else None
|
|
138
|
+
if not file:
|
|
139
|
+
raise CliError("verify needs an audit file, e.g. `toolwrit verify audit.jsonl`")
|
|
140
|
+
|
|
141
|
+
# Silently ignoring an unimplemented flag would be a fail-open in the one
|
|
142
|
+
# command whose job is detecting tampering: `verify --against` exists to
|
|
143
|
+
# catch a truncated log, and a run that prints "ok" and exits 0 without
|
|
144
|
+
# having performed the anchor check is worse than no check at all.
|
|
145
|
+
if "against" in parsed.flags:
|
|
146
|
+
raise CliError(
|
|
147
|
+
"`verify --against <anchor>` is not implemented in the Python CLI; "
|
|
148
|
+
"the chain check alone cannot detect a truncated log, so use the "
|
|
149
|
+
"TypeScript CLI (`toolwrit verify <file> --against <anchor>`) rather "
|
|
150
|
+
"than reading this run as a pass"
|
|
151
|
+
)
|
|
152
|
+
_reject_unknown_flags(parsed, (), "verify")
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
result = verify_file(file)
|
|
156
|
+
except OSError as err:
|
|
157
|
+
raise CliError(f"cannot read audit file {file} ({err})") from err
|
|
158
|
+
|
|
159
|
+
if result.ok:
|
|
160
|
+
plural = "y" if result.count == 1 else "ies"
|
|
161
|
+
sys.stdout.write(f"ok: {result.count} entr{plural} verified\n")
|
|
162
|
+
sys.stdout.write(f"head: {result.head}\n")
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
sys.stderr.write(
|
|
166
|
+
f"FAILED: audit chain is not intact ({result.count} entries read)\n"
|
|
167
|
+
)
|
|
168
|
+
if result.failure is not None:
|
|
169
|
+
sys.stderr.write(f" seq: {result.failure.seq}\n")
|
|
170
|
+
sys.stderr.write(f" reason: {result.failure.reason}\n")
|
|
171
|
+
sys.stderr.write(f" detail: {result.failure.detail}\n")
|
|
172
|
+
return 1
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _cmd_check(parsed: ParsedArgs) -> int:
|
|
176
|
+
import json
|
|
177
|
+
|
|
178
|
+
policy = _load_policy(_require_flag(parsed, "policy"))
|
|
179
|
+
tool = _require_flag(parsed, "tool")
|
|
180
|
+
|
|
181
|
+
raw = parsed.flags.get("args")
|
|
182
|
+
args: dict[str, object] = {}
|
|
183
|
+
if raw is not None and raw != "true":
|
|
184
|
+
try:
|
|
185
|
+
value = json.loads(raw)
|
|
186
|
+
except ValueError as err:
|
|
187
|
+
raise CliError(f"--args is not valid JSON ({err})") from err
|
|
188
|
+
if not isinstance(value, dict):
|
|
189
|
+
raise CliError("--args must be a JSON object")
|
|
190
|
+
args = value
|
|
191
|
+
|
|
192
|
+
# No audit file and no execution: `check` is a dry run, safe to put in CI.
|
|
193
|
+
decision = Toolwrit(policy).check(tool, args)
|
|
194
|
+
_print_decision(tool, decision)
|
|
195
|
+
return 0 if decision.effect == "allow" else 1 if decision.effect == "deny" else 2
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _print_decision(tool: str, decision: Decision) -> None:
|
|
199
|
+
sys.stdout.write(f"{decision.effect.upper()} {tool}\n")
|
|
200
|
+
sys.stdout.write(f" rule: {decision.rule or '(policy default)'}\n")
|
|
201
|
+
sys.stdout.write(f" reason: {decision.reason}\n")
|
|
202
|
+
if decision.violations:
|
|
203
|
+
sys.stdout.write(" violations:\n")
|
|
204
|
+
for violation in decision.violations:
|
|
205
|
+
where = f" {violation.path}" if violation.path else ""
|
|
206
|
+
sys.stdout.write(
|
|
207
|
+
f" - [{violation.rule}{where}] {violation.constraint}: "
|
|
208
|
+
f"{violation.message}\n"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _cmd_explain(parsed: ParsedArgs) -> int:
|
|
213
|
+
file = _require_flag(parsed, "policy")
|
|
214
|
+
sys.stdout.write(explain_policy(_load_policy(file), file))
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def explain_policy(policy: Policy, file: str) -> str:
|
|
219
|
+
out: list[str] = []
|
|
220
|
+
out.append(f"{policy.name or file} (version {policy.version})")
|
|
221
|
+
out.append(f"default effect: {policy.default or 'deny'}")
|
|
222
|
+
|
|
223
|
+
if policy.budget is not None:
|
|
224
|
+
parts = [
|
|
225
|
+
f"{key}={js_number(value)}"
|
|
226
|
+
for key, value in (
|
|
227
|
+
# Same order as the TypeScript CLI: a reviewer comparing the
|
|
228
|
+
# two implementations on one policy must see one rendering, and
|
|
229
|
+
# a dimension missing here reads as a ceiling that isn't set.
|
|
230
|
+
("calls", policy.budget.calls),
|
|
231
|
+
("tokens", policy.budget.tokens),
|
|
232
|
+
("usd", policy.budget.usd),
|
|
233
|
+
("bytes", policy.budget.bytes),
|
|
234
|
+
("seconds", policy.budget.seconds),
|
|
235
|
+
)
|
|
236
|
+
if value is not None
|
|
237
|
+
]
|
|
238
|
+
out.append(f"budget: {', '.join(parts) if parts else '(none)'}")
|
|
239
|
+
else:
|
|
240
|
+
out.append("budget: unlimited")
|
|
241
|
+
|
|
242
|
+
out.append("")
|
|
243
|
+
out.append(
|
|
244
|
+
f"rules ({len(policy.rules)}); among rules that match, "
|
|
245
|
+
"deny beats ask beats allow:"
|
|
246
|
+
)
|
|
247
|
+
for rule in policy.rules:
|
|
248
|
+
out.append("")
|
|
249
|
+
out.append(f" {rule.id} [{rule.effect}]")
|
|
250
|
+
if rule.description:
|
|
251
|
+
out.append(f" {rule.description}")
|
|
252
|
+
out.append(f" tools: {', '.join(rule.tools)}")
|
|
253
|
+
if rule.limit is not None:
|
|
254
|
+
window = (
|
|
255
|
+
f"{js_number(rule.limit.perSeconds)}s"
|
|
256
|
+
if rule.limit.perSeconds is not None
|
|
257
|
+
else "run"
|
|
258
|
+
)
|
|
259
|
+
out.append(f" limit: {js_number(rule.limit.max)} call(s) per {window}")
|
|
260
|
+
for path, constraint in (rule.when or {}).items():
|
|
261
|
+
out.append(f" when {path}: {describe_constraint(constraint)}")
|
|
262
|
+
|
|
263
|
+
return "\n".join(out) + "\n"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def describe_constraint(c: ArgConstraint) -> str:
|
|
267
|
+
"""Render one constraint as a phrase a reviewer can read out loud."""
|
|
268
|
+
parts: list[str] = []
|
|
269
|
+
if c.type:
|
|
270
|
+
parts.append(f"is {c.type}")
|
|
271
|
+
if c.optional:
|
|
272
|
+
parts.append("may be absent")
|
|
273
|
+
if c.oneOf is not None:
|
|
274
|
+
parts.append(f"one of {js_json(c.oneOf)}")
|
|
275
|
+
if c.noneOf is not None:
|
|
276
|
+
parts.append(f"none of {js_json(c.noneOf)}")
|
|
277
|
+
if c.matches:
|
|
278
|
+
parts.append(f"matches /{c.matches}/")
|
|
279
|
+
if c.startsWith is not None:
|
|
280
|
+
parts.append("starts with " + " | ".join(c.startsWith))
|
|
281
|
+
if c.excludes is not None:
|
|
282
|
+
parts.append("excludes " + " | ".join(c.excludes))
|
|
283
|
+
if c.urlHosts is not None:
|
|
284
|
+
parts.append("url host in " + " | ".join(c.urlHosts))
|
|
285
|
+
if c.min is not None:
|
|
286
|
+
parts.append(f">= {js_number(c.min)}")
|
|
287
|
+
if c.max is not None:
|
|
288
|
+
parts.append(f"<= {js_number(c.max)}")
|
|
289
|
+
if c.minLength is not None:
|
|
290
|
+
parts.append(f"length >= {js_number(c.minLength)}")
|
|
291
|
+
if c.maxLength is not None:
|
|
292
|
+
parts.append(f"length <= {js_number(c.maxLength)}")
|
|
293
|
+
return "; ".join(parts) if parts else "any value"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _require_flag(parsed: ParsedArgs, name: str) -> str:
|
|
297
|
+
value = parsed.flags.get(name)
|
|
298
|
+
if value is None or value == "true":
|
|
299
|
+
raise CliError(f"missing required --{name} <value>")
|
|
300
|
+
return value
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _load_policy(file: str) -> Policy:
|
|
304
|
+
try:
|
|
305
|
+
return load_policy_file(file)
|
|
306
|
+
except PolicyError as err:
|
|
307
|
+
raise CliError(str(err)) from err
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Policy parsing, matching and the pure decision function."""
|