agentperm 0.2.0__tar.gz → 0.2.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentperm
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Permission policy mediator for Claude Code, Codex, OpenCode, and Gemini CLI.
5
5
  Project-URL: Homepage, https://github.com/jacks0n/agentperm
6
6
  Project-URL: Repository, https://github.com/jacks0n/agentperm
@@ -117,7 +117,7 @@ After `install`, each agent calls agentperm before running a tool. Your native s
117
117
  Rules go in `allow`, `ask`, or `deny`. Three forms:
118
118
 
119
119
  - **`"Bash(git status:*)"`** — match a shell command by prefix (`git status` and anything after it). Drop the `:*` for an exact match, or use glob tokens: `*` matches one argument, `**` matches zero or more (e.g. `Bash(pnpm --dir * build:*)`).
120
- - **`"Read"`, `"WebFetch(domain:github.com)"`** — match a named, non-shell tool, optionally scoped.
120
+ - **`"Read"`, `"WebFetch(domain:github.com)"`** — match a non-shell tool by name (`Read`, `mcp__memory__*`, `*`). An optional specifier scopes by the tool's input fields: `WebFetch(domain:github.com)` matches a URL field on that host or a subdomain; any other specifier is a path glob (`*` within a segment, `**` across) on the tool's path fields (`Read(/etc/**)`, `Edit(src/*)`). Bare name (or `(*)`) matches any input.
121
121
  - **Object form** — match on flags: `{ "tool": "Bash", "command": ["sed"], "when": { "hasOption": ["-i"] }, "reason": "..." }`.
122
122
 
123
123
  In a compound command the strictest segment decides — one unrecognized command turns the whole line into an `ask`, and **deny always wins**. Full grammar: [policy reference](docs/policy-reference.md).
@@ -163,6 +163,14 @@ So you allow a tool broadly once, and a single repo can both add its own command
163
163
 
164
164
  Create or edit the project file with `agentperm edit --local` (it writes to your git repo root). `import` and `install` always act on the global file and your agents' global hooks.
165
165
 
166
+ ## Examples
167
+
168
+ Ready-to-crib policies in [`examples/`](examples/):
169
+
170
+ - [`starter.agent-permissions.jsonc`](examples/starter.agent-permissions.jsonc) — a minimal global policy to begin from: read-only shell and tools allowed, `sed -i` asks, `sudo` / `rm -rf` denied.
171
+ - [`global.agent-permissions.jsonc`](examples/global.agent-permissions.jsonc) — a fuller real-world global policy: a large allow-list of read-only AWS and CLI commands, the `sed -i` ask rule, a deny list, and shell-redirection defaults.
172
+ - [`project.agent-permissions.jsonc`](examples/project.agent-permissions.jsonc) — this repo's own per-project file, allowing just its dev-tooling commands on top of whatever your global policy permits.
173
+
166
174
  ## Commands
167
175
 
168
176
  | Command | What it does |
@@ -58,7 +58,7 @@ After `install`, each agent calls agentperm before running a tool. Your native s
58
58
  Rules go in `allow`, `ask`, or `deny`. Three forms:
59
59
 
60
60
  - **`"Bash(git status:*)"`** — match a shell command by prefix (`git status` and anything after it). Drop the `:*` for an exact match, or use glob tokens: `*` matches one argument, `**` matches zero or more (e.g. `Bash(pnpm --dir * build:*)`).
61
- - **`"Read"`, `"WebFetch(domain:github.com)"`** — match a named, non-shell tool, optionally scoped.
61
+ - **`"Read"`, `"WebFetch(domain:github.com)"`** — match a non-shell tool by name (`Read`, `mcp__memory__*`, `*`). An optional specifier scopes by the tool's input fields: `WebFetch(domain:github.com)` matches a URL field on that host or a subdomain; any other specifier is a path glob (`*` within a segment, `**` across) on the tool's path fields (`Read(/etc/**)`, `Edit(src/*)`). Bare name (or `(*)`) matches any input.
62
62
  - **Object form** — match on flags: `{ "tool": "Bash", "command": ["sed"], "when": { "hasOption": ["-i"] }, "reason": "..." }`.
