toolgovern-cli 0.1.1__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.
Files changed (36) hide show
  1. toolgovern/__init__.py +222 -0
  2. toolgovern/approval/__init__.py +25 -0
  3. toolgovern/approval/pending_registry.py +379 -0
  4. toolgovern/classifier/__init__.py +19 -0
  5. toolgovern/classifier/credential_access.py +180 -0
  6. toolgovern/classifier/cross_agent_inheritance.py +216 -0
  7. toolgovern/classifier/filesystem_scope.py +202 -0
  8. toolgovern/classifier/index.py +80 -0
  9. toolgovern/classifier/information_flow.py +154 -0
  10. toolgovern/classifier/network_egress.py +289 -0
  11. toolgovern/classifier/shell_risk.py +357 -0
  12. toolgovern/classifier/util.py +259 -0
  13. toolgovern/cli.py +298 -0
  14. toolgovern/mcp_trust/__init__.py +342 -0
  15. toolgovern/middleware/__init__.py +30 -0
  16. toolgovern/middleware/idempotency_cache.py +117 -0
  17. toolgovern/middleware/on_tool_call.py +538 -0
  18. toolgovern/policy/__init__.py +10 -0
  19. toolgovern/policy/load_policy.py +41 -0
  20. toolgovern/policy/validate_policy.py +106 -0
  21. toolgovern/py.typed +0 -0
  22. toolgovern/scoping/__init__.py +13 -0
  23. toolgovern/scoping/inheritance_enforcer.py +145 -0
  24. toolgovern/scoping/scope_declaration.py +84 -0
  25. toolgovern/shared/__init__.py +0 -0
  26. toolgovern/shared/paths.py +266 -0
  27. toolgovern/trace/__init__.py +33 -0
  28. toolgovern/trace/canonical_json.py +27 -0
  29. toolgovern/trace/trace_reader.py +206 -0
  30. toolgovern/trace/trace_writer.py +247 -0
  31. toolgovern/types.py +247 -0
  32. toolgovern_cli-0.1.1.dist-info/METADATA +337 -0
  33. toolgovern_cli-0.1.1.dist-info/RECORD +36 -0
  34. toolgovern_cli-0.1.1.dist-info/WHEEL +4 -0
  35. toolgovern_cli-0.1.1.dist-info/entry_points.txt +2 -0
  36. toolgovern_cli-0.1.1.dist-info/licenses/LICENSE +202 -0
