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.
@@ -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."""