63
63
 
64
64
  In a compound command the strictest segment decides — one unrecognized command turns the whole line into an `ask`, and **deny always wins**. Full grammar: [policy reference](docs/policy-reference.md).
@@ -104,6 +104,14 @@ So you allow a tool broadly once, and a single repo can both add its own command
104
104
 
105
105
  Create or edit the project file with `agentperm edit --local` (it writes to your git repo root). `import` and `install` always act on the global file and your agents' global hooks.
106
106
 
107
+ ## Examples
108
+
109
+ Ready-to-crib policies in [`examples/`](examples/):
110
+
111
+ - [`starter.agent-permissions.jsonc`](examples/starter.agent-permissions.jsonc) — a minimal global policy to begin from: read-only shell and tools allowed, `sed -i` asks, `sudo` / `rm -rf` denied.
112
+ - [`global.agent-permissions.jsonc`](examples/global.agent-permissions.jsonc) — a fuller real-world global policy: a large allow-list of read-only AWS and CLI commands, the `sed -i` ask rule, a deny list, and shell-redirection defaults.
113
+ - [`project.agent-permissions.jsonc`](examples/project.agent-permissions.jsonc) — this repo's own per-project file, allowing just its dev-tooling commands on top of whatever your global policy permits.
114
+
107
115
  ## Commands
108
116
 
109
117
  | Command | What it does |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentperm"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Permission policy mediator for Claude Code, Codex, OpenCode, and Gemini CLI."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -17,13 +17,16 @@ from __future__ import annotations
17
17
  import argparse
18
18
  import json
19
19
  import os
20
+ import posixpath
20
21
  import re
21
22
  import shlex
22
23
  import shutil
23
24
  import subprocess
24
25
  import sys
25
26
  import tempfile
27
+ import urllib.parse
26
28
  from abc import ABC, abstractmethod
29
+ from collections import deque
27
30
  from collections.abc import Iterable, Iterator, Mapping, Sequence
28
31
  from dataclasses import dataclass, field
29
32
  from enum import StrEnum
@@ -135,6 +138,12 @@ class Pipeline:
135
138
  unparseable_reason: str = ""
136
139
 
137
140
 
141
+ # A tool request's input flattened to (field-name, value) pairs, e.g.
142
+ # (("url", "https://github.com/x"), ("prompt", "…")). Keeping field identity lets a scoped
143
+ # rule match only the authoritative field, never a look-alike value in another field.
144
+ ToolArguments = tuple[tuple[str, str], ...]
145
+
146
+
138
147
  class Request:
139
148
  """Marker base for ShellRequest / ToolRequest. Sum-typed via isinstance dispatch."""
140
149
 
@@ -147,6 +156,7 @@ class ShellRequest(Request):
147
156
  @dataclass(frozen=True)
148
157
  class ToolRequest(Request):
149
158
  tool: str
159
+ arguments: ToolArguments = ()
150
160
 
151
161
 
152
162
  # Permission rules are a sum type. Each rule knows how to match its kind of request.
@@ -232,21 +242,132 @@ class BashOption(Rule):
232
242
  }
233
243
 
234
244
 
245
+ _URL_ARG_KEYS = frozenset({"url", "uri", "href"})
246
+ _PATH_ARG_KEYS = frozenset(
247
+ {"path", "file_path", "filepath", "paths", "file_paths", "notebook_path", "absolute_path"}
248
+ )
249
+ _MAX_ARG_NODES = 1000
250
+
251
+
235
252
  @dataclass(frozen=True)
236
253
  class NamedTool(Rule):