toolgovern/cli.py ADDED
@@ -0,0 +1,298 @@
1
+ """toolgovern-cli -- validate policy files and audit local gate traces, without needing the
2
+ hosted dashboard.
3
+
4
+ Ported from ``packages/toolgovern-cli/src/cli.ts`` (which uses hand-rolled ``process.argv``
5
+ parsing); this port uses the same hand-rolled flag parser translated to Python so the flag
6
+ shapes, defaults, and output text stay identical to the npm CLI's behavior.
7
+
8
+ toolgovern-cli validate ./toolgovern.policy.yml
9
+ toolgovern-cli audit ./toolgovern-trace.jsonl --since 24h --decision deny
10
+ toolgovern-cli init langgraph
11
+
12
+ Every command function below returns a ``CliResult`` (exit code + stdout/stderr text) instead of
13
+ writing to stdout/stderr directly, so the command logic is testable in isolation -- ``main()`` is
14
+ the only place that touches the real process streams.
15
+
16
+ Note on scope versus the npm CLI: ``init`` scaffolds a *TypeScript* integration file wiring
17
+ ``governTool()``/``governedLangGraphTools()`` into a Node project -- that scaffold is
18
+ JS/TS-specific by nature (it imports ``toolgovern-integration-langgraph``, a JS package) and is
19
+ not reproduced here. The Python port's ``validate`` and ``audit`` commands are otherwise
20
+ byte-for-byte equivalent in behavior, flags, and output shape (including ``--json``) to the npm
21
+ CLI.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import sys
28
+ from dataclasses import dataclass, field
29
+ from typing import Any, Dict, List, Optional, Sequence, Union
30
+
31
+ import yaml
32
+
33
+ from .policy.validate_policy import validate_policy
34
+ from .trace.trace_reader import TraceQuery, filter_trace, read_trace, verify_chain
35
+ from .trace.trace_reader import VerifyChainOptions
36
+
37
+ _VALID_DECISIONS = {"allow", "deny", "require-approval"}
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class CliResult:
42
+ code: int
43
+ stdout: str
44
+ stderr: str
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ParsedFlags:
49
+ positional: Sequence[str]
50
+ flags: Dict[str, Union[str, bool]]
51
+
52
+
53
+ def _is_json_flag(flags: Dict[str, Any]) -> bool:
54
+ return flags.get("json") is True or flags.get("json") == "true"
55
+
56
+
57
+ def _json_result(code: int, envelope: Dict[str, Any]) -> CliResult:
58
+ return CliResult(code=code, stdout=json.dumps(envelope, indent=2) + "\n", stderr="")
59
+
60
+
61
+ def parse_args(argv: Sequence[str]) -> ParsedFlags:
62
+ positional: List[str] = []
63
+ flags: Dict[str, Union[str, bool]] = {}
64
+ i = 0
65
+ while i < len(argv):
66
+ arg = argv[i]
67
+ if arg.startswith("--"):
68
+ key = arg[2:]
69
+ nxt = argv[i + 1] if i + 1 < len(argv) else None
70
+ if nxt is not None and not nxt.startswith("--"):
71
+ flags[key] = nxt
72
+ i += 1
73
+ else:
74
+ flags[key] = True
75
+ else:
76
+ positional.append(arg)
77
+ i += 1
78
+ return ParsedFlags(positional=positional, flags=flags)
79
+
80
+
81
+ USAGE = "\n".join(
82
+ [
83
+ "Usage:",
84
+ " toolgovern-cli validate <policy-file> [--json]",
85
+ " toolgovern-cli audit <trace-file> [--since <window>] [--decision <allow|deny|require-approval>] [--agent <id>] [--rule <ruleId>] [--verify-chain] [--key-file <path>] [--json]",
86
+ "",
87
+ " --json Emit a single structured JSON object on stdout instead of human-formatted text --",
88
+ " { ok, command, data } on success, { ok: false, command, error } on failure. Exit",
89
+ " code still reflects success/failure; nothing is ever split across stdout/stderr",
90
+ " in --json mode. Meant for another program (an agent, a script) to parse reliably.",
91
+ "",
92
+ " --key-file Path to the secret key file used to verify hmac-sha256-signed trace entries.",
93
+ " Only needed if the trace was written with a TraceWriter secret_key. Entries",
94
+ " signed with the default unkeyed sha256 scheme verify without it.",
95
+ "",
96
+ ]
97
+ )
98
+
99
+
100
+ def validate_command(policy_file: Optional[str], flags: Optional[Dict[str, Any]] = None) -> CliResult:
101
+ flags = flags or {}
102
+ json_mode = _is_json_flag(flags)
103
+
104
+ if not policy_file:
105
+ message = "validate requires a <policy-file> argument."
106
+ if json_mode:
107
+ return _json_result(2, {"ok": False, "command": "validate", "error": {"message": message}})
108
+ return CliResult(code=2, stdout="", stderr=f"{message}\n{USAGE}")
109
+
110
+ try:
111
+ with open(policy_file, "r", encoding="utf-8") as f:
112
+ raw = yaml.safe_load(f.read())
113
+ except Exception as error:
114
+ message = f'Failed to read/parse "{policy_file}": {error}'
115
+ if json_mode:
116
+ return _json_result(1, {"ok": False, "command": "validate", "error": {"message": message}})
117
+ return CliResult(code=1, stdout="", stderr=f"{message}\n")
118
+
119
+ result = validate_policy(raw)
120
+ if result.valid:
121
+ if json_mode:
122
+ return _json_result(
123
+ 0,
124
+ {
125
+ "ok": True,
126
+ "command": "validate",
127
+ "data": {"file": policy_file, "valid": True, "errors": []},
128
+ },
129
+ )
130
+ return CliResult(code=0, stdout=f"OK {policy_file} is a valid toolgovern policy.\n", stderr="")
131
+
132
+ if json_mode:
133
+ return _json_result(
134
+ 1,
135
+ {
136
+ "ok": False,
137
+ "command": "validate",
138
+ "data": {"file": policy_file, "valid": False, "errors": list(result.errors)},
139
+ "error": {
140
+ "message": f'"{policy_file}" is not a valid toolgovern policy.',
141
+ "details": list(result.errors),
142
+ },
143
+ },
144
+ )
145
+ stderr = "\n".join([f"INVALID {policy_file}", *[f" - {e}" for e in result.errors], ""])
146
+ return CliResult(code=1, stdout="", stderr=stderr)
147
+
148
+
149
+ def audit_command(trace_file: Optional[str], flags: Dict[str, Any]) -> CliResult:
150
+ json_mode = _is_json_flag(flags)
151
+
152
+ if not trace_file:
153
+ message = "audit requires a <trace-file> argument."
154
+ if json_mode:
155
+ return _json_result(2, {"ok": False, "command": "audit", "error": {"message": message}})
156
+ return CliResult(code=2, stdout="", stderr=f"{message}\n{USAGE}")
157
+
158
+ try:
159
+ entries = read_trace(trace_file)
160
+ except Exception as error:
161
+ message = f'Failed to read trace file "{trace_file}": {error}'
162
+ if json_mode:
163
+ return _json_result(1, {"ok": False, "command": "audit", "error": {"message": message}})
164
+ return CliResult(code=1, stdout="", stderr=f"{message}\n")
165
+
166
+ stdout = ""
167
+ chain: Optional[Dict[str, Any]] = None
168
+
169
+ if flags.get("verify-chain"):
170
+ secret_key: Optional[bytes] = None
171
+ key_file = flags.get("key-file")
172
+ if isinstance(key_file, str):
173
+ try:
174
+ with open(key_file, "rb") as f:
175
+ secret_key = f.read()
176
+ except Exception as error:
177
+ message = f'Failed to read --key-file "{key_file}": {error}'
178
+ if json_mode:
179
+ return _json_result(1, {"ok": False, "command": "audit", "error": {"message": message}})
180
+ return CliResult(code=1, stdout="", stderr=f"{message}\n")
181
+ verification = verify_chain(entries, VerifyChainOptions(secret_key=secret_key))
182
+ if not verification.valid:
183
+ if json_mode:
184
+ return _json_result(
185
+ 1,
186
+ {
187
+ "ok": False,
188
+ "command": "audit",
189
+ "data": {
190
+ "file": trace_file,
191
+ "chain": {"verified": False, "entries": len(entries)},
192
+ "issues": [
193
+ {"traceId": i.trace_id, "reason": i.reason} for i in verification.issues
194
+ ],
195
+ },
196
+ "error": {
197
+ "message": f'Chain verification failed for "{trace_file}".',
198
+ "details": [f"{i.trace_id}: {i.reason}" for i in verification.issues],
199
+ },
200
+ },
201
+ )
202
+ stderr = "\n".join(
203
+ [
204
+ f"CHAIN INVALID {trace_file}",
205
+ *[f" - {i.trace_id}: {i.reason}" for i in verification.issues],
206
+ "",
207
+ ]
208
+ )
209
+ return CliResult(code=1, stdout="", stderr=stderr)
210
+ chain = {"verified": True, "entries": len(entries)}
211
+ stdout += f"Chain OK -- {len(entries)} entries verified.\n"
212
+
213
+ decision_flag = flags.get("decision") if isinstance(flags.get("decision"), str) else None
214
+ if decision_flag and decision_flag not in _VALID_DECISIONS:
215
+ message = f'--decision must be one of: allow, deny, require-approval (got "{decision_flag}")'
216
+ if json_mode:
217
+ return _json_result(2, {"ok": False, "command": "audit", "error": {"message": message}})
218
+ return CliResult(code=2, stdout="", stderr=f"{message}\n")
219
+
220
+ query = TraceQuery(
221
+ since=flags.get("since") if isinstance(flags.get("since"), str) else None,
222
+ decision=decision_flag, # type: ignore[arg-type]
223
+ agent_id=flags.get("agent") if isinstance(flags.get("agent"), str) else None,
224
+ rule_id=flags.get("rule") if isinstance(flags.get("rule"), str) else None,
225
+ )
226
+
227
+ try:
228
+ filtered = filter_trace(entries, query)
229
+ except Exception as error:
230
+ message = str(error)
231
+ if json_mode:
232
+ return _json_result(2, {"ok": False, "command": "audit", "error": {"message": message}})
233
+ return CliResult(code=2, stdout="", stderr=f"{message}\n")
234
+
235
+ if json_mode:
236
+ return _json_result(
237
+ 0,
238
+ {
239
+ "ok": True,
240
+ "command": "audit",
241
+ "data": {
242
+ "file": trace_file,
243
+ "chain": chain,
244
+ "query": {
245
+ "since": query.since,
246
+ "decision": query.decision,
247
+ "agentId": query.agent_id,
248
+ "ruleId": query.rule_id,
249
+ },
250
+ "matched": len(filtered),
251
+ "total": len(entries),
252
+ "entries": [e.to_dict() for e in filtered],
253
+ },
254
+ },
255
+ )
256
+
257
+ for entry in filtered:
258
+ rules = ", ".join(entry.rule_fired) if entry.rule_fired else "(no rule fired)"
259
+ stdout += (
260
+ f"{entry.decision.upper():<16} {entry.agent_id} -> {entry.tool} [{rules}] {entry.timestamp}\n"
261
+ )
262
+ stdout += f"\n{len(filtered)} of {len(entries)} trace entries matched.\n"
263
+
264
+ return CliResult(code=0, stdout=stdout, stderr="")
265
+
266
+
267
+ def run_command(argv: Sequence[str]) -> CliResult:
268
+ command = argv[0] if argv else None
269
+ rest = argv[1:] if argv else []
270
+ parsed = parse_args(rest)
271
+ positional, flags = parsed.positional, parsed.flags
272
+
273
+ if not command or command in ("--help", "-h"):
274
+ return CliResult(code=0 if command else 2, stdout=USAGE if command else "", stderr="" if command else USAGE)
275
+
276
+ if command == "validate":
277
+ return validate_command(positional[0] if positional else None, flags)
278
+
279
+ if command == "audit":
280
+ return audit_command(positional[0] if positional else None, flags)
281
+
282
+ message = f'Unknown command "{command}".'
283
+ if _is_json_flag(flags):
284
+ return _json_result(2, {"ok": False, "command": command, "error": {"message": message}})
285
+ return CliResult(code=2, stdout="", stderr=f"{message}\n{USAGE}")
286
+
287
+
288
+ def main() -> None:
289
+ result = run_command(sys.argv[1:])
290
+ if result.stdout:
291
+ sys.stdout.write(result.stdout)
292
+ if result.stderr:
293
+ sys.stderr.write(result.stderr)
294
+ sys.exit(result.code)
295
+
296
+
297
+ if __name__ == "__main__":
298
+ main()
@@ -0,0 +1,342 @@
1
+ """MCP-server trust boundary: connection-time governance of the MCP *servers* an agent connects
2
+ to, as distinct from TG01-TG05's per-call classification of what a tool call does once a server
3
+ is already connected and its tools are already being invoked.
4
+
5
+ Ported from ``packages/toolgovern/src/mcp-trust/index.ts``. See that module's docstring for the
6
+ full rationale (the CrewAI CVE-2026-2275/2287 chain and the Postmark MCP rug-pull); this port
7
+ mirrors its two primitives and its fail-closed behavior exactly, with one deliberate,
8
+ disclosed asymmetry: this port fetches a manifest URL synchronously via ``urllib.request``
9
+ (``govern_tool()`` is synchronous end-to-end in this port, same rationale as TG03's
10
+ ``socket.getaddrinfo()`` use), where the TS original uses ``fetch()``. Same checks, same
11
+ fail-closed outcomes, different (language-appropriate) I/O plumbing.
12
+
13
+ Two primitives, and a deliberate fail-closed posture on both:
14
+
15
+ 1. ``is_origin_allowed()`` -- an explicit origin allowlist, checked once per connection, not once
16
+ per call. No implicit subdomain trust: an allowlist entry matches only that exact origin
17
+ unless the operator explicitly opts into subdomain matching with a leading ``*.`` entry.
18
+ 2. ``verify_mcp_server_manifest()`` -- signature verification of a fetched MCP server manifest
19
+ against a pinned public-key list before any tool the manifest declares is ever trusted.
20
+ Supports Ed25519 and RSA-SHA256 (PKCS#1 v1.5) detached signatures over the manifest's exact
21
+ bytes, via the ``cryptography`` package. There is no code path in this module that returns
22
+ ``"allow"`` without a signature that actually verified against a pinned key -- an unreachable
23
+ manifest, an unknown key ID, a signature that fails to verify, and an unconfigured pinned-key
24
+ list all deny, they do not warn.
25
+
26
+ What this explicitly does NOT do, disclosed rather than hidden:
27
+
28
+ - No sigstore/keyless verification -- the pinned-key path is the one this module actually
29
+ implements and tests.
30
+ - No revocation checking for a pinned key that has been compromised or retired.
31
+ - No re-verification of a live connection after the manifest check passes -- this is a
32
+ connection-time gate, run once before a server's tools are trusted.
33
+ - ``is_origin_allowed()``'s allowlist match is a plain string/hostname comparison, not a TLS
34
+ certificate or transport-identity check.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import base64
40
+ import json
41
+ import urllib.error
42
+ import urllib.request
43
+ from dataclasses import dataclass
44
+ from typing import Callable, Literal, Optional, Sequence, Union
45
+
46
+ from cryptography.exceptions import InvalidSignature
47
+ from cryptography.hazmat.primitives import hashes, serialization
48
+ from cryptography.hazmat.primitives.asymmetric import padding
49
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
50
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
51
+
52
+ from ..shared.paths import normalize_host
53
+
54
+ # MCP-server trust decisions are binary and fail-closed -- there is no "require-approval" third
55
+ # state here, unlike the per-call Decision type in types.py. A connection either passed every
56
+ # connection-time check, or it did not.
57
+ McpTrustDecision = Literal["allow", "deny"]
58
+
59
+ Algorithm = Literal["ed25519", "rsa-sha256"]
60
+
61
+ #: Injectable fetch function: given a manifest URL and a timeout (seconds), returns the raw
62
+ #: response bytes, or raises on any failure (connection error, non-2xx status, timeout). Real
63
+ #: callers get ``_default_fetch`` (``urllib.request``); tests inject a stand-in.
64
+ FetchImpl = Callable[[str, float], bytes]
65
+
66
+ DEFAULT_MANIFEST_FETCH_TIMEOUT_SECONDS = 5.0
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class PinnedPublicKey:
71
+ """A pinned public key an MCP server manifest's detached signature is checked against.
72
+ ``algorithm`` fixes what verification scheme applies to ``public_key_pem`` -- there is no
73
+ algorithm-sniffing from the key material itself."""
74
+
75
+ key_id: str
76
+ algorithm: Algorithm
77
+ public_key_pem: str
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class McpManifestEnvelope:
82
+ """The exact, already-fetched (or directly-supplied) manifest signature envelope
83
+ ``verify_mcp_server_manifest()`` verifies. ``manifest_bytes`` must be the literal bytes the
84
+ signer signed -- this module never re-serializes a parsed manifest object before verifying."""
85
+
86
+ manifest_bytes: str
87
+ signature_b64: str
88
+ key_id: str
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class McpTrustVerdict:
93
+ """The outcome of an MCP-server trust check. ``decision`` is never a silent "allow" produced
94
+ by an unhandled edge case -- every deny carries a human-readable ``reason``."""
95
+
96
+ decision: McpTrustDecision
97
+ reason: str
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class McpServerConnectionRequest:
102
+ """One MCP-server connection attempt: the origin the agent is about to connect to, and the
103
+ URL (or an already-fetched envelope) of that server's manifest."""
104
+
105
+ origin: str
106
+ manifest: Union[str, McpManifestEnvelope]
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class McpTrustPolicy:
111
+ """The full connection-time trust policy: an origin allowlist plus a pinned-key list for
112
+ manifest-signature verification. Passed to ``assert_mcp_server_trusted()``."""
113
+
114
+ allowed_origins: Sequence[str]
115
+ pinned_keys: Sequence[PinnedPublicKey]
116
+ fetch_impl: Optional[FetchImpl] = None
117
+ timeout_seconds: float = DEFAULT_MANIFEST_FETCH_TIMEOUT_SECONDS
118
+
119
+
120
+ def is_origin_allowed(origin: str, allowlist: Sequence[str]) -> bool:
121
+ """Checked once at MCP-server CONNECTION time, never per-call -- an explicit allowlist of
122
+ the origins an agent is permitted to connect to at all.
123
+
124
+ Default posture is exact match, not subdomain trust: an allowlist entry of ``"example.com"``
125
+ matches ``"example.com"`` only, not ``"evil.example.com"``. An operator who genuinely wants
126
+ subdomain matching opts in explicitly with a leading ``*.`` entry (``"*.example.com"``
127
+ matches ``"example.com"`` and any subdomain of it). This deliberately diverges from TG03's
128
+ ``host_matches_allowed()`` (which matches subdomains unconditionally) -- a connection-time
129
+ server allowlist is a narrower, higher-stakes trust decision than a per-call network-egress
130
+ check.
131
+
132
+ ``origin`` and each allowlist entry may be a full origin (``"https://mcp.example.com"``) or a
133
+ bare hostname (``"mcp.example.com"``) -- both are normalized to a hostname before comparison.
134
+ Returns ``False`` (never raises) for an empty/unparseable origin or an empty allowlist -- an
135
+ empty allowlist is "nothing is allowed," not "anything is allowed."
136
+ """
137
+ if not origin or not allowlist:
138
+ return False
139
+ host = normalize_host(origin)
140
+ if not host:
141
+ return False
142
+
143
+ def _matches(raw_entry: str) -> bool:
144
+ entry = raw_entry.strip()
145
+ if not entry:
146
+ return False
147
+ if entry.startswith("*."):
148
+ suffix = entry[2:].lower()
149
+ if not suffix:
150
+ return False
151
+ return host == suffix or host.endswith(f".{suffix}")
152
+ entry_host = normalize_host(entry)
153
+ return bool(entry_host) and host == entry_host
154
+
155
+ return any(_matches(entry) for entry in allowlist)
156
+
157
+
158
+ def _verify_signature_bytes(
159
+ algorithm: Algorithm, public_key_pem: str, data: bytes, signature: bytes
160
+ ) -> bool:
161
+ """Verifies a detached ``signature`` over ``data`` using ``public_key_pem``, per
162
+ ``algorithm``. Raises if ``public_key_pem`` is not parseable, or does not match the declared
163
+ algorithm's expected key type -- an actual signature mismatch returns ``False``, it does not
164
+ raise."""
165
+ public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
166
+ try:
167
+ if algorithm == "ed25519":
168
+ if not isinstance(public_key, Ed25519PublicKey):
169
+ raise TypeError(
170
+ f"Pinned key is not an Ed25519 public key (got {type(public_key).__name__})"
171
+ )
172
+ public_key.verify(signature, data)
173
+ else:
174
+ if not isinstance(public_key, RSAPublicKey):
175
+ raise TypeError(
176
+ f"Pinned key is not an RSA public key (got {type(public_key).__name__})"
177
+ )
178
+ public_key.verify(signature, data, padding.PKCS1v15(), hashes.SHA256())
179
+ return True
180
+ except InvalidSignature:
181
+ return False
182
+
183
+
184
+ def _default_fetch(url: str, timeout_seconds: float) -> bytes:
185
+ """Default ``FetchImpl``: a plain synchronous GET via ``urllib.request``, with a hard
186
+ timeout so a hung/unresponsive manifest host cannot stall a connection attempt
187
+ indefinitely -- the same rationale ``network_egress.py``'s DNS-lookup timeout already
188
+ applies. Raises (``urllib.error.URLError``/``HTTPError``, ``TimeoutError``, or any other
189
+ exception) rather than returning a value implying success on failure."""
190
+ request = urllib.request.Request(url, method="GET")
191
+ with urllib.request.urlopen(request, timeout=timeout_seconds) as response: # noqa: S310
192
+ status = getattr(response, "status", None) or response.getcode()
193
+ if status is None or status < 200 or status >= 300:
194
+ raise RuntimeError(f"Manifest fetch returned HTTP {status}")
195
+ return response.read()
196
+
197
+
198
+ def _fetch_manifest_envelope(
199
+ url: str, fetch_impl: FetchImpl, timeout_seconds: float
200
+ ) -> McpManifestEnvelope:
201
+ raw = fetch_impl(url, timeout_seconds)
202
+ try:
203
+ body = json.loads(raw)
204
+ except (json.JSONDecodeError, TypeError, UnicodeDecodeError) as error:
205
+ raise ValueError(f"Manifest response is not valid JSON: {error}") from error
206
+
207
+ manifest = body.get("manifest") if isinstance(body, dict) else None
208
+ signature = body.get("signature") if isinstance(body, dict) else None
209
+ key_id = body.get("keyId") if isinstance(body, dict) else None
210
+ if not isinstance(manifest, str) or not isinstance(signature, str) or not isinstance(key_id, str):
211
+ raise ValueError(
212
+ "Manifest response is missing one or more required fields "
213
+ "(manifest, signature, keyId) or a field is not a string."
214
+ )
215
+ return McpManifestEnvelope(manifest_bytes=manifest, signature_b64=signature, key_id=key_id)
216
+
217
+
218
+ def verify_mcp_server_manifest(
219
+ manifest_url_or_envelope: Union[str, McpManifestEnvelope],
220
+ pinned_keys: Sequence[PinnedPublicKey],
221
+ fetch_impl: Optional[FetchImpl] = None,
222
+ timeout_seconds: float = DEFAULT_MANIFEST_FETCH_TIMEOUT_SECONDS,
223
+ ) -> McpTrustVerdict:
224
+ """Verifies an MCP server manifest's detached signature against a pinned public-key list
225
+ before any tool the manifest declares may be trusted. Accepts either a manifest URL (fetched
226
+ via ``fetch_impl`` or the default ``urllib``-based fetch, subject to ``timeout_seconds``) or
227
+ an already-fetched ``McpManifestEnvelope`` directly.
228
+
229
+ Fail-closed on every path, never a silent allow:
230
+
231
+ - No pinned keys configured -> ``deny``.
232
+ - Manifest URL unreachable, times out, or returns a malformed/non-2xx response -> ``deny``.
233
+ - Envelope's ``key_id`` does not match any pinned key -> ``deny``.
234
+ - Signature or manifest bytes are malformed (undecodable base64, etc.) -> ``deny``.
235
+ - Signature verification raises (a malformed or mismatched-type pinned public key) -> ``deny``.
236
+ - Signature does not verify against the matched pinned key -> ``deny``. This includes a
237
+ bit-flipped/tampered manifest with its original (now-mismatched) signature left in place --
238
+ see ``test_mcp_trust.py``'s tampered-manifest test for a direct proof, not just an
239
+ assertion.
240
+ - Only a signature that positively verifies against a pinned key returns ``allow``.
241
+ """
242
+ if not pinned_keys:
243
+ return McpTrustVerdict(
244
+ "deny",
245
+ "No pinned public keys configured -- refusing to trust any manifest signature "
246
+ "(fail closed).",
247
+ )
248
+
249
+ if isinstance(manifest_url_or_envelope, str):
250
+ fetch = fetch_impl or _default_fetch
251
+ try:
252
+ envelope = _fetch_manifest_envelope(
253
+ manifest_url_or_envelope, fetch, timeout_seconds
254
+ )
255
+ except Exception as error: # noqa: BLE001 -- any fetch/parse failure fails closed
256
+ return McpTrustVerdict(
257
+ "deny",
258
+ f'Manifest unreachable at "{manifest_url_or_envelope}": {error}. '
259
+ "Failing closed.",
260
+ )
261
+ else:
262
+ envelope = manifest_url_or_envelope
263
+
264
+ key = next((k for k in pinned_keys if k.key_id == envelope.key_id), None)
265
+ if key is None:
266
+ return McpTrustVerdict(
267
+ "deny",
268
+ f'Manifest signed with keyId "{envelope.key_id}", which is not in the pinned '
269
+ "key list. Failing closed.",
270
+ )
271
+
272
+ try:
273
+ signature_bytes = base64.b64decode(envelope.signature_b64, validate=True)
274
+ if not signature_bytes:
275
+ raise ValueError("decoded signature is empty")
276
+ data_bytes = envelope.manifest_bytes.encode("utf-8")
277
+ except Exception as error: # noqa: BLE001 -- any decoding failure fails closed
278
+ return McpTrustVerdict(
279
+ "deny", f"Malformed manifest signature encoding: {error}. Failing closed."
280
+ )
281
+
282
+ try:
283
+ verified = _verify_signature_bytes(
284
+ key.algorithm, key.public_key_pem, data_bytes, signature_bytes
285
+ )
286
+ except Exception as error: # noqa: BLE001 -- any verification failure fails closed
287
+ return McpTrustVerdict(
288
+ "deny",
289
+ f'Signature verification against pinned key "{key.key_id}" raised ({error}) -- '
290
+ "treating as unverified. Failing closed.",
291
+ )
292
+
293
+ if not verified:
294
+ return McpTrustVerdict(
295
+ "deny",
296
+ f'Manifest signature does not verify against pinned key "{key.key_id}". '
297
+ "Failing closed.",
298
+ )
299
+
300
+ return McpTrustVerdict(
301
+ "allow",
302
+ f'Manifest signature verified against pinned key "{key.key_id}" ({key.algorithm}).',
303
+ )
304
+
305
+
306
+ def assert_mcp_server_trusted(
307
+ request: McpServerConnectionRequest, policy: McpTrustPolicy
308
+ ) -> McpTrustVerdict:
309
+ """The single connection-time gate combining both primitives: an MCP server's tools are
310
+ trusted only if its origin is on the allowlist AND its manifest's signature verifies against
311
+ a pinned key. Either check failing alone is a hard deny -- there is no partial-trust state,
312
+ and origin is checked first so a manifest fetch (network I/O) is never attempted for an
313
+ origin that was never going to be trusted anyway.
314
+ """
315
+ if not is_origin_allowed(request.origin, policy.allowed_origins):
316
+ return McpTrustVerdict(
317
+ "deny",
318
+ f'Origin "{request.origin}" is not on the connection-time allowlist. '
319
+ "Failing closed.",
320
+ )
321
+ return verify_mcp_server_manifest(
322
+ request.manifest,
323
+ policy.pinned_keys,
324
+ fetch_impl=policy.fetch_impl,
325
+ timeout_seconds=policy.timeout_seconds,
326
+ )
327
+
328
+
329
+ __all__ = [
330
+ "McpTrustDecision",
331
+ "Algorithm",
332
+ "FetchImpl",
333
+ "DEFAULT_MANIFEST_FETCH_TIMEOUT_SECONDS",
334
+ "PinnedPublicKey",
335
+ "McpManifestEnvelope",
336
+ "McpTrustVerdict",
337
+ "McpServerConnectionRequest",
338
+ "McpTrustPolicy",
339
+ "is_origin_allowed",
340
+ "verify_mcp_server_manifest",
341
+ "assert_mcp_server_trusted",
342
+ ]
@@ -0,0 +1,30 @@
1
+ from .idempotency_cache import IdempotencyCache, IdempotencyOptions
2
+ from .on_tool_call import (
3
+ ApprovalHandler,
4
+ ApprovalOutcome,
5
+ GateDecisionInfo,
6
+ GovernToolOptions,
7
+ InvalidAgentIdError,
8
+ PendingApprovalNotResolvableError,
9
+ ResumePendingApprovalOptions,
10
+ ToolDefinition,
11
+ ToolGovernDenialError,
12
+ govern_tool,
13
+ resume_pending_approval,
14
+ )
15
+
16
+ __all__ = [
17
+ "IdempotencyCache",
18
+ "IdempotencyOptions",
19
+ "ApprovalHandler",
20
+ "ApprovalOutcome",
21
+ "GateDecisionInfo",
22
+ "GovernToolOptions",
23
+ "InvalidAgentIdError",
24
+ "PendingApprovalNotResolvableError",
25
+ "ResumePendingApprovalOptions",
26
+ "ToolDefinition",
27
+ "ToolGovernDenialError",
28
+ "govern_tool",
29
+ "resume_pending_approval",
30
+ ]