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
@@ -0,0 +1,154 @@
1
+ """TG08 -- Information-Flow Control (confidentiality-label propagation).
2
+
3
+ Ported from ``packages/toolgovern/src/classifier/information-flow.ts``.
4
+
5
+ A categorically different question from TG01-TG05: those all ask "should this call happen" (is
6
+ this argument dangerous, is this path/host/credential in scope). This asks "can this *data* flow
7
+ here" -- does a call read from a source the caller has labeled confidential-or-higher, and
8
+ write/send that data to a destination whose declared trust tier is lower (or not declared at
9
+ all)? That is the same governance question Microsoft Agent Framework's FIDES answers with a full
10
+ confidentiality-label lattice tracked across an MCP gateway boundary -- this module is
11
+ deliberately NOT that. It is the smallest real primitive that lets a genuine label-propagation
12
+ check exist at all: one flat, closed label order (``ConfidentialityLabel`` in ``../types.py``), a
13
+ caller-declared source/sink labeling (``IfcPolicy``), and a single per-call rule.
14
+
15
+ What this explicitly does NOT do, disclosed rather than hidden:
16
+
17
+ - No automatic label inference. toolgovern cannot know that ``args["table"] == "customers.ssn"``
18
+ is confidential, or that ``args["webhook"]`` points at an untrusted third party -- those are
19
+ business facts only the integrator has. ``IfcPolicy.sources`` / ``IfcPolicy.sink_trust`` are a
20
+ real, if minimal, labeling API the caller must declare; this rule only evaluates what was
21
+ declared.
22
+ - No cross-call / session-level taint tracking. Each call is evaluated in isolation, exactly like
23
+ every other TG0x rule -- if confidential data is read in one call and only handed to an
24
+ untrusted sink two calls later, this rule does not see that.
25
+ - No reader/principal-scoped lattice. ``ConfidentialityLabel`` is one flat total order, not a set
26
+ of readers or a join/meet lattice over multiple simultaneous principals.
27
+ - No result-value inspection. This evaluates the call's declared source/sink arguments, not the
28
+ actual data a tool call returns.
29
+
30
+ Fail-closed posture (the one property this module does commit to): a call whose source is
31
+ labeled confidential-or-higher and whose destination's trust tier is NOT declared in
32
+ ``IfcPolicy.sink_trust`` never silently allows -- it requires human approval. Only a destination
33
+ explicitly declared trustworthy enough (rank >= the source's label) is allowed through
34
+ unchallenged; a destination explicitly declared LOWER-trust is denied outright.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from dataclasses import dataclass
40
+ from typing import Any, Callable, List, Mapping, Optional, Sequence
41
+
42
+ from ..types import ConfidentialityLabel, RuleContext, RuleMatch
43
+
44
+ _CATEGORY = "TG08"
45
+
46
+ # Total order for ConfidentialityLabel, least to most sensitive.
47
+ _LABEL_ORDER: Sequence[str] = ("public", "internal", "confidential", "restricted")
48
+
49
+ # Argument key names a caller may use to name the source/sink of a labeled data flow.
50
+ # Deliberately separate, explicit key sets from TG02/TG04's path/credential-style keys and TG03's
51
+ # host/url-style keys -- reusing those would mean silently *inferring* that any path/host/
52
+ # credential argument is "the source" or "the sink" of a two-sided flow, which is exactly the
53
+ # automatic inference this module disclaims. A caller opts into TG08 by naming the source and
54
+ # sink explicitly under one of these keys.
55
+ _SOURCE_KEYS = ["source", "sourceId", "from", "readFrom"]
56
+ _SINK_KEYS = ["sink", "sinkId", "to", "destination", "sendTo", "forwardTo"]
57
+
58
+
59
+ @dataclass
60
+ class _Rule:
61
+ id: str
62
+ category: str
63
+ description: str
64
+ _evaluate: Callable[[RuleContext], Optional[RuleMatch]]
65
+
66
+ def evaluate(self, ctx: RuleContext) -> Optional[RuleMatch]:
67
+ return self._evaluate(ctx)
68
+
69
+
70
+ def _label_rank(label: str) -> int:
71
+ return _LABEL_ORDER.index(label)
72
+
73
+
74
+ def _first_string(args: Mapping[str, Any], keys: Sequence[str]) -> Optional[str]:
75
+ for key in keys:
76
+ value = args.get(key)
77
+ if isinstance(value, str) and len(value) > 0:
78
+ return value
79
+ return None
80
+
81
+
82
+ def _lookup_label(identifier: str, labels: Mapping[str, str]) -> Optional[str]:
83
+ """Whether ``identifier`` matches an entry in a declared IfcPolicy label map -- exact match,
84
+ a trailing path-segment match, or a substring match. Mirrors ``is_credential_granted()``'s
85
+ matching style in ``util.py``. Exact match is checked across the whole map first so a
86
+ longer, more specific declared key is never shadowed by an unrelated shorter key that
87
+ merely happens to be a substring of it."""
88
+ lower = identifier.lower()
89
+ for key, label in labels.items():
90
+ if key.lower() == lower:
91
+ return label
92
+ for key, label in labels.items():
93
+ k = key.lower()
94
+ if k and (lower.endswith(f"/{k}") or k in lower):
95
+ return label
96
+ return None
97
+
98
+
99
+ def _match(rule_id: str, decision: str, reason: str, matched_argument: str) -> RuleMatch:
100
+ return RuleMatch(
101
+ rule_id=rule_id,
102
+ category=_CATEGORY, # type: ignore[arg-type]
103
+ decision=decision, # type: ignore[arg-type]
104
+ reason=reason,
105
+ matched_argument=matched_argument,
106
+ )
107
+
108
+
109
+ def _confidential_source_to_untrusted_sink_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
110
+ ifc = ctx.scope.ifc
111
+ if ifc is None:
112
+ return None
113
+
114
+ source = _first_string(ctx.args, _SOURCE_KEYS)
115
+ if not source:
116
+ return None
117
+ source_label = _lookup_label(source, ifc.sources)
118
+ if not source_label or source_label == "public":
119
+ return None
120
+
121
+ sink = _first_string(ctx.args, _SINK_KEYS)
122
+ if not sink:
123
+ return None
124
+
125
+ sink_trust = _lookup_label(sink, ifc.sink_trust)
126
+ if sink_trust is None:
127
+ return _match(
128
+ "TG08-confidential-source-to-untrusted-sink",
129
+ "require-approval",
130
+ f'Call reads from "{source}" labeled "{source_label}" and sends to "{sink}", whose '
131
+ "trust tier is not declared in the IFC policy. Failing closed pending human review.",
132
+ sink,
133
+ )
134
+ if _label_rank(sink_trust) < _label_rank(source_label):
135
+ return _match(
136
+ "TG08-confidential-source-to-untrusted-sink",
137
+ "deny",
138
+ f'Call reads from "{source}" labeled "{source_label}" but destination "{sink}" is '
139
+ f'only trusted for "{sink_trust}" data.',
140
+ sink,
141
+ )
142
+ return None
143
+
144
+
145
+ information_flow_rules: List[_Rule] = [
146
+ _Rule(
147
+ "TG08-confidential-source-to-untrusted-sink",
148
+ _CATEGORY,
149
+ "A call reads from a source labeled confidential-or-higher and writes/sends to a "
150
+ "destination whose declared trust tier is lower than the source's label, or whose "
151
+ "trust tier was never declared at all (fails closed to require-approval, not allow).",
152
+ _confidential_source_to_untrusted_sink_evaluate,
153
+ ),
154
+ ]
@@ -0,0 +1,289 @@
1
+ """TG03 -- Undeclared Network Egress.
2
+
3
+ Ported from ``packages/toolgovern/src/classifier/network-egress.ts``.
4
+
5
+ Fires when a call reaches a host not present in the caller's declared network scope
6
+ (``scope.network``: ``False`` for no access, ``True`` for unrestricted, or an explicit host
7
+ allowlist).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import socket
14
+ import threading
15
+ from dataclasses import dataclass
16
+ from typing import Any, Callable, Dict, List, Optional
17
+
18
+ from ..shared.paths import host_matches_allowed, is_ip_literal, is_private_or_metadata_target
19
+ from ..types import RuleContext, RuleMatch
20
+ from .util import extract_candidate_host, extract_command, extract_host
21
+
22
+ _CATEGORY = "TG03"
23
+
24
+ _KNOWN_RELAY_DOMAINS = [
25
+ "pastebin.com",
26
+ "pastebin-mirror.io",
27
+ "transfer.sh",
28
+ "ngrok.io",
29
+ "ngrok-free.app",
30
+ "requestbin.com",
31
+ "webhook.site",
32
+ "file.io",
33
+ ]
34
+
35
+
36
+ @dataclass
37
+ class _Rule:
38
+ id: str
39
+ category: str
40
+ description: str
41
+ _evaluate: Callable[[RuleContext], Optional[RuleMatch]]
42
+
43
+ def evaluate(self, ctx: RuleContext) -> Optional[RuleMatch]:
44
+ return self._evaluate(ctx)
45
+
46
+
47
+ def _match(rule_id: str, decision: str, reason: str, matched_argument: str) -> RuleMatch:
48
+ return RuleMatch(
49
+ rule_id=rule_id,
50
+ category=_CATEGORY, # type: ignore[arg-type]
51
+ decision=decision, # type: ignore[arg-type]
52
+ reason=reason,
53
+ matched_argument=matched_argument,
54
+ )
55
+
56
+
57
+ def _is_host_in_scope(host: str, network) -> bool:
58
+ if network is True:
59
+ return True
60
+ if network is False:
61
+ return False
62
+ return any(host_matches_allowed(host, allowed) for allowed in network)
63
+
64
+
65
+ def _network_disabled_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
66
+ if ctx.scope.network is not False:
67
+ return None
68
+ host = extract_candidate_host(ctx.args)
69
+ if not host:
70
+ return None
71
+ return _match(
72
+ "TG03-network-disabled",
73
+ "deny",
74
+ f'Network call to "{host}" attempted with network scope disabled.',
75
+ host,
76
+ )
77
+
78
+
79
+ def _host_not_in_scope_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
80
+ if ctx.scope.network is False or ctx.scope.network is True:
81
+ return None
82
+ host = extract_candidate_host(ctx.args)
83
+ if not host:
84
+ return None
85
+ if _is_host_in_scope(host, ctx.scope.network):
86
+ return None
87
+ return _match(
88
+ "TG03-host-not-in-scope", "deny", f'Host "{host}" is not in the declared network allowlist.', host
89
+ )
90
+
91
+
92
+ def _raw_ip_literal_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
93
+ host = extract_candidate_host(ctx.args)
94
+ if not host or not is_ip_literal(host):
95
+ return None
96
+ # An explicit, exact allowlist entry for this host is honored even if it is private/
97
+ # metadata -- that's a deliberate, specific operator decision (they named this exact
98
+ # address), unlike the blanket scope.network is True grant checked below, which
99
+ # incidentally covers everything including metadata endpoints without the operator ever
100
+ # having considered this specific address.
101
+ if isinstance(ctx.scope.network, (list, tuple)) and host in ctx.scope.network:
102
+ return None
103
+ if is_private_or_metadata_target(host):
104
+ # Loopback, RFC1918/unique-local, link-local, and cloud-metadata targets are denied
105
+ # outright for any scope that did not explicitly name this exact host above -- this
106
+ # check must run before the scope.network is True early-return below, not after it:
107
+ # an agent with unrestricted (but not host-specific) network access must still never
108
+ # be able to reach an internal network or cloud-metadata endpoint via this rule.
109
+ # That's the entire point of "never approvable" -- it cannot be conditional on a
110
+ # blanket network=True grant.
111
+ return _match(
112
+ "TG03-raw-ip-literal",
113
+ "deny",
114
+ f'Connection to loopback/private/cloud-metadata IP literal "{host}" is never approvable.',
115
+ host,
116
+ )
117
+ if ctx.scope.network is True:
118
+ return None
119
+ return _match("TG03-raw-ip-literal", "require-approval", f'Connection to raw IP literal "{host}".', host)
120
+
121
+
122
+ _PORT_PATTERN = re.compile(r":(\d{2,5})\b")
123
+
124
+
125
+ def _non_standard_port_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
126
+ raw = extract_host(ctx.args) or extract_command(ctx.args) or ""
127
+ port_match = _PORT_PATTERN.search(raw)
128
+ if not port_match:
129
+ return None
130
+ port = int(port_match.group(1))
131
+ if port in (80, 443):
132
+ return None
133
+ host = extract_candidate_host(ctx.args)
134
+ if not host:
135
+ return None
136
+ if ctx.scope.network is True:
137
+ return None
138
+ if isinstance(ctx.scope.network, (list, tuple)) and host in ctx.scope.network:
139
+ return None
140
+ return _match(
141
+ "TG03-non-standard-port",
142
+ "require-approval",
143
+ f'Connection to "{host}" on non-standard port {port}.',
144
+ f"{host}:{port}",
145
+ )
146
+
147
+
148
+ def _dns_exfil_pattern_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
149
+ host = extract_candidate_host(ctx.args)
150
+ if not host:
151
+ return None
152
+ first_label = host.split(".")[0] if host else ""
153
+ if len(first_label) < 40:
154
+ return None
155
+ return _match(
156
+ "TG03-dns-exfil-pattern", "require-approval", f'Unusually long subdomain label on "{host}".', host
157
+ )
158
+
159
+
160
+ def _known_paste_relay_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
161
+ host = extract_candidate_host(ctx.args)
162
+ if not host:
163
+ return None
164
+ hit = next(
165
+ (domain for domain in _KNOWN_RELAY_DOMAINS if host == domain or host.endswith(f".{domain}")),
166
+ None,
167
+ )
168
+ if not hit:
169
+ return None
170
+ if isinstance(ctx.scope.network, (list, tuple)) and hit in ctx.scope.network:
171
+ return None
172
+ return _match(
173
+ "TG03-known-paste-relay", "deny", f'Host "{host}" matches known paste/relay service "{hit}".', host
174
+ )
175
+
176
+
177
+ _DNS_LOOKUP_TIMEOUT_SECONDS = 3.0
178
+
179
+
180
+ def _resolve_host_addresses(host: str) -> List[str]:
181
+ """Resolves every address a hostname maps to via the OS resolver (``socket.getaddrinfo``,
182
+ which also honors ``/etc/hosts`` -- so an operator-added ``127.0.0.1 internal-alias`` entry
183
+ is caught exactly like a real DNS A/AAAA record would be), racing it against a hard timeout
184
+ in a worker thread so a hung/unresponsive resolver cannot stall the call indefinitely.
185
+
186
+ ``socket.getaddrinfo()`` has no built-in timeout of its own and cannot be cancelled once
187
+ started, so this uses the same thread + ``join(timeout)`` idiom
188
+ ``middleware/on_tool_call.py``'s ``_resolve_approval`` already uses for approval timeouts,
189
+ rather than inventing a second timeout mechanism.
190
+
191
+ Raises on failure or timeout -- never returns a value implying "safe, nothing found" for
192
+ those cases. Callers must treat an exception as "unknown, fail closed."
193
+ """
194
+ result_box: Dict[str, Any] = {}
195
+
196
+ def _run() -> None:
197
+ try:
198
+ result_box["infos"] = socket.getaddrinfo(host, None)
199
+ except Exception as error: # noqa: BLE001 -- re-raised on the calling thread below
200
+ result_box["error"] = error
201
+
202
+ thread = threading.Thread(target=_run, daemon=True)
203
+ thread.start()
204
+ thread.join(_DNS_LOOKUP_TIMEOUT_SECONDS)
205
+ if thread.is_alive():
206
+ raise TimeoutError(
207
+ f'DNS lookup for "{host}" timed out after {_DNS_LOOKUP_TIMEOUT_SECONDS}s'
208
+ )
209
+ if "error" in result_box:
210
+ raise result_box["error"] # type: ignore[misc]
211
+ infos = result_box.get("infos", [])
212
+ return [info[4][0] for info in infos]
213
+
214
+
215
+ def _dns_resolves_private_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
216
+ host = extract_candidate_host(ctx.args)
217
+ # A raw IP literal is already fully handled by TG03-raw-ip-literal; resolving it would be a
218
+ # no-op (or, for a bare decimal-encoded literal, actively wrong) -- this rule only concerns
219
+ # itself with actual hostnames.
220
+ if not host or is_ip_literal(host):
221
+ return None
222
+ # Mirrors TG03-raw-ip-literal's own carve-out: an explicit, exact allowlist entry for this
223
+ # hostname is a deliberate, specific operator decision, honored even if it turns out to
224
+ # resolve to a private address.
225
+ if isinstance(ctx.scope.network, (list, tuple)) and host in ctx.scope.network:
226
+ return None
227
+
228
+ try:
229
+ addresses = _resolve_host_addresses(host)
230
+ except Exception as error:
231
+ # Fail CLOSED on a DNS-resolution failure (NXDOMAIN, timeout, resolver error, whatever the
232
+ # cause) -- an unresolvable host is never treated as "safe to allow." require-approval
233
+ # (not an automatic allow) matches TG03-raw-ip-literal's own use of require-approval for
234
+ # anything not affirmatively known to be safe.
235
+ return _match(
236
+ "TG03-dns-resolves-private",
237
+ "require-approval",
238
+ f'DNS resolution for host "{host}" failed ({error}); failing closed rather than '
239
+ "assuming an unresolvable host is safe to reach.",
240
+ host,
241
+ )
242
+
243
+ if not addresses:
244
+ # Some resolvers return an empty record set rather than raising for an unknown name --
245
+ # treated identically to a resolution failure above, for the same fail-closed reason.
246
+ return _match(
247
+ "TG03-dns-resolves-private",
248
+ "require-approval",
249
+ f'DNS resolution for host "{host}" returned no addresses; failing closed rather than '
250
+ "assuming an unresolvable host is safe to reach.",
251
+ host,
252
+ )
253
+
254
+ private_address = next((a for a in addresses if is_private_or_metadata_target(a)), None)
255
+ if not private_address:
256
+ return None
257
+
258
+ return _match(
259
+ "TG03-dns-resolves-private",
260
+ "deny",
261
+ f'Host "{host}" resolves via DNS to loopback/private/cloud-metadata address '
262
+ f'"{private_address}" -- denied even though the call argument is a hostname, not a raw '
263
+ "IP literal. Residual limitation, disclosed rather than hidden: this is a "
264
+ "resolve-then-check at classification time, not a connection-time guarantee -- it "
265
+ "narrows but does not eliminate DNS-rebinding TOCTOU, since an attacker who controls "
266
+ "this name's DNS answer can still swap it to a private/internal address after this "
267
+ "check runs and before the tool's own HTTP client actually connects. True TOCTOU-proof "
268
+ "protection would require the tool's own HTTP client to connect to this exact "
269
+ "resolved+validated address (DNS pinning), which a pre-execution argument gate like "
270
+ "govern_tool() cannot enforce -- see docs/security-model.md.",
271
+ host,
272
+ )
273
+
274
+
275
+ network_egress_rules: List[_Rule] = [
276
+ _Rule("TG03-network-disabled", _CATEGORY, "Any network egress attempted while the agent has no network scope at all.", _network_disabled_evaluate),
277
+ _Rule("TG03-host-not-in-scope", _CATEGORY, "The target host is not present in the declared network allowlist.", _host_not_in_scope_evaluate),
278
+ _Rule("TG03-raw-ip-literal", _CATEGORY, "Connection to a raw IP literal, bypassing a domain-based allowlist.", _raw_ip_literal_evaluate),
279
+ _Rule("TG03-non-standard-port", _CATEGORY, "Connection to a non-standard port on a host outside the allowlist.", _non_standard_port_evaluate),
280
+ _Rule("TG03-dns-exfil-pattern", _CATEGORY, "Suspiciously long, high-entropy subdomain label -- a common DNS-exfil shape.", _dns_exfil_pattern_evaluate),
281
+ _Rule("TG03-known-paste-relay", _CATEGORY, "Target host matches a known paste/relay/tunnel service commonly used for exfil.", _known_paste_relay_evaluate),
282
+ _Rule(
283
+ "TG03-dns-resolves-private",
284
+ _CATEGORY,
285
+ "A hostname argument that resolves via DNS to a loopback/RFC1918/link-local/cloud-metadata "
286
+ "address, even though the argument itself is a domain name, not a raw IP literal.",
287
+ _dns_resolves_private_evaluate,
288
+ ),
289
+ ]