237
- """Tool name pattern: exact (``Read``), wildcard (``*``), or prefix (``mcp__memory__*``)."""
254
+ """Non-shell tool rule: a name plus an optional argument specifier.
238
255
 
239
- pattern: str
256
+ Name matches exactly (``Read``), as a prefix glob (``mcp__memory__*``), or as ``*``.
257
+ The optional specifier scopes by the tool's input, keyed by conventional field names so
258
+ the same syntax works for any tool without hard-coding tool names:
240
259
 
241
- def matches(self, name: str) -> bool:
242
- if self.pattern in ("*", name):
260
+ - ``WebFetch(domain:github.com)`` — a URL field (``url`` / ``uri`` / ``href``) whose
261
+ host is ``github.com`` or a subdomain.
262
+ - ``Read(/etc/**)`` / ``Edit(src/*)`` — a path field (``path`` / ``file_path`` / …)
263
+ matching the glob: ``*`` within one segment, ``**`` across ``/``.
264
+ - bare name / ``(*)`` / ``()`` — matches the tool regardless of input.
265
+ """
266
+
267
+ name: str
268
+ specifier: str | None = None
269
+
270
+ def matches(self, name: str, arguments: ToolArguments = ()) -> bool:
271
+ if not self._name_matches(name):
272
+ return False
273
+ if self.specifier is None or self.specifier == "*":
243
274
  return True
244
- if self.pattern.endswith("*"):
245
- return name.startswith(self.pattern[:-1])
246
- return False
275
+ return self._specifier_matches(arguments)
276
+
277
+ def _name_matches(self, name: str) -> bool:
278
+ if self.name in ("*", name):
279
+ return True
280
+ return self.name.endswith("*") and name.startswith(self.name[:-1])
281
+
282
+ def _specifier_matches(self, arguments: ToolArguments) -> bool:
283
+ spec = self.specifier
284
+ if spec is None:
285
+ return True
286
+ if spec.startswith("domain:"):
287
+ host = spec[len("domain:") :]
288
+ return any(_url_host_matches(value, host) for key, value in arguments if key.lower() in _URL_ARG_KEYS)
289
+ return any(_path_glob_matches(spec, value) for key, value in arguments if key.lower() in _PATH_ARG_KEYS)
247
290
 
248
291
  def serialize(self) -> str:
249
- return self.pattern
292
+ return self.name if self.specifier is None else f"{self.name}({self.specifier})"
293
+
294
+
295
+ def _url_host_matches(value: str, host: str) -> bool:
296
+ """True if ``value`` is a URL whose host equals ``host`` or is a subdomain of it."""
297
+ target = _idna_host(host)
298
+ if not target:
299
+ return False
300
+ try:
301
+ parsed = urllib.parse.urlparse(value if "//" in value else f"//{value}")
302
+ actual = _idna_host(parsed.hostname or "")
303
+ except ValueError:
304
+ return False
305
+ return bool(actual) and (actual == target or actual.endswith(f".{target}"))
306
+
307
+
308
+ def _idna_host(host: str) -> str:
309
+ """Canonicalize a host for comparison: lowercase, no trailing root dot, IDNA/ASCII form."""
310
+ host = host.rstrip(".").lower()
311
+ if not host:
312
+ return ""
313
+ try:
314
+ return host.encode("idna").decode("ascii")
315
+ except (UnicodeError, ValueError):
316
+ return host
317
+
318
+
319
+ def _path_glob_matches(pattern: str, value: str) -> bool:
320
+ """Glob match where ``*`` stays within one path segment and ``**`` crosses ``/``.
321
+
322
+ The value's ``.``/``..`` segments are normalized first, so a scope can't be escaped via
323
+ traversal (``/repo/src/../secrets`` is matched as ``/repo/secrets``).
324
+ """
325
+ return re.fullmatch(_glob_to_regex(pattern), posixpath.normpath(value)) is not None
326
+
327
+
328
+ def _glob_to_regex(pattern: str) -> str:
329
+ out: list[str] = []
330
+ i = 0
331
+ while i < len(pattern):
332
+ if pattern.startswith("**", i):
333
+ out.append(".*")
334
+ i += 2
335
+ elif pattern[i] == "*":
336
+ out.append("[^/]*")
337
+ i += 1
338
+ elif pattern[i] == "?":
339
+ out.append("[^/]")
340
+ i += 1
341
+ else:
342
+ out.append(re.escape(pattern[i]))
343
+ i += 1
344
+ return "".join(out)
345
+
346
+
347
+ def _tool_arguments(value: object) -> ToolArguments:
348
+ """Flatten a tool-input payload to (field-name, string-value) pairs for scoping.
349
+
350
+ Breadth-first and bounded by ``_MAX_ARG_NODES`` so a deep or huge payload can't cause
351
+ ``RecursionError`` or unbounded work; shallow (authoritative) fields are kept first.
352
+ List items inherit their containing field's name.
353
+ """
354
+ out: list[tuple[str, str]] = []
355
+ queue: deque[tuple[str, object]] = deque([("", value)])
356
+ seen = 0
357
+ while queue and seen < _MAX_ARG_NODES:
358
+ key, node = queue.popleft()
359
+ seen += 1
360
+ if isinstance(node, str):
361
+ out.append((key, node))
362
+ elif isinstance(node, dict):
363
+ for sub_key, sub_value in node.items():
364
+ if isinstance(sub_key, str) and len(queue) < _MAX_ARG_NODES:
365
+ queue.append((sub_key, sub_value))
366
+ elif isinstance(node, list):
367
+ for item in node:
368
+ if len(queue) < _MAX_ARG_NODES:
369
+ queue.append((key, item))
370
+ return tuple(out)
250
371
 
