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