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.
- {agentperm-0.2.0 → agentperm-0.2.1}/PKG-INFO +10 -2
- {agentperm-0.2.0 → agentperm-0.2.1}/README.md +9 -1
- {agentperm-0.2.0 → agentperm-0.2.1}/pyproject.toml +1 -1
- {agentperm-0.2.0 → agentperm-0.2.1}/src/agentperm/__init__.py +159 -28
- {agentperm-0.2.0 → agentperm-0.2.1}/tests/test_adapters.py +15 -1
- {agentperm-0.2.0 → agentperm-0.2.1}/tests/test_policy.py +83 -0
- {agentperm-0.2.0 → agentperm-0.2.1}/.gitignore +0 -0
- {agentperm-0.2.0 → agentperm-0.2.1}/LICENSE +0 -0
- {agentperm-0.2.0 → agentperm-0.2.1}/tests/__init__.py +0 -0
- {agentperm-0.2.0 → agentperm-0.2.1}/tests/test_cli.py +0 -0
- {agentperm-0.2.0 → agentperm-0.2.1}/tests/test_parser.py +0 -0
- {agentperm-0.2.0 → agentperm-0.2.1}/zellij-plugin/README.md +0 -0
- {agentperm-0.2.0 → agentperm-0.2.1}/zellij-plugin/src/lib.rs +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentperm
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
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
|
|
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 |
|
|
@@ -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
|
-
"""
|
|
254
|
+
"""Non-shell tool rule: a name plus an optional argument specifier.
|
|
238
255
|
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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.
|
|
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
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|