251
372
 
252
373
  def _basename(arg: str) -> str:
@@ -299,7 +420,7 @@ class Policy:
299
420
  if isinstance(request, ShellRequest):
300
421
  return self._decide_shell(request.pipeline)
301
422
  if isinstance(request, ToolRequest):
302
- return self._decide_tool(request.tool)
423
+ return self._decide_tool(request.tool, request.arguments)
303
424
  return Verdict(Decision.NoOpinion, "unrecognized request")
304
425
 
305
426
  def all_rules(self) -> Iterator[tuple[Decision, Rule]]:
@@ -369,9 +490,9 @@ class Policy:
369
490
  return Verdict(Decision.Allow, "inert shell builtin")
370
491
  return Verdict(Decision.NoOpinion, f"no rule matched {segment.argv[0] if segment.argv else '<empty>'!r}")
371
492
 
372
- def _decide_tool(self, name: str) -> Verdict:
493
+ def _decide_tool(self, name: str, arguments: ToolArguments) -> Verdict:
373
494
  for decision, rule in self.all_rules():
374
- if isinstance(rule, NamedTool) and rule.matches(name):
495
+ if isinstance(rule, NamedTool) and rule.matches(name, arguments):
375
496
  return Verdict(decision, _format_rule(rule, decision))
376
497
  return Verdict(Decision.NoOpinion, f"no rule matched {name!r}")
377
498
 
@@ -1011,6 +1132,10 @@ def _parse_string_rule(text: str) -> Rule | None:
1011
1132
  bash_exact = re.fullmatch(r"Bash\((.+)\)", text)
1012
1133
  if bash_exact:
1013
1134
  return BashCommand(tuple(bash_exact.group(1).split()), trailing_wildcard=False)
1135
+ named = re.fullmatch(r"(.+?)\((.*)\)", text)
1136
+ if named:
1137
+ spec = named.group(2)
1138
+ return NamedTool(named.group(1), None if spec in ("", "*") else spec)
1014
1139
  if text:
1015
1140
  return NamedTool(text)
1016
1141
  return None
@@ -1250,7 +1375,7 @@ class ClaudeAdapter(AgentAdapter):
1250
1375
  tool_input = payload.get("tool_input")
1251
1376
  command = tool_input.get("command") if isinstance(tool_input, dict) else None
1252
1377
  return ShellRequest(parse_pipeline(command if isinstance(command, str) else ""))
