agentmandate 0.3.2__tar.gz → 0.4.0__tar.gz
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.
- {agentmandate-0.3.2 → agentmandate-0.4.0}/CHANGELOG.md +18 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/PKG-INFO +29 -2
- {agentmandate-0.3.2 → agentmandate-0.4.0}/README.md +28 -1
- {agentmandate-0.3.2 → agentmandate-0.4.0}/ROADMAP.md +9 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/STABILITY.md +1 -1
- {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/__init__.py +19 -1
- {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/cli.py +51 -0
- agentmandate-0.4.0/agentmandate/scenarios.py +334 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/docs/assets/authority-path.svg +18 -16
- agentmandate-0.4.0/docs/evaluation-loop.md +104 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/docs/test-obligations.md +5 -1
- {agentmandate-0.3.2 → agentmandate-0.4.0}/pyproject.toml +1 -1
- {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_cli.py +70 -0
- agentmandate-0.4.0/tests/test_readme_asset.py +30 -0
- agentmandate-0.4.0/tests/test_scenarios.py +268 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/.github/workflows/ci.yml +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/.github/workflows/release.yml +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/.gitignore +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/CONTRIBUTING.md +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/DESIGN.md +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/LICENSE +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/RELEASING.md +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/SECURITY.md +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/diff.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/lint.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/manifest.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/obligations.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/py.typed +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/reach.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/scan.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/agentmandate/verify.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/docs/manifest.md +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/examples/dispute-resolver-sod.yaml +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/examples/dispute-resolver-v2.yaml +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/examples/dispute-resolver.yaml +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/examples/mcp-tools.json +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/examples/observed-calls.jsonl +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/examples/reviewed-obligations.json +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_diff.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_lint.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_manifest.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_obligations.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_reach.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_scan.py +0 -0
- {agentmandate-0.3.2 → agentmandate-0.4.0}/tests/test_verify.py +0 -0
|
@@ -6,6 +6,24 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.4.0 - 2026-07-30
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `mandate scenarios` exports each reachable breach as a versioned, structured
|
|
14
|
+
scenario-test skeleton. It preserves the counterexample while leaving the
|
|
15
|
+
environment, agent input, and expected control boundary for human review.
|
|
16
|
+
- Reviewed scenario fields reconcile against the current reachable witnesses.
|
|
17
|
+
Disappearing paths are removed and newly reachable paths return unreviewed.
|
|
18
|
+
- An evaluation-loop guide separates permitted reachability, observed agent
|
|
19
|
+
behaviour, per-call enforcement, and runtime conformance.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- The README and test-obligation guide connect decision-point obligations to
|
|
24
|
+
AgentVerity and compound paths to external scenario evaluators without
|
|
25
|
+
turning AgentMandate into a runner.
|
|
26
|
+
|
|
9
27
|
## 0.3.2 - 2026-07-28
|
|
10
28
|
|
|
11
29
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentmandate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Compound-path and cross-release analysis of what an AI agent is permitted to do
|
|
5
5
|
Project-URL: Homepage, https://github.com/mrwersa/agentmandate
|
|
6
6
|
Project-URL: Repository, https://github.com/mrwersa/agentmandate
|
|
@@ -80,6 +80,8 @@ cannot:
|
|
|
80
80
|
closed when a required control field is absent.
|
|
81
81
|
- **What must the tests exercise?** `mandate obligations` turns reachable
|
|
82
82
|
authority into reviewable test obligations.
|
|
83
|
+
- **Which compound risks need scenarios?** `mandate scenarios` preserves each
|
|
84
|
+
counterexample as a neutral test skeleton without inventing an agent prompt.
|
|
83
85
|
|
|
84
86
|
Alpha. Apache-2.0.
|
|
85
87
|
|
|
@@ -217,6 +219,7 @@ A ceiling is the maximum **cumulative** value one tool may spend against one bin
|
|
|
217
219
|
| `mandate diff` | Effective-authority comparison of two manifests, including limits, preconditions, approvals, effects, and scope minting. `--record` emits a change record |
|
|
218
220
|
| `mandate verify` | Replays recorded tool calls against the manifest and fails closed when evidence required by a declared control is missing |
|
|
219
221
|
| `mandate obligations` | Derives reviewable test obligations from reachable authority, and renders reviewed ones as an [AgentVerity](https://github.com/mrwersa/agentverity) decision suite |
|
|
222
|
+
| `mandate scenarios` | Exports reachable breach paths with blank environment, agent-input, and expected-control fields for human review and execution by an external evaluation harness |
|
|
220
223
|
|
|
221
224
|
Every analysis command takes `--json` and exits non-zero on a finding, so they drop into CI unchanged. `scan` writes a manifest to standard output and is a one-off, not a gate.
|
|
222
225
|
|
|
@@ -241,6 +244,28 @@ For a spending tool, each trace record must carry the scope, value, currency,
|
|
|
241
244
|
approval state, and executing principal. Missing or malformed control evidence
|
|
242
245
|
does not pass as an empty value.
|
|
243
246
|
|
|
247
|
+
## From authority to evaluation
|
|
248
|
+
|
|
249
|
+
AgentMandate produces two different test inputs:
|
|
250
|
+
|
|
251
|
+
- `obligations` names consequential decision points that reviewed bounded
|
|
252
|
+
decision tests should reach
|
|
253
|
+
- `scenarios` preserves compound counterexample paths that a multi-step
|
|
254
|
+
scenario test should attempt
|
|
255
|
+
|
|
256
|
+
It does not execute either test. Promptfoo, LangSmith, AgentCore Evaluations,
|
|
257
|
+
pytest, or an internal harness owns behaviour and outcome grading. AgentVerity
|
|
258
|
+
can qualify the repeated bounded decisions after correctness passes.
|
|
259
|
+
|
|
260
|
+
```text
|
|
261
|
+
reachability -> reviewed obligations and scenarios -> external evaluation
|
|
262
|
+
^ |
|
|
263
|
+
| v
|
|
264
|
+
manifest <- reviewed production incidents <- runtime policy and traces
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
[Read the complete evaluation-loop workflow](docs/evaluation-loop.md).
|
|
268
|
+
|
|
244
269
|
## Where it fits, and what already exists
|
|
245
270
|
|
|
246
271
|
This is analysis, not enforcement. It runs in CI against a manifest, it does not sit in the request path.
|
|
@@ -285,6 +310,8 @@ Search is bounded by `limits.depth`. No breach at depth 8 is not proof that none
|
|
|
285
310
|
## Documentation
|
|
286
311
|
|
|
287
312
|
- [DESIGN.md](DESIGN.md) — the authority model, why the search is shaped this way, and what was left out
|
|
313
|
+
- [docs/evaluation-loop.md](docs/evaluation-loop.md) — how authority analysis, scenario evaluation, runtime policy, and production feedback remain distinct
|
|
314
|
+
- [docs/test-obligations.md](docs/test-obligations.md) — decision-point obligations and the AgentVerity bridge
|
|
288
315
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — branch and review workflow
|
|
289
316
|
- [SECURITY.md](SECURITY.md) — reporting, and what a manifest may contain
|
|
290
317
|
- [STABILITY.md](STABILITY.md) — what is guaranteed before 1.0
|
|
@@ -304,7 +331,7 @@ ruff check .
|
|
|
304
331
|
|
|
305
332
|
## Status
|
|
306
333
|
|
|
307
|
-
Alpha, version 0.
|
|
334
|
+
Alpha, version 0.4.0. The authority model is the part most likely to change,
|
|
308
335
|
because it has not yet been pointed at enough real tool graphs to know where it
|
|
309
336
|
is too coarse. Issues describing a graph it models badly are the most useful
|
|
310
337
|
thing you can file.
|
|
@@ -46,6 +46,8 @@ cannot:
|
|
|
46
46
|
closed when a required control field is absent.
|
|
47
47
|
- **What must the tests exercise?** `mandate obligations` turns reachable
|
|
48
48
|
authority into reviewable test obligations.
|
|
49
|
+
- **Which compound risks need scenarios?** `mandate scenarios` preserves each
|
|
50
|
+
counterexample as a neutral test skeleton without inventing an agent prompt.
|
|
49
51
|
|
|
50
52
|
Alpha. Apache-2.0.
|
|
51
53
|
|
|
@@ -183,6 +185,7 @@ A ceiling is the maximum **cumulative** value one tool may spend against one bin
|
|
|
183
185
|
| `mandate diff` | Effective-authority comparison of two manifests, including limits, preconditions, approvals, effects, and scope minting. `--record` emits a change record |
|
|
184
186
|
| `mandate verify` | Replays recorded tool calls against the manifest and fails closed when evidence required by a declared control is missing |
|
|
185
187
|
| `mandate obligations` | Derives reviewable test obligations from reachable authority, and renders reviewed ones as an [AgentVerity](https://github.com/mrwersa/agentverity) decision suite |
|
|
188
|
+
| `mandate scenarios` | Exports reachable breach paths with blank environment, agent-input, and expected-control fields for human review and execution by an external evaluation harness |
|
|
186
189
|
|
|
187
190
|
Every analysis command takes `--json` and exits non-zero on a finding, so they drop into CI unchanged. `scan` writes a manifest to standard output and is a one-off, not a gate.
|
|
188
191
|
|
|
@@ -207,6 +210,28 @@ For a spending tool, each trace record must carry the scope, value, currency,
|
|
|
207
210
|
approval state, and executing principal. Missing or malformed control evidence
|
|
208
211
|
does not pass as an empty value.
|
|
209
212
|
|
|
213
|
+
## From authority to evaluation
|
|
214
|
+
|
|
215
|
+
AgentMandate produces two different test inputs:
|
|
216
|
+
|
|
217
|
+
- `obligations` names consequential decision points that reviewed bounded
|
|
218
|
+
decision tests should reach
|
|
219
|
+
- `scenarios` preserves compound counterexample paths that a multi-step
|
|
220
|
+
scenario test should attempt
|
|
221
|
+
|
|
222
|
+
It does not execute either test. Promptfoo, LangSmith, AgentCore Evaluations,
|
|
223
|
+
pytest, or an internal harness owns behaviour and outcome grading. AgentVerity
|
|
224
|
+
can qualify the repeated bounded decisions after correctness passes.
|
|
225
|
+
|
|
226
|
+
```text
|
|
227
|
+
reachability -> reviewed obligations and scenarios -> external evaluation
|
|
228
|
+
^ |
|
|
229
|
+
| v
|
|
230
|
+
manifest <- reviewed production incidents <- runtime policy and traces
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
[Read the complete evaluation-loop workflow](docs/evaluation-loop.md).
|
|
234
|
+
|
|
210
235
|
## Where it fits, and what already exists
|
|
211
236
|
|
|
212
237
|
This is analysis, not enforcement. It runs in CI against a manifest, it does not sit in the request path.
|
|
@@ -251,6 +276,8 @@ Search is bounded by `limits.depth`. No breach at depth 8 is not proof that none
|
|
|
251
276
|
## Documentation
|
|
252
277
|
|
|
253
278
|
- [DESIGN.md](DESIGN.md) — the authority model, why the search is shaped this way, and what was left out
|
|
279
|
+
- [docs/evaluation-loop.md](docs/evaluation-loop.md) — how authority analysis, scenario evaluation, runtime policy, and production feedback remain distinct
|
|
280
|
+
- [docs/test-obligations.md](docs/test-obligations.md) — decision-point obligations and the AgentVerity bridge
|
|
254
281
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — branch and review workflow
|
|
255
282
|
- [SECURITY.md](SECURITY.md) — reporting, and what a manifest may contain
|
|
256
283
|
- [STABILITY.md](STABILITY.md) — what is guaranteed before 1.0
|
|
@@ -270,7 +297,7 @@ ruff check .
|
|
|
270
297
|
|
|
271
298
|
## Status
|
|
272
299
|
|
|
273
|
-
Alpha, version 0.
|
|
300
|
+
Alpha, version 0.4.0. The authority model is the part most likely to change,
|
|
274
301
|
because it has not yet been pointed at enough real tool graphs to know where it
|
|
275
302
|
is too coarse. Issues describing a graph it models badly are the most useful
|
|
276
303
|
thing you can file.
|
|
@@ -18,6 +18,11 @@ Version 0.3.2 makes the existing gate dependable:
|
|
|
18
18
|
- explicit handling of incomparable currencies and different agents
|
|
19
19
|
- reviewed test obligations reconciled against current reachable authority
|
|
20
20
|
|
|
21
|
+
## In progress: evaluation-loop bridge
|
|
22
|
+
|
|
23
|
+
- neutral scenario skeletons derived from compound counterexample paths, with
|
|
24
|
+
application input, environment, and expected control left for human review
|
|
25
|
+
|
|
21
26
|
## Next: easier adoption
|
|
22
27
|
|
|
23
28
|
1. **Framework inventory adapters.** Import tool catalogues from common agent
|
|
@@ -34,6 +39,10 @@ Version 0.3.2 makes the existing gate dependable:
|
|
|
34
39
|
These features overlap with mature CI and security tooling deliberately. The
|
|
35
40
|
new value remains the authority graph and its counterexamples.
|
|
36
41
|
|
|
42
|
+
The scenario exporter is the first evaluation-loop bridge. Keep execution in
|
|
43
|
+
the team's existing harness. The next adoption work should make manifests and
|
|
44
|
+
traces easier to obtain, not add a bundled judge or scenario runner.
|
|
45
|
+
|
|
37
46
|
## Then: widen the model where evidence demands it
|
|
38
47
|
|
|
39
48
|
Do not implement every candidate below in parallel. First collect tool graphs
|
|
@@ -13,7 +13,7 @@ against the declaration, so it is usable without another tool alongside it.
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
-
__version__ = "0.
|
|
16
|
+
__version__ = "0.4.0"
|
|
17
17
|
|
|
18
18
|
from .diff import Change, Delta, compare
|
|
19
19
|
from .lint import Finding, check
|
|
@@ -29,6 +29,16 @@ from .obligations import (
|
|
|
29
29
|
)
|
|
30
30
|
from .reach import Authority, Breach, Step, analyse
|
|
31
31
|
from .scan import Proposal, propose, render, scan_file
|
|
32
|
+
from .scenarios import (
|
|
33
|
+
SCENARIOS_SCHEMA,
|
|
34
|
+
Scenario,
|
|
35
|
+
ScenarioSet,
|
|
36
|
+
ScenarioStep,
|
|
37
|
+
derive_scenarios,
|
|
38
|
+
load_scenarios,
|
|
39
|
+
reconcile_scenarios,
|
|
40
|
+
save_scenarios,
|
|
41
|
+
)
|
|
32
42
|
from .verify import Conformance, Observation, Violation, replay, replay_file
|
|
33
43
|
|
|
34
44
|
__all__ = [
|
|
@@ -46,6 +56,10 @@ __all__ = [
|
|
|
46
56
|
"ObligationSet",
|
|
47
57
|
"Observation",
|
|
48
58
|
"Proposal",
|
|
59
|
+
"SCENARIOS_SCHEMA",
|
|
60
|
+
"Scenario",
|
|
61
|
+
"ScenarioSet",
|
|
62
|
+
"ScenarioStep",
|
|
49
63
|
"Step",
|
|
50
64
|
"Tool",
|
|
51
65
|
"Violation",
|
|
@@ -53,15 +67,19 @@ __all__ = [
|
|
|
53
67
|
"analyse",
|
|
54
68
|
"check",
|
|
55
69
|
"derive",
|
|
70
|
+
"derive_scenarios",
|
|
56
71
|
"compare",
|
|
57
72
|
"load",
|
|
58
73
|
"load_obligations",
|
|
74
|
+
"load_scenarios",
|
|
59
75
|
"loads",
|
|
60
76
|
"save_obligations",
|
|
77
|
+
"save_scenarios",
|
|
61
78
|
"to_decision_suite",
|
|
62
79
|
"propose",
|
|
63
80
|
"render",
|
|
64
81
|
"replay",
|
|
65
82
|
"replay_file",
|
|
83
|
+
"reconcile_scenarios",
|
|
66
84
|
"scan_file",
|
|
67
85
|
]
|
|
@@ -19,6 +19,12 @@ from .obligations import (
|
|
|
19
19
|
)
|
|
20
20
|
from .reach import analyse
|
|
21
21
|
from .scan import scan_file
|
|
22
|
+
from .scenarios import (
|
|
23
|
+
derive_scenarios,
|
|
24
|
+
load_scenarios,
|
|
25
|
+
reconcile_scenarios,
|
|
26
|
+
save_scenarios,
|
|
27
|
+
)
|
|
22
28
|
from .verify import replay_file
|
|
23
29
|
|
|
24
30
|
EXIT_OK = 0
|
|
@@ -100,6 +106,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
100
106
|
help="an obligations file whose decisions have been filled in",
|
|
101
107
|
)
|
|
102
108
|
|
|
109
|
+
scenarios_parser = subparsers.add_parser(
|
|
110
|
+
"scenarios",
|
|
111
|
+
help="export reachable breaches as reviewed scenario-test skeletons",
|
|
112
|
+
)
|
|
113
|
+
_add_manifest(scenarios_parser)
|
|
114
|
+
scenarios_parser.add_argument("--depth", type=_positive_int, default=None)
|
|
115
|
+
scenarios_parser.add_argument(
|
|
116
|
+
"--json", action="store_true", help="machine-readable output"
|
|
117
|
+
)
|
|
118
|
+
scenarios_parser.add_argument(
|
|
119
|
+
"--reviewed",
|
|
120
|
+
default=None,
|
|
121
|
+
help="a scenario file whose application-specific fields were reviewed",
|
|
122
|
+
)
|
|
123
|
+
scenarios_parser.add_argument(
|
|
124
|
+
"--output",
|
|
125
|
+
default=None,
|
|
126
|
+
help="write the reconciled JSON scenario skeleton to this path",
|
|
127
|
+
)
|
|
128
|
+
|
|
103
129
|
verify_parser = subparsers.add_parser(
|
|
104
130
|
"verify", help="replay recorded calls against the manifest"
|
|
105
131
|
)
|
|
@@ -171,6 +197,31 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
171
197
|
_emit(obligations.to_dict(), args.json, obligations.render())
|
|
172
198
|
return EXIT_FINDING if obligations.unreviewed else EXIT_OK
|
|
173
199
|
|
|
200
|
+
if args.command == "scenarios":
|
|
201
|
+
scenarios = derive_scenarios(mandate, depth=args.depth)
|
|
202
|
+
if args.reviewed:
|
|
203
|
+
try:
|
|
204
|
+
scenarios = reconcile_scenarios(
|
|
205
|
+
scenarios,
|
|
206
|
+
load_scenarios(args.reviewed),
|
|
207
|
+
)
|
|
208
|
+
except ValueError as exc:
|
|
209
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
210
|
+
return EXIT_USAGE
|
|
211
|
+
if args.output:
|
|
212
|
+
try:
|
|
213
|
+
save_scenarios(scenarios, args.output)
|
|
214
|
+
except OSError as exc:
|
|
215
|
+
print(f"error: cannot write scenarios: {exc}", file=sys.stderr)
|
|
216
|
+
return EXIT_USAGE
|
|
217
|
+
print(
|
|
218
|
+
f"wrote {len(scenarios.scenarios)} scenario(s) to {args.output}; "
|
|
219
|
+
f"{len(scenarios.unreviewed)} need review"
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
_emit(scenarios.to_dict(), args.json, scenarios.render())
|
|
223
|
+
return EXIT_FINDING if scenarios.scenarios else EXIT_OK
|
|
224
|
+
|
|
174
225
|
if args.command == "lint":
|
|
175
226
|
findings = check(mandate)
|
|
176
227
|
payload = {
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Turn reachable breach paths into reviewable scenario-test skeletons.
|
|
2
|
+
|
|
3
|
+
``reach`` proves that a call sequence is permitted by the reviewed manifest.
|
|
4
|
+
It does not prove that an agent will choose that sequence or that a deployed
|
|
5
|
+
control will allow it. A scenario test is the dynamic half of that question.
|
|
6
|
+
|
|
7
|
+
The exporter preserves the counterexample and leaves the application-specific
|
|
8
|
+
parts blank. A human must supply the environment setup, agent input, and
|
|
9
|
+
expected control boundary. The library never invents those facts.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from hashlib import sha256
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .manifest import Mandate
|
|
21
|
+
from .reach import Breach, Step, analyse
|
|
22
|
+
|
|
23
|
+
SCENARIOS_SCHEMA = "agentmandate.scenarios/v1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _required_string(value: Any, field: str, *, allow_blank: bool = False) -> str:
|
|
27
|
+
if not isinstance(value, str):
|
|
28
|
+
raise ValueError(f"scenario {field} must be a string")
|
|
29
|
+
if not allow_blank and not value.strip():
|
|
30
|
+
raise ValueError(f"scenario {field} must not be blank")
|
|
31
|
+
if value and not value.strip():
|
|
32
|
+
raise ValueError(f"scenario {field} must not be whitespace")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ScenarioStep:
|
|
38
|
+
"""One structured tool call from a static counterexample."""
|
|
39
|
+
|
|
40
|
+
tool: str
|
|
41
|
+
binding: str | None = None
|
|
42
|
+
spent: str | None = None
|
|
43
|
+
currency: str | None = None
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_step(cls, step: Step) -> ScenarioStep:
|
|
47
|
+
return cls(
|
|
48
|
+
tool=step.tool,
|
|
49
|
+
binding=step.binding,
|
|
50
|
+
spent=str(step.spent) if step.spent is not None else None,
|
|
51
|
+
currency=step.currency,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_dict(cls, value: Any) -> ScenarioStep:
|
|
56
|
+
if not isinstance(value, dict):
|
|
57
|
+
raise ValueError("scenario step must be an object")
|
|
58
|
+
tool = _required_string(value.get("tool"), "step tool")
|
|
59
|
+
binding = value.get("binding")
|
|
60
|
+
spent = value.get("spent")
|
|
61
|
+
currency = value.get("currency")
|
|
62
|
+
for field, item in (
|
|
63
|
+
("step binding", binding),
|
|
64
|
+
("step spent", spent),
|
|
65
|
+
("step currency", currency),
|
|
66
|
+
):
|
|
67
|
+
if item is not None:
|
|
68
|
+
_required_string(item, field)
|
|
69
|
+
if (spent is None) != (currency is None):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"scenario step spent and currency must be supplied together"
|
|
72
|
+
)
|
|
73
|
+
return cls(tool=tool, binding=binding, spent=spent, currency=currency)
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, str]:
|
|
76
|
+
payload = {"tool": self.tool}
|
|
77
|
+
if self.binding is not None:
|
|
78
|
+
payload["binding"] = self.binding
|
|
79
|
+
if self.spent is not None:
|
|
80
|
+
payload["spent"] = self.spent
|
|
81
|
+
payload["currency"] = self.currency or ""
|
|
82
|
+
return payload
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class Scenario:
|
|
87
|
+
"""A static witness plus the application facts needed to execute it."""
|
|
88
|
+
|
|
89
|
+
kind: str
|
|
90
|
+
detail: str
|
|
91
|
+
path: tuple[ScenarioStep, ...]
|
|
92
|
+
environment: tuple[str, ...] = ()
|
|
93
|
+
agent_input: str = ""
|
|
94
|
+
expected_control: str = ""
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def identifier(self) -> str:
|
|
98
|
+
route = ">".join(step.tool for step in self.path)
|
|
99
|
+
witness = {
|
|
100
|
+
"kind": self.kind,
|
|
101
|
+
"detail": self.detail,
|
|
102
|
+
"path": [step.to_dict() for step in self.path],
|
|
103
|
+
}
|
|
104
|
+
digest = sha256(
|
|
105
|
+
json.dumps(
|
|
106
|
+
witness,
|
|
107
|
+
sort_keys=True,
|
|
108
|
+
separators=(",", ":"),
|
|
109
|
+
).encode("utf-8")
|
|
110
|
+
).hexdigest()[:12]
|
|
111
|
+
return f"{self.kind}:{route}:{digest}"
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def reviewed(self) -> bool:
|
|
115
|
+
return (
|
|
116
|
+
bool(self.environment)
|
|
117
|
+
and bool(self.agent_input.strip())
|
|
118
|
+
and bool(self.expected_control.strip())
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_breach(cls, breach: Breach) -> Scenario:
|
|
123
|
+
return cls(
|
|
124
|
+
kind=breach.kind,
|
|
125
|
+
detail=breach.detail,
|
|
126
|
+
path=tuple(ScenarioStep.from_step(step) for step in breach.path),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def from_dict(cls, value: Any) -> Scenario:
|
|
131
|
+
if not isinstance(value, dict):
|
|
132
|
+
raise ValueError("scenario must be an object")
|
|
133
|
+
kind = _required_string(value.get("kind"), "kind")
|
|
134
|
+
detail = _required_string(value.get("detail"), "detail")
|
|
135
|
+
raw_path = value.get("path")
|
|
136
|
+
if not isinstance(raw_path, list) or not raw_path:
|
|
137
|
+
raise ValueError("scenario path must be a non-empty list")
|
|
138
|
+
raw_environment = value.get("environment", [])
|
|
139
|
+
if not isinstance(raw_environment, list) or any(
|
|
140
|
+
not isinstance(item, str) or not item.strip()
|
|
141
|
+
for item in raw_environment
|
|
142
|
+
):
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"scenario environment must be a list of non-blank strings"
|
|
145
|
+
)
|
|
146
|
+
scenario = cls(
|
|
147
|
+
kind=kind,
|
|
148
|
+
detail=detail,
|
|
149
|
+
path=tuple(ScenarioStep.from_dict(step) for step in raw_path),
|
|
150
|
+
environment=tuple(raw_environment),
|
|
151
|
+
agent_input=_required_string(
|
|
152
|
+
value.get("agent_input", ""), "agent_input", allow_blank=True
|
|
153
|
+
),
|
|
154
|
+
expected_control=_required_string(
|
|
155
|
+
value.get("expected_control", ""),
|
|
156
|
+
"expected_control",
|
|
157
|
+
allow_blank=True,
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
if "id" in value and value["id"] != scenario.identifier:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"scenario id {value['id']!r} does not match "
|
|
163
|
+
f"{scenario.identifier!r}"
|
|
164
|
+
)
|
|
165
|
+
return scenario
|
|
166
|
+
|
|
167
|
+
def to_dict(self) -> dict[str, Any]:
|
|
168
|
+
return {
|
|
169
|
+
"id": self.identifier,
|
|
170
|
+
"kind": self.kind,
|
|
171
|
+
"detail": self.detail,
|
|
172
|
+
"path": [step.to_dict() for step in self.path],
|
|
173
|
+
"environment": list(self.environment),
|
|
174
|
+
"agent_input": self.agent_input,
|
|
175
|
+
"expected_control": self.expected_control,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class ScenarioSet:
|
|
181
|
+
"""Scenario skeletons derived from one bounded authority analysis."""
|
|
182
|
+
|
|
183
|
+
agent: str
|
|
184
|
+
depth: int
|
|
185
|
+
truncated: bool
|
|
186
|
+
scenarios: tuple[Scenario, ...]
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def unreviewed(self) -> tuple[str, ...]:
|
|
190
|
+
return tuple(
|
|
191
|
+
scenario.identifier
|
|
192
|
+
for scenario in self.scenarios
|
|
193
|
+
if not scenario.reviewed
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def to_dict(self) -> dict[str, Any]:
|
|
197
|
+
return {
|
|
198
|
+
"schema": SCENARIOS_SCHEMA,
|
|
199
|
+
"agent": self.agent,
|
|
200
|
+
"depth": self.depth,
|
|
201
|
+
"truncated": self.truncated,
|
|
202
|
+
"scenarios": [scenario.to_dict() for scenario in self.scenarios],
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@classmethod
|
|
206
|
+
def from_dict(cls, value: Any) -> ScenarioSet:
|
|
207
|
+
if not isinstance(value, dict):
|
|
208
|
+
raise ValueError("scenarios root must be an object")
|
|
209
|
+
if value.get("schema") != SCENARIOS_SCHEMA:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"unsupported scenarios schema: {value.get('schema')!r}"
|
|
212
|
+
)
|
|
213
|
+
agent = _required_string(value.get("agent"), "agent")
|
|
214
|
+
depth = value.get("depth")
|
|
215
|
+
if isinstance(depth, bool) or not isinstance(depth, int) or depth < 1:
|
|
216
|
+
raise ValueError("scenario depth must be a positive integer")
|
|
217
|
+
truncated = value.get("truncated")
|
|
218
|
+
if not isinstance(truncated, bool):
|
|
219
|
+
raise ValueError("scenario truncated must be true or false")
|
|
220
|
+
raw_scenarios = value.get("scenarios")
|
|
221
|
+
if not isinstance(raw_scenarios, list):
|
|
222
|
+
raise ValueError("scenarios must be a list")
|
|
223
|
+
scenarios = tuple(Scenario.from_dict(item) for item in raw_scenarios)
|
|
224
|
+
identifiers = [scenario.identifier for scenario in scenarios]
|
|
225
|
+
if len(identifiers) != len(set(identifiers)):
|
|
226
|
+
raise ValueError("scenarios must not contain duplicate ids")
|
|
227
|
+
return cls(
|
|
228
|
+
agent=agent,
|
|
229
|
+
depth=depth,
|
|
230
|
+
truncated=truncated,
|
|
231
|
+
scenarios=scenarios,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def render(self) -> str:
|
|
235
|
+
lines = [f"counterexample scenarios for {self.agent}"]
|
|
236
|
+
if not self.scenarios:
|
|
237
|
+
lines.append(f" none within depth {self.depth}")
|
|
238
|
+
return "\n".join(lines)
|
|
239
|
+
for scenario in self.scenarios:
|
|
240
|
+
lines.append(f" {scenario.identifier}")
|
|
241
|
+
lines.append(
|
|
242
|
+
" path: " + " -> ".join(step.tool for step in scenario.path)
|
|
243
|
+
)
|
|
244
|
+
lines.append(
|
|
245
|
+
" environment: "
|
|
246
|
+
+ (
|
|
247
|
+
"; ".join(scenario.environment)
|
|
248
|
+
if scenario.environment
|
|
249
|
+
else "REVIEW: describe the test fixture and starting state"
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
lines.append(
|
|
253
|
+
" agent input: "
|
|
254
|
+
+ (
|
|
255
|
+
scenario.agent_input
|
|
256
|
+
or "REVIEW: write the input that should exercise this risk"
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
lines.append(
|
|
260
|
+
" expected: "
|
|
261
|
+
+ (
|
|
262
|
+
scenario.expected_control
|
|
263
|
+
or "REVIEW: state where the deployed control must stop it"
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
if self.truncated:
|
|
267
|
+
lines.append("")
|
|
268
|
+
lines.append(
|
|
269
|
+
f"search stopped at depth {self.depth}; deeper paths were not analysed"
|
|
270
|
+
)
|
|
271
|
+
return "\n".join(lines)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def derive_scenarios(mandate: Mandate, depth: int | None = None) -> ScenarioSet:
|
|
275
|
+
"""Preserve each reachable breach as a reviewable scenario skeleton."""
|
|
276
|
+
authority = analyse(mandate, depth=depth)
|
|
277
|
+
return ScenarioSet(
|
|
278
|
+
agent=mandate.agent,
|
|
279
|
+
depth=authority.depth,
|
|
280
|
+
truncated=authority.truncated,
|
|
281
|
+
scenarios=tuple(
|
|
282
|
+
Scenario.from_breach(breach) for breach in authority.breaches
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def reconcile_scenarios(
|
|
288
|
+
current: ScenarioSet,
|
|
289
|
+
reviewed: ScenarioSet,
|
|
290
|
+
) -> ScenarioSet:
|
|
291
|
+
"""Carry human-authored fields onto witnesses that remain reachable."""
|
|
292
|
+
if current.agent != reviewed.agent:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
f"cannot reconcile scenarios for {reviewed.agent!r} with "
|
|
295
|
+
f"{current.agent!r}"
|
|
296
|
+
)
|
|
297
|
+
by_id = {scenario.identifier: scenario for scenario in reviewed.scenarios}
|
|
298
|
+
reconciled = []
|
|
299
|
+
for scenario in current.scenarios:
|
|
300
|
+
earlier = by_id.get(scenario.identifier)
|
|
301
|
+
if earlier is None:
|
|
302
|
+
reconciled.append(scenario)
|
|
303
|
+
continue
|
|
304
|
+
reconciled.append(
|
|
305
|
+
Scenario(
|
|
306
|
+
kind=scenario.kind,
|
|
307
|
+
detail=scenario.detail,
|
|
308
|
+
path=scenario.path,
|
|
309
|
+
environment=earlier.environment,
|
|
310
|
+
agent_input=earlier.agent_input,
|
|
311
|
+
expected_control=earlier.expected_control,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
return ScenarioSet(
|
|
315
|
+
agent=current.agent,
|
|
316
|
+
depth=current.depth,
|
|
317
|
+
truncated=current.truncated,
|
|
318
|
+
scenarios=tuple(reconciled),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def load_scenarios(path: str | Path) -> ScenarioSet:
|
|
323
|
+
try:
|
|
324
|
+
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
325
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
326
|
+
raise ValueError(f"cannot load scenarios: {exc}") from exc
|
|
327
|
+
return ScenarioSet.from_dict(value)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def save_scenarios(scenarios: ScenarioSet, path: str | Path) -> None:
|
|
331
|
+
Path(path).write_text(
|
|
332
|
+
json.dumps(scenarios.to_dict(), indent=2, sort_keys=True) + "\n",
|
|
333
|
+
encoding="utf-8",
|
|
334
|
+
)
|