agentmandate 0.1.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.
- agentmandate/__init__.py +51 -0
- agentmandate/cli.py +173 -0
- agentmandate/diff.py +189 -0
- agentmandate/lint.py +149 -0
- agentmandate/manifest.py +297 -0
- agentmandate/py.typed +0 -0
- agentmandate/reach.py +324 -0
- agentmandate/scan.py +205 -0
- agentmandate/verify.py +212 -0
- agentmandate-0.1.0.dist-info/METADATA +246 -0
- agentmandate-0.1.0.dist-info/RECORD +14 -0
- agentmandate-0.1.0.dist-info/WHEEL +4 -0
- agentmandate-0.1.0.dist-info/entry_points.txt +2 -0
- agentmandate-0.1.0.dist-info/licenses/LICENSE +202 -0
agentmandate/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""AgentMandate: analyse what an AI agent is permitted to do.
|
|
2
|
+
|
|
3
|
+
Two questions this package answers that a per-tool scanner cannot:
|
|
4
|
+
|
|
5
|
+
- Given a set of individually legal tools, is there a legal call sequence that
|
|
6
|
+
breaches a limit? ``reach`` returns the sequence, not a score.
|
|
7
|
+
- Did this release widen what the agent can reach? ``diff`` compares effective
|
|
8
|
+
authority rather than configuration text, because the two come apart.
|
|
9
|
+
|
|
10
|
+
It also covers the ordinary single-manifest checks and replays recorded runs
|
|
11
|
+
against the declaration, so it is usable without another tool alongside it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
from .diff import Change, Delta, compare
|
|
19
|
+
from .lint import Finding, check
|
|
20
|
+
from .manifest import Mandate, ManifestError, Money, Tool, load, loads
|
|
21
|
+
from .reach import Authority, Breach, Step, analyse
|
|
22
|
+
from .scan import Proposal, propose, render, scan_file
|
|
23
|
+
from .verify import Conformance, Observation, Violation, replay, replay_file
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Authority",
|
|
27
|
+
"Breach",
|
|
28
|
+
"Change",
|
|
29
|
+
"Conformance",
|
|
30
|
+
"Delta",
|
|
31
|
+
"Finding",
|
|
32
|
+
"Mandate",
|
|
33
|
+
"ManifestError",
|
|
34
|
+
"Money",
|
|
35
|
+
"Observation",
|
|
36
|
+
"Proposal",
|
|
37
|
+
"Step",
|
|
38
|
+
"Tool",
|
|
39
|
+
"Violation",
|
|
40
|
+
"__version__",
|
|
41
|
+
"analyse",
|
|
42
|
+
"check",
|
|
43
|
+
"compare",
|
|
44
|
+
"load",
|
|
45
|
+
"loads",
|
|
46
|
+
"propose",
|
|
47
|
+
"render",
|
|
48
|
+
"replay",
|
|
49
|
+
"replay_file",
|
|
50
|
+
"scan_file",
|
|
51
|
+
]
|
agentmandate/cli.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .diff import compare
|
|
12
|
+
from .lint import ERROR, check
|
|
13
|
+
from .manifest import ManifestError, load
|
|
14
|
+
from .reach import analyse
|
|
15
|
+
from .scan import scan_file
|
|
16
|
+
from .verify import replay_file
|
|
17
|
+
|
|
18
|
+
EXIT_OK = 0
|
|
19
|
+
EXIT_FINDING = 1
|
|
20
|
+
EXIT_USAGE = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _add_manifest(parser: argparse.ArgumentParser) -> None:
|
|
24
|
+
parser.add_argument("manifest", help="path to a mandate manifest (YAML or JSON)")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="mandate",
|
|
30
|
+
description=(
|
|
31
|
+
"Analyse what an AI agent is permitted to do, and what changed "
|
|
32
|
+
"between two releases."
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("--version", action="version", version=f"agentmandate {__version__}")
|
|
36
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
37
|
+
|
|
38
|
+
lint_parser = subparsers.add_parser(
|
|
39
|
+
"lint", help="single-manifest control checks"
|
|
40
|
+
)
|
|
41
|
+
_add_manifest(lint_parser)
|
|
42
|
+
lint_parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
43
|
+
|
|
44
|
+
reach_parser = subparsers.add_parser(
|
|
45
|
+
"reach",
|
|
46
|
+
help="search for a legal call sequence that breaches a limit",
|
|
47
|
+
)
|
|
48
|
+
_add_manifest(reach_parser)
|
|
49
|
+
reach_parser.add_argument(
|
|
50
|
+
"--depth", type=int, default=None, help="override the manifest search depth"
|
|
51
|
+
)
|
|
52
|
+
reach_parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
53
|
+
|
|
54
|
+
diff_parser = subparsers.add_parser(
|
|
55
|
+
"diff", help="compare the effective authority of two manifests"
|
|
56
|
+
)
|
|
57
|
+
diff_parser.add_argument("before", help="the released manifest")
|
|
58
|
+
diff_parser.add_argument("after", help="the proposed manifest")
|
|
59
|
+
diff_parser.add_argument("--depth", type=int, default=None)
|
|
60
|
+
diff_parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
61
|
+
diff_parser.add_argument(
|
|
62
|
+
"--record",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="emit a markdown change record for a change advisory board",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
verify_parser = subparsers.add_parser(
|
|
68
|
+
"verify", help="replay recorded calls against the manifest"
|
|
69
|
+
)
|
|
70
|
+
_add_manifest(verify_parser)
|
|
71
|
+
verify_parser.add_argument(
|
|
72
|
+
"--traces", required=True, help="JSON Lines file of observed tool calls"
|
|
73
|
+
)
|
|
74
|
+
verify_parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
75
|
+
|
|
76
|
+
scan_parser = subparsers.add_parser(
|
|
77
|
+
"scan",
|
|
78
|
+
help="derive a manifest skeleton from an MCP tools/list catalogue",
|
|
79
|
+
)
|
|
80
|
+
scan_parser.add_argument("catalogue", help="JSON file holding an MCP tools/list payload")
|
|
81
|
+
scan_parser.add_argument(
|
|
82
|
+
"--agent", default="unnamed-agent", help="agent name to write into the skeleton"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return parser
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _emit(payload: dict, as_json: bool, text: str) -> None:
|
|
89
|
+
if as_json:
|
|
90
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
91
|
+
else:
|
|
92
|
+
print(text)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
96
|
+
parser = build_parser()
|
|
97
|
+
args = parser.parse_args(argv)
|
|
98
|
+
|
|
99
|
+
if args.command == "scan":
|
|
100
|
+
try:
|
|
101
|
+
print(scan_file(args.catalogue, args.agent), end="")
|
|
102
|
+
except (OSError, ValueError) as exc:
|
|
103
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
104
|
+
return EXIT_USAGE
|
|
105
|
+
return EXIT_OK
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
if args.command == "diff":
|
|
109
|
+
before = load(args.before)
|
|
110
|
+
after = load(args.after)
|
|
111
|
+
else:
|
|
112
|
+
mandate = load(args.manifest)
|
|
113
|
+
except ManifestError as exc:
|
|
114
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
115
|
+
return EXIT_USAGE
|
|
116
|
+
|
|
117
|
+
if args.command == "lint":
|
|
118
|
+
findings = check(mandate)
|
|
119
|
+
payload = {
|
|
120
|
+
"findings": [
|
|
121
|
+
{
|
|
122
|
+
"rule": f.rule,
|
|
123
|
+
"severity": f.severity,
|
|
124
|
+
"subject": f.subject,
|
|
125
|
+
"message": f.message,
|
|
126
|
+
}
|
|
127
|
+
for f in findings
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
text = (
|
|
131
|
+
"\n".join(f.render() for f in findings)
|
|
132
|
+
if findings
|
|
133
|
+
else "no single-manifest findings"
|
|
134
|
+
)
|
|
135
|
+
_emit(payload, args.json, text)
|
|
136
|
+
return EXIT_FINDING if any(f.severity == ERROR for f in findings) else EXIT_OK
|
|
137
|
+
|
|
138
|
+
if args.command == "reach":
|
|
139
|
+
authority = analyse(mandate, depth=args.depth)
|
|
140
|
+
if authority.breaches:
|
|
141
|
+
text = "\n\n".join(b.render() for b in authority.breaches)
|
|
142
|
+
else:
|
|
143
|
+
reached = f"{len(authority.reachable_tools)} tool(s) reachable"
|
|
144
|
+
bound = (
|
|
145
|
+
f", most extractable {authority.max_extractable}"
|
|
146
|
+
if authority.max_extractable
|
|
147
|
+
else ""
|
|
148
|
+
)
|
|
149
|
+
depth_note = (
|
|
150
|
+
f" within depth {authority.depth}"
|
|
151
|
+
+ (" (search truncated)" if authority.truncated else "")
|
|
152
|
+
)
|
|
153
|
+
text = f"no reachable breach{depth_note}. {reached}{bound}"
|
|
154
|
+
_emit(authority.as_dict(), args.json, text)
|
|
155
|
+
return EXIT_FINDING if authority.breaches else EXIT_OK
|
|
156
|
+
|
|
157
|
+
if args.command == "diff":
|
|
158
|
+
delta = compare(before, after, depth=args.depth)
|
|
159
|
+
text = (
|
|
160
|
+
delta.record(args.before, args.after)
|
|
161
|
+
if args.record
|
|
162
|
+
else delta.render(args.before, args.after)
|
|
163
|
+
)
|
|
164
|
+
_emit(delta.as_dict(), args.json, text)
|
|
165
|
+
return EXIT_FINDING if delta.widened else EXIT_OK
|
|
166
|
+
|
|
167
|
+
conformance = replay_file(mandate, args.traces)
|
|
168
|
+
_emit(conformance.as_dict(), args.json, conformance.render())
|
|
169
|
+
return EXIT_OK if conformance.conformant else EXIT_FINDING
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__": # pragma: no cover
|
|
173
|
+
raise SystemExit(main())
|
agentmandate/diff.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Compare the effective authority of two releases.
|
|
2
|
+
|
|
3
|
+
The distinction this module exists to make: a configuration diff shows what
|
|
4
|
+
somebody typed, and an authority diff shows what the agent can now do. They
|
|
5
|
+
come apart often enough to matter. Adding one read-only tool is two lines of
|
|
6
|
+
YAML and can open a path that did not exist, because reachability composes and
|
|
7
|
+
text does not.
|
|
8
|
+
|
|
9
|
+
The output is deliberately shaped for CI. A widening change exits non-zero so a
|
|
10
|
+
pull request stops, and the change record it prints is the artefact a change
|
|
11
|
+
advisory board actually wants.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from decimal import Decimal
|
|
18
|
+
|
|
19
|
+
from .manifest import Mandate
|
|
20
|
+
from .reach import Authority, analyse
|
|
21
|
+
|
|
22
|
+
WIDENING = "widening"
|
|
23
|
+
NARROWING = "narrowing"
|
|
24
|
+
NEUTRAL = "neutral"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Change:
|
|
29
|
+
direction: str
|
|
30
|
+
kind: str
|
|
31
|
+
detail: str
|
|
32
|
+
|
|
33
|
+
def render(self) -> str:
|
|
34
|
+
marker = {WIDENING: "+", NARROWING: "-", NEUTRAL: "="}[self.direction]
|
|
35
|
+
return f" {marker} {self.kind}: {self.detail}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Delta:
|
|
40
|
+
before: Authority
|
|
41
|
+
after: Authority
|
|
42
|
+
changes: tuple[Change, ...]
|
|
43
|
+
after_agent: str = "agent"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def direction(self) -> str:
|
|
47
|
+
if any(c.direction == WIDENING for c in self.changes):
|
|
48
|
+
return WIDENING
|
|
49
|
+
if any(c.direction == NARROWING for c in self.changes):
|
|
50
|
+
return NARROWING
|
|
51
|
+
return NEUTRAL
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def widened(self) -> bool:
|
|
55
|
+
return self.direction == WIDENING
|
|
56
|
+
|
|
57
|
+
def render(self, before_label: str = "before", after_label: str = "after") -> str:
|
|
58
|
+
lines = [f"authority diff {before_label} -> {after_label}"]
|
|
59
|
+
if not self.changes:
|
|
60
|
+
lines.append(" = no change in effective authority")
|
|
61
|
+
else:
|
|
62
|
+
lines.extend(change.render() for change in self.changes)
|
|
63
|
+
lines.append("")
|
|
64
|
+
lines.append(f"verdict: {self.direction.upper()}")
|
|
65
|
+
if self.widened:
|
|
66
|
+
lines.append("a widening change needs named review before release")
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
def record(self, before_label: str = "before", after_label: str = "after") -> str:
|
|
70
|
+
"""A change record in the shape a change advisory board asks for.
|
|
71
|
+
|
|
72
|
+
The point of emitting this from the tool rather than leaving it to a
|
|
73
|
+
human is that the authority section is then derived rather than
|
|
74
|
+
asserted. A change record where somebody typed "no permission changes"
|
|
75
|
+
is worth very little.
|
|
76
|
+
"""
|
|
77
|
+
widening = [c for c in self.changes if c.direction == WIDENING]
|
|
78
|
+
narrowing = [c for c in self.changes if c.direction == NARROWING]
|
|
79
|
+
lines = [
|
|
80
|
+
"# Agent authority change record",
|
|
81
|
+
"",
|
|
82
|
+
f"- **Agent:** {self.after_agent}",
|
|
83
|
+
f"- **Compared:** `{before_label}` against `{after_label}`",
|
|
84
|
+
f"- **Verdict:** {self.direction.upper()}",
|
|
85
|
+
f"- **Search depth:** {self.after.depth}"
|
|
86
|
+
+ (" (truncated)" if self.after.truncated else ""),
|
|
87
|
+
"",
|
|
88
|
+
"## Authority gained",
|
|
89
|
+
"",
|
|
90
|
+
]
|
|
91
|
+
lines.extend([f"- {c.kind}: {c.detail}" for c in widening] or ["- none"])
|
|
92
|
+
lines.extend(["", "## Authority removed", ""])
|
|
93
|
+
lines.extend([f"- {c.kind}: {c.detail}" for c in narrowing] or ["- none"])
|
|
94
|
+
lines.extend(["", "## Reachable breaches after this change", ""])
|
|
95
|
+
lines.extend(
|
|
96
|
+
[f"- {b.detail}" for b in self.after.breaches]
|
|
97
|
+
or ["- none within the search depth"]
|
|
98
|
+
)
|
|
99
|
+
lines.extend(["", "## Review", ""])
|
|
100
|
+
if self.widened:
|
|
101
|
+
lines.append(
|
|
102
|
+
"This change widens what the agent is permitted to do. It needs a "
|
|
103
|
+
"named reviewer who accepts the gained authority above."
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
lines.append(
|
|
107
|
+
"This change does not widen what the agent is permitted to do."
|
|
108
|
+
)
|
|
109
|
+
lines.append("")
|
|
110
|
+
lines.append(
|
|
111
|
+
"Authority is computed from the manifests by bounded reachability. "
|
|
112
|
+
"An unchanged manifest does not prove unchanged authority if a tool "
|
|
113
|
+
"implementation, connector, or downstream role changed."
|
|
114
|
+
)
|
|
115
|
+
return "\n".join(lines)
|
|
116
|
+
|
|
117
|
+
def as_dict(self) -> dict:
|
|
118
|
+
return {
|
|
119
|
+
"direction": self.direction,
|
|
120
|
+
"changes": [
|
|
121
|
+
{"direction": c.direction, "kind": c.kind, "detail": c.detail}
|
|
122
|
+
for c in self.changes
|
|
123
|
+
],
|
|
124
|
+
"before": self.before.as_dict(),
|
|
125
|
+
"after": self.after.as_dict(),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _set_change(
|
|
130
|
+
kind: str, before: frozenset[str], after: frozenset[str]
|
|
131
|
+
) -> list[Change]:
|
|
132
|
+
changes: list[Change] = []
|
|
133
|
+
for gained in sorted(after - before):
|
|
134
|
+
changes.append(Change(WIDENING, kind, f"gained {gained}"))
|
|
135
|
+
for lost in sorted(before - after):
|
|
136
|
+
changes.append(Change(NARROWING, kind, f"lost {lost}"))
|
|
137
|
+
return changes
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def compare(before: Mandate, after: Mandate, depth: int | None = None) -> Delta:
|
|
141
|
+
"""Diff the reachable authority of two mandates."""
|
|
142
|
+
lhs = analyse(before, depth=depth)
|
|
143
|
+
rhs = analyse(after, depth=depth)
|
|
144
|
+
changes: list[Change] = []
|
|
145
|
+
|
|
146
|
+
changes.extend(_set_change("tool", lhs.reachable_tools, rhs.reachable_tools))
|
|
147
|
+
changes.extend(
|
|
148
|
+
_set_change(
|
|
149
|
+
"ungated irreversible",
|
|
150
|
+
lhs.ungated_irreversible,
|
|
151
|
+
rhs.ungated_irreversible,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
changes.extend(
|
|
155
|
+
_set_change(
|
|
156
|
+
"service principal",
|
|
157
|
+
lhs.service_principal_tools,
|
|
158
|
+
rhs.service_principal_tools,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
before_effects = frozenset(f"{effect} on {scope}" for effect, scope in lhs.effects)
|
|
163
|
+
after_effects = frozenset(f"{effect} on {scope}" for effect, scope in rhs.effects)
|
|
164
|
+
changes.extend(_set_change("effect", before_effects, after_effects))
|
|
165
|
+
|
|
166
|
+
lhs_max = lhs.max_extractable.amount if lhs.max_extractable else Decimal(0)
|
|
167
|
+
rhs_max = rhs.max_extractable.amount if rhs.max_extractable else Decimal(0)
|
|
168
|
+
if rhs_max != lhs_max:
|
|
169
|
+
currency = (
|
|
170
|
+
rhs.max_extractable.currency
|
|
171
|
+
if rhs.max_extractable
|
|
172
|
+
else (lhs.max_extractable.currency if lhs.max_extractable else "")
|
|
173
|
+
)
|
|
174
|
+
direction = WIDENING if rhs_max > lhs_max else NARROWING
|
|
175
|
+
changes.append(
|
|
176
|
+
Change(
|
|
177
|
+
direction,
|
|
178
|
+
"extractable value",
|
|
179
|
+
f"{lhs_max} -> {rhs_max} {currency}".strip(),
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
before_breaches = frozenset(b.kind for b in lhs.breaches)
|
|
184
|
+
after_breaches = frozenset(b.kind for b in rhs.breaches)
|
|
185
|
+
changes.extend(_set_change("reachable breach", before_breaches, after_breaches))
|
|
186
|
+
|
|
187
|
+
return Delta(
|
|
188
|
+
before=lhs, after=rhs, changes=tuple(changes), after_agent=after.agent
|
|
189
|
+
)
|
agentmandate/lint.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Single-manifest checks.
|
|
2
|
+
|
|
3
|
+
These are the defects visible in one manifest without walking the graph. Other
|
|
4
|
+
agent scanners cover much of this ground, and that overlap is deliberate: a
|
|
5
|
+
tool that only reported compound findings would be unusable without also
|
|
6
|
+
running one of them. The checks here are the floor, not the contribution.
|
|
7
|
+
|
|
8
|
+
Every finding names the control it comes from, because in a regulated setting
|
|
9
|
+
"the linter says no" carries less weight than "this breaks separation of
|
|
10
|
+
duties, and here is the pair of tools that proves it".
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from .manifest import CALLER, IRREVERSIBLE, SERVICE, Mandate
|
|
18
|
+
|
|
19
|
+
ERROR = "error"
|
|
20
|
+
WARNING = "warning"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Finding:
|
|
25
|
+
rule: str
|
|
26
|
+
severity: str
|
|
27
|
+
subject: str
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
def render(self) -> str:
|
|
31
|
+
return f"{self.severity.upper():7} {self.rule:24} {self.subject}\n {self.message}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _separation_of_duties(mandate: Mandate) -> list[Finding]:
|
|
35
|
+
"""One principal must not hold both sides of a maker-checker control.
|
|
36
|
+
|
|
37
|
+
NIST separates static conflict, where the role assignment itself is wrong,
|
|
38
|
+
from dynamic conflict enforced at access time. A manifest can only settle
|
|
39
|
+
the static half, so that is what this reports.
|
|
40
|
+
"""
|
|
41
|
+
findings: list[Finding] = []
|
|
42
|
+
proposers = set(mandate.roles.get("proposer", ()))
|
|
43
|
+
approvers = set(mandate.roles.get("approver", ()))
|
|
44
|
+
if not proposers or not approvers:
|
|
45
|
+
return findings
|
|
46
|
+
if proposers & approvers:
|
|
47
|
+
overlap = ", ".join(sorted(proposers & approvers))
|
|
48
|
+
findings.append(
|
|
49
|
+
Finding(
|
|
50
|
+
rule="sod.same-tool",
|
|
51
|
+
severity=ERROR,
|
|
52
|
+
subject=overlap,
|
|
53
|
+
message=(
|
|
54
|
+
"the same tool is listed as both proposer and approver, "
|
|
55
|
+
"so the control approves itself"
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
# The agent holds both sides even when the tools differ, because a single
|
|
60
|
+
# identity can call either one. An approve tool the proposing agent can
|
|
61
|
+
# invoke is not an independent second pair of eyes.
|
|
62
|
+
findings.append(
|
|
63
|
+
Finding(
|
|
64
|
+
rule="sod.single-identity",
|
|
65
|
+
severity=ERROR,
|
|
66
|
+
subject=f"{min(proposers)} + {min(approvers)}",
|
|
67
|
+
message=(
|
|
68
|
+
"one agent identity reaches both the proposer and the approver "
|
|
69
|
+
"role, so maker-checker is not enforced. The approver must be a "
|
|
70
|
+
"human or an independently authorised service the agent cannot "
|
|
71
|
+
"invoke"
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
return findings
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check(mandate: Mandate) -> list[Finding]:
|
|
79
|
+
"""Run every single-manifest rule and return findings, worst first."""
|
|
80
|
+
findings: list[Finding] = []
|
|
81
|
+
findings.extend(_separation_of_duties(mandate))
|
|
82
|
+
|
|
83
|
+
for tool in mandate.tools:
|
|
84
|
+
if tool.effect == IRREVERSIBLE and not tool.requires_approval:
|
|
85
|
+
findings.append(
|
|
86
|
+
Finding(
|
|
87
|
+
rule="effect.ungated-irreversible",
|
|
88
|
+
severity=ERROR,
|
|
89
|
+
subject=tool.name,
|
|
90
|
+
message=(
|
|
91
|
+
"an irreversible effect with no approval requirement. "
|
|
92
|
+
"Reversibility, not confidence, decides where a gate belongs"
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
if tool.principal == SERVICE:
|
|
97
|
+
findings.append(
|
|
98
|
+
Finding(
|
|
99
|
+
rule="identity.service-principal",
|
|
100
|
+
severity=ERROR if tool.effect != "read" else WARNING,
|
|
101
|
+
subject=tool.name,
|
|
102
|
+
message=(
|
|
103
|
+
"the call spends a service account rather than the "
|
|
104
|
+
f"caller's identity, which is the confused-deputy shape. "
|
|
105
|
+
f"Exchange the caller token instead (principal: {CALLER})"
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
if tool.spends_value and tool.scope_key not in tool.requires:
|
|
110
|
+
findings.append(
|
|
111
|
+
Finding(
|
|
112
|
+
rule="ceiling.unbound-scope",
|
|
113
|
+
severity=ERROR,
|
|
114
|
+
subject=tool.name,
|
|
115
|
+
message=(
|
|
116
|
+
f"the ceiling is measured against {tool.scope_key!r}, but the "
|
|
117
|
+
"tool does not require that scope, so the bound is not "
|
|
118
|
+
"attached to anything the caller was authorised for"
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
if tool.unbounded and tool.produces is None:
|
|
123
|
+
findings.append(
|
|
124
|
+
Finding(
|
|
125
|
+
rule="scope.unbounded-nothing",
|
|
126
|
+
severity=WARNING,
|
|
127
|
+
subject=tool.name,
|
|
128
|
+
message="marked unbounded but produces no scope, so the flag has no effect",
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
currencies = {t.ceiling.currency for t in mandate.tools if t.ceiling}
|
|
133
|
+
if mandate.limits.total is not None:
|
|
134
|
+
currencies.add(mandate.limits.total.currency)
|
|
135
|
+
if len(currencies) > 1:
|
|
136
|
+
findings.append(
|
|
137
|
+
Finding(
|
|
138
|
+
rule="ceiling.mixed-currency",
|
|
139
|
+
severity=ERROR,
|
|
140
|
+
subject=", ".join(sorted(currencies)),
|
|
141
|
+
message=(
|
|
142
|
+
"ceilings in more than one currency cannot be summed, so "
|
|
143
|
+
"cumulative analysis would be meaningless"
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
order = {ERROR: 0, WARNING: 1}
|
|
149
|
+
return sorted(findings, key=lambda f: (order[f.severity], f.rule, f.subject))
|