1253
- return ToolRequest(tool_name)
1378
+ return ToolRequest(tool_name, _tool_arguments(payload.get("tool_input")))
1254
1379
 
1255
1380
  def write_verdict(
1256
1381
  self,
@@ -1346,7 +1471,7 @@ class CodexAdapter(AgentAdapter):
1346
1471
  command = metadata.get("command") if isinstance(metadata, dict) else None
1347
1472
  return ShellRequest(parse_pipeline(command if isinstance(command, str) else ""))
1348
1473
  if isinstance(permission_type, str):
1349
- return ToolRequest(permission_type)
1474
+ return ToolRequest(permission_type, _tool_arguments(metadata))
1350
1475
  return None
1351
1476
  return ClaudeAdapter().parse_event(payload, event_name)
1352
1477
 
@@ -1518,7 +1643,7 @@ class OpencodeAdapter(AgentAdapter):
1518
1643
  command = metadata.get("command")
1519
1644
  return ShellRequest(parse_pipeline(command if isinstance(command, str) else ""))
1520
1645
  if isinstance(permission_type, str):
1521
- return ToolRequest(permission_type)
1646
+ return ToolRequest(_opencode_tool_name(permission_type), _tool_arguments(metadata))
1522
1647
  return None
1523
1648
 
1524
1649
  def write_verdict(self, verdict: Verdict, event_name: str) -> None:
@@ -1550,19 +1675,25 @@ def _opencode_rule(tool: str, pattern: str) -> Rule | None:
1550
1675
  if pattern == "*":
1551
1676
  return None
1552
1677
  return BashCommand(tuple(pattern.split()))
1553
- return NamedTool(
1554
- {
1555
- "read": "Read",
1556
- "grep": "Grep",
1557
- "glob": "Glob",
1558
- "edit": "Edit",
1559
- "write": "Write",
1560
- "webfetch": "WebFetch",
1561
- "websearch": "WebSearch",
1562
- "task": "Task",
1563
- "skill": "Skill",
1564
- }.get(tool, tool)
1565
- )
1678
+ return NamedTool(_opencode_tool_name(tool))
1679
+
1680
+
1681
+ _OPENCODE_TOOL_NAMES = {
1682
+ "read": "Read",
1683
+ "grep": "Grep",
1684
+ "glob": "Glob",
1685
+ "edit": "Edit",
1686
+ "write": "Write",
1687
+ "webfetch": "WebFetch",
1688
+ "websearch": "WebSearch",
1689
+ "task": "Task",
1690
+ "skill": "Skill",
1691
+ }
1692
+
1693
+
1694
+ def _opencode_tool_name(tool: str) -> str:
1695
+ """Canonicalize an OpenCode tool key (``webfetch``) to the policy name (``WebFetch``)."""
1696
+ return _OPENCODE_TOOL_NAMES.get(tool, tool)
1566
1697
 
1567
1698
 
1568
1699
  def _opencode_decision(action: str) -> Decision | None:
@@ -1584,7 +1715,7 @@ class GeminiAdapter(AgentAdapter):
1584
1715
  if tool_name == "run_shell_command":
1585
1716
  command = tool_input.get("command") if isinstance(tool_input, dict) else None
1586
1717
  return ShellRequest(parse_pipeline(command if isinstance(command, str) else ""))
1587
- return ToolRequest(_gemini_tool_name(tool_name))
1718
+ return ToolRequest(_gemini_tool_name(tool_name), _tool_arguments(tool_input))
1588
1719
 
1589
1720
  def write_verdict(self, verdict: Verdict, event_name: str) -> None:
1590
1721
  if verdict.decision is Decision.NoOpinion:
@@ -106,6 +106,7 @@ def test_claude_parse_non_bash_tool_event():
106
106
  request = adapter.parse_event({"tool_name": "Read", "tool_input": {"file_path": "/tmp/x"}}, "PreToolUse")
107
107
  assert isinstance(request, ToolRequest)
108
108
  assert request.tool == "Read"
109
+ assert ("file_path", "/tmp/x") in request.arguments # input threaded through for scoping
109
110
 
110
111
 
111
112
  def test_claude_write_verdict_no_opinion_emits_empty():
@@ -321,11 +322,12 @@ def test_codex_parse_permission_request_bash_modern_envelope():
321
322
  def test_codex_parse_permission_request_other_tool():
322
323
  adapter = CodexAdapter()
323
324
  request = adapter.parse_event(
324
- {"permission": {"type": "apply_patch"}},
325
+ {"permission": {"type": "apply_patch", "metadata": {"file_path": "/tmp/x"}}},
325
326
  "PermissionRequest",
326
327
  )
327
328
  assert isinstance(request, ToolRequest)
328
329
  assert request.tool == "apply_patch"
330
+ assert ("file_path", "/tmp/x") in request.arguments # metadata threaded through for scoping
329
331
 
330
332
 
331
333
  def test_codex_pretooluse_passes_allow_through():
@@ -417,6 +419,17 @@ def test_opencode_parse_bash_event():
417
419
  assert request.pipeline.segments[0].argv == ("rm", "-rf", "/")
418
420
 
419
421
 
422
+ def test_opencode_parse_non_bash_event_threads_arguments():
423
+ adapter = OpencodeAdapter()
424
+ request = adapter.parse_event(
425
+ {"permission": {"type": "webfetch", "metadata": {"url": "https://github.com/x"}}},
426
+ "permission.ask",
427
+ )
428
+ assert isinstance(request, ToolRequest)
429
+ assert request.tool == "WebFetch" # canonicalized to match imported/written rule names
430
+ assert ("url", "https://github.com/x") in request.arguments # metadata threaded through
431
+
432
+
420
433
  def test_opencode_write_verdict_emits_status():
421
434
  adapter = OpencodeAdapter()
422
435
  buf = io.StringIO()
@@ -456,6 +469,7 @@ def test_gemini_parse_read_tool_event():
456
469
  )
457
470
  assert isinstance(request, ToolRequest)
458
471
  assert request.tool == "Read"
472
+ assert ("absolute_path", "/tmp/x") in request.arguments # input threaded through for scoping
459
473
 
460
474
 
461
475
  def test_gemini_ask_blocks_because_beforetool_cannot_prompt():
@@ -19,6 +19,7 @@ from agentperm import (
19
19
  coerce_for_pane_bypass,
20
20
  coerce_for_permission_mode,
21
21
  parse_pipeline,
22
+ parse_rule,
22
23
  )
23
24
 
24
25
  # ---- Rule matching --------------------------------------------------------
@@ -128,6 +129,88 @@ def test_named_tool_prefix_glob():
128
129
  assert NamedTool("mcp__memory__*").matches("mcp__other__x") is False
129
130
 
130
131
 
132
+ def test_named_tool_no_specifier_ignores_arguments():
133
+ # Bare name (and the `*` specifier) match the tool regardless of input.
134
+ assert NamedTool("Read").matches("Read", (("file_path", "/etc/passwd"),)) is True
135
+ assert NamedTool("Read", "*").matches("Read", (("file_path", "/anything"),)) is True
136
+
137
+
138
+ def test_named_tool_domain_specifier_matches_url_field():
139
+ rule = NamedTool("WebFetch", "domain:github.com")
140
+ assert rule.matches("WebFetch", (("url", "https://github.com/a/b"),)) is True
141
+ assert rule.matches("WebFetch", (("url", "https://api.github.com/x"),)) is True # subdomain
142
+ assert rule.matches("WebFetch", (("url", "https://github.com./x"),)) is True # trailing root dot
143
+ assert rule.matches("WebFetch", (("url", "https://evil.com/x"),)) is False
144
+ assert rule.matches("WebFetch", (("url", "https://notgithub.com/x"),)) is False # not a suffix
145
+ assert rule.matches("WebFetch", ()) is False # no URL to check
146
+
147
+
148
+ def test_named_tool_domain_ignores_url_in_non_url_field():
149
+ # A github.com URL sitting in a non-URL field (e.g. prompt) must NOT satisfy the rule.
150
+ rule = NamedTool("WebFetch", "domain:github.com")
151
+ args = (("url", "https://evil.example/x"), ("prompt", "compare with https://github.com/x"))
152
+ assert rule.matches("WebFetch", args) is False
153
+
154
+
155
+ def test_named_tool_domain_does_not_crash_on_malformed_url():
156
+ rule = NamedTool("WebFetch", "domain:github.com")
157
+ assert rule.matches("WebFetch", (("url", "http://[::1"),)) is False # no exception
158
+
159
+
160
+ def test_named_tool_domain_idna_normalizes_host():
161
+ # Unicode and punycode forms of the same host are equivalent in both directions.
162
+ assert NamedTool("WebFetch", "domain:bücher.example").matches(
163
+ "WebFetch", (("url", "https://xn--bcher-kva.example/x"),)
164
+ ) is True
165
+ assert NamedTool("WebFetch", "domain:xn--bcher-kva.example").matches(
166
+ "WebFetch", (("url", "https://bücher.example/x"),)
167
+ ) is True
168
+
169
+
170
+ def test_named_tool_glob_specifier_matches_path_field():
171
+ rule = NamedTool("Read", "/etc/**")
172
+ assert rule.matches("Read", (("file_path", "/etc/passwd"),)) is True
173
+ assert rule.matches("Read", (("file_path", "/etc/ssl/cert.pem"),)) is True # ** crosses /
174
+ assert rule.matches("Read", (("file_path", "/home/user/x"),)) is False
175
+ # `*` stays within one segment; the same mechanism scopes any tool, not just Read
176
+ assert NamedTool("Edit", "src/*").matches("Edit", (("file_path", "src/main.py"),)) is True
177
+ assert NamedTool("Edit", "src/*").matches("Edit", (("file_path", "src/sub/secret"),)) is False
178
+
179
+
180
+ def test_named_tool_glob_normalizes_path_traversal():
181
+ # `..` is collapsed before matching, so a scope can't be escaped via traversal.
182
+ assert NamedTool("Read", "/repo/src/**").matches(
183
+ "Read", (("file_path", "/repo/src/../secrets/token"),)
184
+ ) is False
185
+ assert NamedTool("Read", "/repo/secrets/**").matches(
186
+ "Read", (("file_path", "/repo/src/../secrets/token"),)
187
+ ) is True
188
+
189
+
190
+ def test_named_tool_glob_ignores_path_in_non_path_field():
191
+ # Path-like text in a non-path field (e.g. an edit's old_string) must NOT match.
192
+ rule = NamedTool("Edit", "src/**")
193
+ args = (("file_path", "/etc/passwd"), ("old_string", "import src.app"))
194
+ assert rule.matches("Edit", args) is False
195
+
196
+
197
+ def test_named_tool_specifier_requires_name_match():
198
+ # specifier only applies once the name matches
199
+ assert NamedTool("Read", "/etc/**").matches("Write", (("file_path", "/etc/passwd"),)) is False
200
+
201
+
202
+ def test_parse_round_trips_scoped_named_tool():
203
+ rule = parse_rule("WebFetch(domain:github.com)")
204
+ assert isinstance(rule, NamedTool)
205
+ assert (rule.name, rule.specifier) == ("WebFetch", "domain:github.com")
206
+ assert rule.serialize() == "WebFetch(domain:github.com)"
207
+ # `Name(*)` and `Name()` normalize to the bare name (no dead rules)
208
+ read_star = parse_rule("Read(*)")
209
+ read_bare = parse_rule("Read")
210
+ assert isinstance(read_star, NamedTool) and read_star.serialize() == "Read"
211
+ assert isinstance(read_bare, NamedTool) and read_bare.serialize() == "Read"
212
+
213
+
131
214
  # ---- Strictness aggregation ----------------------------------------------
132
215
 
133
216
 
File without changes
File without changes
File without changes
File without changes