agentperm 0.1.0__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.
agentperm/__init__.py
ADDED
|
@@ -0,0 +1,2276 @@
|
|
|
1
|
+
"""Permission policy mediator for Claude Code, Codex, OpenCode, and Gemini CLI.
|
|
2
|
+
|
|
3
|
+
The user maintains one policy file (``~/.agent-permissions.jsonc``); this module
|
|
4
|
+
is called by agent hooks configured outside the bridge.
|
|
5
|
+
|
|
6
|
+
Module layout:
|
|
7
|
+
Domain — Decision, Verdict, Rule, Request, Pipeline, Segment, Policy
|
|
8
|
+
Shell — Tree-sitter Bash -> Pipeline
|
|
9
|
+
Rule I/O — string/dict <-> Rule
|
|
10
|
+
Policy I/O — file <-> Policy
|
|
11
|
+
Adapter — AgentAdapter ABC + Claude/Codex/Opencode/Gemini implementations
|
|
12
|
+
CLI — import, check, edit
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
from abc import ABC, abstractmethod
|
|
27
|
+
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from enum import StrEnum
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import ClassVar
|
|
32
|
+
|
|
33
|
+
import pyjson5
|
|
34
|
+
import tomlkit
|
|
35
|
+
import tree_sitter_bash
|
|
36
|
+
from tree_sitter import Language, Node, Parser
|
|
37
|
+
|
|
38
|
+
POLICY_FILENAME = ".agent-permissions.jsonc"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# -----------------------------------------------------------------------------
|
|
42
|
+
# JSON value model (system-boundary type)
|
|
43
|
+
# -----------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
type JsonScalar = str | int | float | bool | None
|
|
46
|
+
# Sequence/Mapping (covariant) — so list[str] ⊆ JsonValue without dict-invariance grief.
|
|
47
|
+
type JsonValue = JsonScalar | Sequence["JsonValue"] | Mapping[str, "JsonValue"]
|
|
48
|
+
type JsonObject = dict[str, JsonValue]
|
|
49
|
+
type JsonArray = list[JsonValue]
|
|
50
|
+
def narrow_json(value: object) -> JsonValue:
|
|
51
|
+
"""Convert untyped JSON output (json.load / pyjson5.decode) into a typed JsonValue.
|
|
52
|
+
|
|
53
|
+
Anything outside the JSON value set raises ``PolicyError`` — fail-loud at the boundary
|
|
54
|
+
so downstream code never sees ``object`` or ``Any``.
|
|
55
|
+
"""
|
|
56
|
+
if value is None:
|
|
57
|
+
return None
|
|
58
|
+
if isinstance(value, bool): # check before int — bool is a subclass of int
|
|
59
|
+
return value
|
|
60
|
+
if isinstance(value, (str, int, float)):
|
|
61
|
+
return value
|
|
62
|
+
if isinstance(value, list):
|
|
63
|
+
return [narrow_json(v) for v in value]
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
result: JsonObject = {}
|
|
66
|
+
for k, v in value.items():
|
|
67
|
+
if not isinstance(k, str):
|
|
68
|
+
raise PolicyError(f"non-string JSON key: {k!r}")
|
|
69
|
+
result[k] = narrow_json(v)
|
|
70
|
+
return result
|
|
71
|
+
raise PolicyError(f"unsupported JSON value: {type(value).__name__}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -----------------------------------------------------------------------------
|
|
75
|
+
# Domain
|
|
76
|
+
# -----------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Decision(StrEnum):
|
|
80
|
+
Allow = "allow"
|
|
81
|
+
Ask = "ask"
|
|
82
|
+
Deny = "deny"
|
|
83
|
+
NoOpinion = "no-opinion"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AgentName(StrEnum):
|
|
87
|
+
Auto = "auto"
|
|
88
|
+
Claude = "claude"
|
|
89
|
+
Codex = "codex"
|
|
90
|
+
Opencode = "opencode"
|
|
91
|
+
Gemini = "gemini"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class InstallMode(StrEnum):
|
|
95
|
+
"""Where ``install`` writes hook entries.
|
|
96
|
+
|
|
97
|
+
``Rulesync`` — merge into ``~/.rulesync/hooks.json``; user re-runs rulesync to
|
|
98
|
+
materialise per-tool configs. ``Direct`` — merge straight into per-tool configs
|
|
99
|
+
(Claude ``settings.json``, Codex ``hooks.json``+``config.toml``, Gemini ``settings.json``).
|
|
100
|
+
OpenCode plugin is always written directly regardless of mode (rulesync has no
|
|
101
|
+
schema for ``permission.ask`` plugins).
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
Rulesync = "rulesync"
|
|
105
|
+
Direct = "direct"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_STRICTNESS = {Decision.Deny: 3, Decision.Ask: 2, Decision.Allow: 1, Decision.NoOpinion: 0}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class Verdict:
|
|
113
|
+
decision: Decision
|
|
114
|
+
rationale: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class Redirect:
|
|
119
|
+
fd: int | None
|
|
120
|
+
op: str
|
|
121
|
+
target: str
|
|
122
|
+
is_fd_dup: bool # 2>&1, 1>&2 — duplicates fd, doesn't write to a file
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class Segment:
|
|
127
|
+
argv: tuple[str, ...]
|
|
128
|
+
redirects: tuple[Redirect, ...]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class Pipeline:
|
|
133
|
+
segments: tuple[Segment, ...]
|
|
134
|
+
parseable: bool
|
|
135
|
+
unparseable_reason: str = ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Request:
|
|
139
|
+
"""Marker base for ShellRequest / ToolRequest. Sum-typed via isinstance dispatch."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class ShellRequest(Request):
|
|
144
|
+
pipeline: Pipeline
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class ToolRequest(Request):
|
|
149
|
+
tool: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Permission rules are a sum type. Each rule knows how to match its kind of request.
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Rule(ABC):
|
|
156
|
+
@abstractmethod
|
|
157
|
+
def serialize(self) -> str | JsonObject: ...
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class BashCommand(Rule):
|
|
162
|
+
"""``Bash(git status:*)`` — matches a bash segment whose argv matches the token pattern.
|
|
163
|
+
|
|
164
|
+
Tokens are literals by default; ``*`` matches exactly one argv element and ``**`` matches
|
|
165
|
+
zero or more. ``trailing_wildcard`` corresponds to the ``:*`` suffix and lets argv extend
|
|
166
|
+
past the pattern; without it, argv must be consumed exactly.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
prefix: tuple[str, ...]
|
|
170
|
+
trailing_wildcard: bool = True
|
|
171
|
+
|
|
172
|
+
def matches(self, segment: Segment) -> bool:
|
|
173
|
+
if not self.prefix:
|
|
174
|
+
return False
|
|
175
|
+
return _glob_match_argv(self.prefix, segment.argv, self.trailing_wildcard)
|
|
176
|
+
|
|
177
|
+
def serialize(self) -> str:
|
|
178
|
+
body = " ".join(self.prefix)
|
|
179
|
+
return f"Bash({body}:*)" if self.trailing_wildcard else f"Bash({body})"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _glob_match_argv(pattern: tuple[str, ...], argv: tuple[str, ...], trailing_wildcard: bool) -> bool:
|
|
183
|
+
"""Match a token-glob pattern against argv.
|
|
184
|
+
|
|
185
|
+
Literals require exact equality (basename rule for argv[0] only). ``*`` consumes exactly
|
|
186
|
+
one argv token; ``**`` consumes zero or more. When ``*`` or ``**`` covers position 0 the
|
|
187
|
+
basename rule does not apply — the glob doesn't carry the literal token to compare.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def go(pi: int, ai: int) -> bool:
|
|
191
|
+
while pi < len(pattern):
|
|
192
|
+
tok = pattern[pi]
|
|
193
|
+
if tok == "**":
|
|
194
|
+
return any(go(pi + 1, ai + skip) for skip in range(len(argv) - ai + 1))
|
|
195
|
+
if ai >= len(argv):
|
|
196
|
+
return False
|
|
197
|
+
if tok == "*":
|
|
198
|
+
pi += 1
|
|
199
|
+
ai += 1
|
|
200
|
+
continue
|
|
201
|
+
actual = _basename(argv[ai]) if ai == 0 else argv[ai]
|
|
202
|
+
if actual != tok:
|
|
203
|
+
return False
|
|
204
|
+
pi += 1
|
|
205
|
+
ai += 1
|
|
206
|
+
return trailing_wildcard or ai == len(argv)
|
|
207
|
+
|
|
208
|
+
return go(0, 0)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class BashOption(Rule):
|
|
213
|
+
"""Structured: matches bash segments that invoke a command with a specific option."""
|
|
214
|
+
|
|
215
|
+
commands: frozenset[str]
|
|
216
|
+
options: frozenset[str]
|
|
217
|
+
rationale: str
|
|
218
|
+
|
|
219
|
+
def matches(self, segment: Segment) -> bool:
|
|
220
|
+
if not segment.argv:
|
|
221
|
+
return False
|
|
222
|
+
if _basename(segment.argv[0]) not in self.commands:
|
|
223
|
+
return False
|
|
224
|
+
return any(_arg_matches_option(arg, opt) for arg in segment.argv[1:] for opt in self.options)
|
|
225
|
+
|
|
226
|
+
def serialize(self) -> JsonObject:
|
|
227
|
+
return {
|
|
228
|
+
"tool": "Bash",
|
|
229
|
+
"command": sorted(self.commands),
|
|
230
|
+
"when": {"hasOption": sorted(self.options)},
|
|
231
|
+
"reason": self.rationale,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(frozen=True)
|
|
236
|
+
class NamedTool(Rule):
|
|
237
|
+
"""Tool name pattern: exact (``Read``), wildcard (``*``), or prefix (``mcp__memory__*``)."""
|
|
238
|
+
|
|
239
|
+
pattern: str
|
|
240
|
+
|
|
241
|
+
def matches(self, name: str) -> bool:
|
|
242
|
+
if self.pattern in ("*", name):
|
|
243
|
+
return True
|
|
244
|
+
if self.pattern.endswith("*"):
|
|
245
|
+
return name.startswith(self.pattern[:-1])
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
def serialize(self) -> str:
|
|
249
|
+
return self.pattern
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _basename(arg: str) -> str:
|
|
253
|
+
return arg.rsplit("/", 1)[-1]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _arg_matches_option(arg: str, option: str) -> bool:
|
|
257
|
+
if arg == "--":
|
|
258
|
+
return False
|
|
259
|
+
if option.startswith("--"):
|
|
260
|
+
return arg == option or arg.startswith(option + "=")
|
|
261
|
+
if option.startswith("-"):
|
|
262
|
+
short = option[1:]
|
|
263
|
+
if not arg.startswith("-") or arg.startswith("--"):
|
|
264
|
+
return False
|
|
265
|
+
return short in arg[1:]
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# -----------------------------------------------------------------------------
|
|
270
|
+
# Policy
|
|
271
|
+
# -----------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# Synthetic argv markers the parser emits for predicate constructs — never real
|
|
275
|
+
# commands, so user rules can't meaningfully target them. Matched *before* user
|
|
276
|
+
# rules in ``_match_bash`` and always allowed. ``test_command`` (`[ … ]` and
|
|
277
|
+
# `[[ … ]]`) both collapse to ``"["``; arithmetic ``(( … ))`` to ``"(("``.
|
|
278
|
+
_SYNTHETIC_INERT_MARKERS: frozenset[str] = frozenset({"[", "[[", "(("})
|
|
279
|
+
|
|
280
|
+
# Real shell builtins with no OS-level side effect of their own. Allowed as a
|
|
281
|
+
# *fallback* in ``_match_bash`` when no user rule matches — an explicit
|
|
282
|
+
# ``deny``/``ask``/``allow`` rule on one of these still takes precedence.
|
|
283
|
+
# Redirect verdicts are applied independently in ``_decide_segment``, so e.g.
|
|
284
|
+
# ``echo foo > out`` still surfaces an Ask via the redirect rule.
|
|
285
|
+
_INERT_COMMAND_NAMES: frozenset[str] = frozenset({
|
|
286
|
+
"true", "false", ":", # status setters / no-op
|
|
287
|
+
"read", # in-process variable bind only
|
|
288
|
+
"echo", "printf", # output to fds; redirects evaluated separately
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@dataclass(frozen=True)
|
|
293
|
+
class Policy:
|
|
294
|
+
deny: tuple[Rule, ...] = ()
|
|
295
|
+
ask: tuple[Rule, ...] = ()
|
|
296
|
+
allow: tuple[Rule, ...] = ()
|
|
297
|
+
|
|
298
|
+
def decide(self, request: Request) -> Verdict:
|
|
299
|
+
if isinstance(request, ShellRequest):
|
|
300
|
+
return self._decide_shell(request.pipeline)
|
|
301
|
+
if isinstance(request, ToolRequest):
|
|
302
|
+
return self._decide_tool(request.tool)
|
|
303
|
+
return Verdict(Decision.NoOpinion, "unrecognized request")
|
|
304
|
+
|
|
305
|
+
def all_rules(self) -> Iterator[tuple[Decision, Rule]]:
|
|
306
|
+
for rule in self.deny:
|
|
307
|
+
yield Decision.Deny, rule
|
|
308
|
+
for rule in self.ask:
|
|
309
|
+
yield Decision.Ask, rule
|
|
310
|
+
for rule in self.allow:
|
|
311
|
+
yield Decision.Allow, rule
|
|
312
|
+
|
|
313
|
+
def merged_with(self, other: Policy) -> Policy:
|
|
314
|
+
def union(a: tuple[Rule, ...], b: tuple[Rule, ...]) -> tuple[Rule, ...]:
|
|
315
|
+
seen: list[Rule] = list(a)
|
|
316
|
+
for rule in b:
|
|
317
|
+
if rule not in seen:
|
|
318
|
+
seen.append(rule)
|
|
319
|
+
return tuple(seen)
|
|
320
|
+
|
|
321
|
+
return Policy(
|
|
322
|
+
deny=union(self.deny, other.deny),
|
|
323
|
+
ask=union(self.ask, other.ask),
|
|
324
|
+
allow=union(self.allow, other.allow),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def _decide_shell(self, pipeline: Pipeline) -> Verdict:
|
|
328
|
+
if not pipeline.parseable:
|
|
329
|
+
return Verdict(Decision.Ask, pipeline.unparseable_reason or "shell syntax not safely parseable")
|
|
330
|
+
if not pipeline.segments:
|
|
331
|
+
return Verdict(Decision.NoOpinion, "")
|
|
332
|
+
verdicts = [self._decide_segment(seg) for seg in pipeline.segments]
|
|
333
|
+
return aggregate(verdicts)
|
|
334
|
+
|
|
335
|
+
def _decide_segment(self, segment: Segment) -> Verdict:
|
|
336
|
+
return _stricter(_evaluate_redirects(segment.redirects), self._match_bash(segment))
|
|
337
|
+
|
|
338
|
+
def _match_bash(self, segment: Segment) -> Verdict:
|
|
339
|
+
argv0 = _basename(segment.argv[0]) if segment.argv else None
|
|
340
|
+
# Synthetic predicate markers ([, ((, …) aren't real commands, so user
|
|
341
|
+
# rules can't target them — allow before the rule loop.
|
|
342
|
+
if argv0 in _SYNTHETIC_INERT_MARKERS:
|
|
343
|
+
return Verdict(Decision.Allow, "inert predicate")
|
|
344
|
+
for decision, rule in self.all_rules():
|
|
345
|
+
if isinstance(rule, BashCommand | BashOption) and rule.matches(segment):
|
|
346
|
+
rationale = rule.rationale if isinstance(rule, BashOption) else _format_rule(rule, decision)
|
|
347
|
+
return Verdict(decision, rationale)
|
|
348
|
+
# ``command -v/-V X`` is a benign name lookup, not execution.
|
|
349
|
+
if _is_command_lookup(segment):
|
|
350
|
+
return Verdict(Decision.Allow, "command lookup")
|
|
351
|
+
# A command-introducing wrapper whose inner command we couldn't extract —
|
|
352
|
+
# a shell ``-c`` form we declined to unwrap (``bash --norc -c "rm -rf /"``),
|
|
353
|
+
# or an exec-prefix wrapper left intact (``timeout 5 rm -rf /``, ``nice -n 10
|
|
354
|
+
# rm -rf /``). It hides its real command, so treat it as a parse failure:
|
|
355
|
+
# bypass keeps prompting rather than coercing the otherwise-NoOpinion verdict
|
|
356
|
+
# to Allow. Reached only when no explicit rule matched, so ``Bash(sudo:*)``
|
|
357
|
+
# and friends still allow-list. (Cleanly-decomposable forms never reach here —
|
|
358
|
+
# they were already expanded into their inner segments.)
|
|
359
|
+
if _is_opaque_shell_command(segment) or (argv0 in _ALL_EXEC_WRAPPERS):
|
|
360
|
+
return Verdict(Decision.Ask, f"unanalyzable command wrapper {segment.argv[0]!r}")
|
|
361
|
+
# A command whose *name* is a runtime expansion (``eval "$cmd"``, ``bash -c
|
|
362
|
+
# "$cmd"``, ``$TOOL args``) is unknowable statically. Flag it so bypass
|
|
363
|
+
# prompts rather than allowing whatever the variable resolves to.
|
|
364
|
+
if segment.argv and "$" in segment.argv[0]:
|
|
365
|
+
return Verdict(Decision.Ask, f"dynamic command name {segment.argv[0]!r}")
|
|
366
|
+
# Real inert builtins (echo, true, …) are a fallback, not a pre-rule short
|
|
367
|
+
# circuit: an explicit deny/ask rule on one of them must still bite.
|
|
368
|
+
if argv0 in _INERT_COMMAND_NAMES:
|
|
369
|
+
return Verdict(Decision.Allow, "inert shell builtin")
|
|
370
|
+
return Verdict(Decision.NoOpinion, f"no rule matched {segment.argv[0] if segment.argv else '<empty>'!r}")
|
|
371
|
+
|
|
372
|
+
def _decide_tool(self, name: str) -> Verdict:
|
|
373
|
+
for decision, rule in self.all_rules():
|
|
374
|
+
if isinstance(rule, NamedTool) and rule.matches(name):
|
|
375
|
+
return Verdict(decision, _format_rule(rule, decision))
|
|
376
|
+
return Verdict(Decision.NoOpinion, f"no rule matched {name!r}")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _format_rule(rule: Rule, decision: Decision) -> str:
|
|
380
|
+
return f"{decision.value} by rule {rule.serialize()!r}"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _stricter(left: Verdict, right: Verdict) -> Verdict:
|
|
384
|
+
if _STRICTNESS[left.decision] > _STRICTNESS[right.decision]:
|
|
385
|
+
return left
|
|
386
|
+
if _STRICTNESS[right.decision] > _STRICTNESS[left.decision]:
|
|
387
|
+
return right
|
|
388
|
+
# Tie on strictness: prefer the side with an informative rationale.
|
|
389
|
+
return left if left.rationale else right
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def aggregate(verdicts: list[Verdict]) -> Verdict:
|
|
393
|
+
"""Aggregate per-segment verdicts. Strictest wins; an unrecognized segment escalates Allow to Ask."""
|
|
394
|
+
if not verdicts:
|
|
395
|
+
return Verdict(Decision.NoOpinion, "")
|
|
396
|
+
strictest = max(verdicts, key=lambda v: _STRICTNESS[v.decision])
|
|
397
|
+
if strictest.decision is Decision.Allow:
|
|
398
|
+
unknown = next((v for v in verdicts if v.decision is Decision.NoOpinion), None)
|
|
399
|
+
if unknown is not None:
|
|
400
|
+
return Verdict(Decision.Ask, f"compound includes unrecognized segment: {unknown.rationale}")
|
|
401
|
+
return strictest
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# -----------------------------------------------------------------------------
|
|
405
|
+
# Redirect policy (built-in; not user-tunable)
|
|
406
|
+
# -----------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _evaluate_redirects(redirects: Iterable[Redirect]) -> Verdict:
|
|
410
|
+
for r in redirects:
|
|
411
|
+
verdict = _evaluate_redirect(r)
|
|
412
|
+
if verdict.decision is not Decision.NoOpinion:
|
|
413
|
+
return verdict
|
|
414
|
+
return Verdict(Decision.NoOpinion, "")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _evaluate_redirect(r: Redirect) -> Verdict:
|
|
418
|
+
if r.is_fd_dup:
|
|
419
|
+
return Verdict(Decision.NoOpinion, "")
|
|
420
|
+
if r.fd == 2 and r.op in (">", ">>") and r.target == "/dev/null":
|
|
421
|
+
return Verdict(Decision.NoOpinion, "")
|
|
422
|
+
# ``>|`` force-clobber and ``&>>`` append-both are writes like ``>`` / ``&>``.
|
|
423
|
+
if r.op in (">", ">>", "&>", ">|", "&>>"):
|
|
424
|
+
return Verdict(Decision.Ask, f"writes to {r.target!r}")
|
|
425
|
+
if r.op == "<":
|
|
426
|
+
return Verdict(Decision.NoOpinion, "")
|
|
427
|
+
return Verdict(Decision.Ask, f"unrecognized redirection {r.op!r}")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# -----------------------------------------------------------------------------
|
|
431
|
+
# Shell parser (Tree-sitter Bash -> Pipeline)
|
|
432
|
+
#
|
|
433
|
+
# Tree-sitter exposes generic Node objects with grammar-specific ``type`` strings.
|
|
434
|
+
# The helpers below are the only place that talks to that boundary; everything
|
|
435
|
+
# outside this section only sees the typed Pipeline/Segment/Redirect domain types.
|
|
436
|
+
# -----------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class _UnsupportedShellError(Exception):
|
|
440
|
+
pass
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
_SHELL_COMMANDS = frozenset({"bash", "sh", "zsh"})
|
|
444
|
+
|
|
445
|
+
# Exec-prefix wrappers run a *following* command. We decompose them so a deny rule
|
|
446
|
+
# on the inner command still bites. Value = short option letters that take NO
|
|
447
|
+
# argument; the inner command is the first token that is neither one of those
|
|
448
|
+
# options nor (for ``env``) a ``NAME=value`` assignment. A wrapper invocation whose
|
|
449
|
+
# options we can't classify is left intact and flagged at decision time.
|
|
450
|
+
_EXEC_WRAPPER_NO_ARG_OPTS: dict[str, frozenset[str]] = {
|
|
451
|
+
"command": frozenset("pvV"),
|
|
452
|
+
"exec": frozenset("cl"),
|
|
453
|
+
"nohup": frozenset(),
|
|
454
|
+
"setsid": frozenset("cfw"),
|
|
455
|
+
"env": frozenset("i"),
|
|
456
|
+
"nice": frozenset(),
|
|
457
|
+
"time": frozenset("p"),
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
# Exec wrappers we never decompose — leading positionals (``timeout 5 cmd``) or
|
|
461
|
+
# option grammars too varied to model. Flagged at decision time so bypass prompts
|
|
462
|
+
# rather than allowing the hidden command; an explicit rule still allow-lists them.
|
|
463
|
+
_OPAQUE_EXEC_WRAPPERS: frozenset[str] = frozenset({
|
|
464
|
+
"timeout", "sudo", "doas", "su", "runuser", "xargs", "stdbuf", "ionice",
|
|
465
|
+
"chrt", "setarch", "setpriv", "unshare", "watch", "parallel", "flock", "eval",
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
_ALL_EXEC_WRAPPERS: frozenset[str] = frozenset(_EXEC_WRAPPER_NO_ARG_OPTS) | _OPAQUE_EXEC_WRAPPERS
|
|
469
|
+
|
|
470
|
+
_ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
471
|
+
_BASH_LANGUAGE = Language(tree_sitter_bash.language())
|
|
472
|
+
_BASH_PARSER = Parser()
|
|
473
|
+
_BASH_PARSER.language = _BASH_LANGUAGE
|
|
474
|
+
|
|
475
|
+
# Argument-position nodes whose literal source text is safe to treat as opaque
|
|
476
|
+
# argument text. Variable values aren't expanded at parse time, so argv[0] rule
|
|
477
|
+
# matching is unaffected. ``_node_contains_substitution`` still rejects anything
|
|
478
|
+
# nesting a command/process substitution, so e.g. ``cat foo$(date)`` is blocked
|
|
479
|
+
# even though the outer node here is a ``concatenation``.
|
|
480
|
+
_OPAQUE_ARG_TYPES = frozenset({
|
|
481
|
+
"word", "number", "string", "raw_string",
|
|
482
|
+
"simple_expansion", "expansion", "concatenation",
|
|
483
|
+
"arithmetic_expansion", "ansi_c_string", "translated_string",
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
# Subset valid as children of a ``string`` node (i.e. inside double quotes).
|
|
487
|
+
# ``concatenation`` doesn't appear here — strings are leaves in tree-sitter-bash.
|
|
488
|
+
_STRING_CHILD_TYPES = frozenset({
|
|
489
|
+
"string_content", "simple_expansion", "expansion", "arithmetic_expansion",
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
# Children of control-flow nodes that are subjects/patterns/names rather than
|
|
493
|
+
# executable segments — skipped during recursion. Includes function names
|
|
494
|
+
# (``foo`` in ``foo() { … }``, parsed as ``word``) and ``case`` patterns.
|
|
495
|
+
_PATTERN_CHILD_TYPES = frozenset({"extglob_pattern", "regex"}) | _OPAQUE_ARG_TYPES
|
|
496
|
+
|
|
497
|
+
# Control-flow / grouping nodes whose named children are recursable into segments.
|
|
498
|
+
# Excludes ``for_statement`` (handled separately because the iterable list contains
|
|
499
|
+
# ``variable_name``/etc. that aren't pattern types but also aren't recursable).
|
|
500
|
+
_CONTROL_FLOW_TYPES = frozenset({
|
|
501
|
+
"program", "list", "pipeline", "do_group",
|
|
502
|
+
"if_statement", "while_statement", "until_statement",
|
|
503
|
+
"case_statement", "case_item",
|
|
504
|
+
"elif_clause", "else_clause",
|
|
505
|
+
"subshell", "negated_command", "function_definition",
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
# AST node types that ``_build_redirected_segment`` will recurse into via
|
|
509
|
+
# ``_extract_segments`` to collect inner segments before attaching the redirect.
|
|
510
|
+
_REDIRECT_INNER_TYPES = frozenset({
|
|
511
|
+
"list", "pipeline", "subshell",
|
|
512
|
+
"test_command", "compound_statement",
|
|
513
|
+
"if_statement", "while_statement", "until_statement",
|
|
514
|
+
"case_statement", "negated_command",
|
|
515
|
+
"function_definition", "declaration_command",
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def parse_pipeline(command: str) -> Pipeline:
|
|
520
|
+
if not command.strip():
|
|
521
|
+
return Pipeline(segments=(), parseable=True)
|
|
522
|
+
source = command.encode()
|
|
523
|
+
tree = _BASH_PARSER.parse(source)
|
|
524
|
+
if tree.root_node.has_error:
|
|
525
|
+
return Pipeline((), parseable=False, unparseable_reason="tree-sitter: shell syntax error")
|
|
526
|
+
segments: list[Segment] = []
|
|
527
|
+
try:
|
|
528
|
+
segments.extend(_extract_segments(tree.root_node, source))
|
|
529
|
+
except _UnsupportedShellError as error:
|
|
530
|
+
return Pipeline((), parseable=False, unparseable_reason=str(error))
|
|
531
|
+
return Pipeline(tuple(segments), parseable=True)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _extract_segments(node: Node, source: bytes) -> Iterator[Segment]:
|
|
535
|
+
if node.type == "command":
|
|
536
|
+
segment, inner = _build_segment(node, source)
|
|
537
|
+
unwrapped = _unwrap_shell_c(segment)
|
|
538
|
+
if unwrapped is not None:
|
|
539
|
+
yield from unwrapped
|
|
540
|
+
yield from inner
|
|
541
|
+
return
|
|
542
|
+
yield from _unwrap_exec_wrapper(segment)
|
|
543
|
+
yield from inner
|
|
544
|
+
return
|
|
545
|
+
if node.type == "redirected_statement":
|
|
546
|
+
yield from _build_redirected_segment(node, source)
|
|
547
|
+
return
|
|
548
|
+
if node.type in ("command_substitution", "process_substitution"):
|
|
549
|
+
# A bare substitution standing where a command is expected — e.g. a
|
|
550
|
+
# ``case $(rm -rf /) in …`` subject. The substitution runs; extract its
|
|
551
|
+
# inner commands for policy evaluation rather than bailing as unparseable.
|
|
552
|
+
yield from _extract_substitution_segments(node, source)
|
|
553
|
+
return
|
|
554
|
+
if node.type == "compound_statement":
|
|
555
|
+
# ``(( … ))`` and ``{ …; }`` share this AST node — disambiguate by source prefix.
|
|
556
|
+
# Arithmetic is a pure predicate (in-process state only); brace groups are
|
|
557
|
+
# ordinary command lists wrapped in braces.
|
|
558
|
+
if source[node.start_byte:node.start_byte + 2] == b"((":
|
|
559
|
+
yield Segment(("((",), ())
|
|
560
|
+
yield from _extract_substitution_segments(node, source)
|
|
561
|
+
return
|
|
562
|
+
for child in node.named_children:
|
|
563
|
+
if child.type in _PATTERN_CHILD_TYPES:
|
|
564
|
+
continue
|
|
565
|
+
yield from _extract_segments(child, source)
|
|
566
|
+
return
|
|
567
|
+
if node.type == "test_command":
|
|
568
|
+
# ``[ … ]`` and ``[[ … ]]`` are pure predicates — collapse to a synthetic
|
|
569
|
+
# segment the inert-builtin matcher recognizes. Children are expressions
|
|
570
|
+
# (test_operator, unary_expression, …) and yield no commands of their own.
|
|
571
|
+
# Substitutions inside (e.g. ``[[ -f $(curl evil) ]]``) execute before
|
|
572
|
+
# the predicate — extract their inner commands as segments for policy eval.
|
|
573
|
+
yield Segment(("[",), ())
|
|
574
|
+
yield from _extract_substitution_segments(node, source)
|
|
575
|
+
return
|
|
576
|
+
if node.type == "declaration_command":
|
|
577
|
+
# ``export FOO=bar`` / ``local`` / ``declare`` / ``readonly`` / ``typeset``.
|
|
578
|
+
# tree-sitter parses these as their own node type, not as ``command``, so a
|
|
579
|
+
# user ``Bash(export:*)`` rule would never match without explicit handling.
|
|
580
|
+
# Yield a normal segment with the keyword as argv[0] and the assignments/
|
|
581
|
+
# words as subsequent argv tokens. Substitution-containing children are
|
|
582
|
+
# dropped from argv and their inner commands yielded as separate segments.
|
|
583
|
+
if not node.children:
|
|
584
|
+
raise _UnsupportedShellError("declaration_command missing keyword")
|
|
585
|
+
decl_argv: list[str] = [_node_text(node.children[0], source)]
|
|
586
|
+
decl_inner: list[Segment] = []
|
|
587
|
+
for child in node.named_children:
|
|
588
|
+
if child.type in ("command_substitution", "process_substitution") \
|
|
589
|
+
or _node_contains_substitution(child):
|
|
590
|
+
decl_inner.extend(_extract_substitution_segments(child, source))
|
|
591
|
+
continue
|
|
592
|
+
decl_argv.append(_node_text(child, source))
|
|
593
|
+
yield Segment(tuple(decl_argv), ())
|
|
594
|
+
yield from decl_inner
|
|
595
|
+
return
|
|
596
|
+
if node.type in _CONTROL_FLOW_TYPES:
|
|
597
|
+
for child in node.named_children:
|
|
598
|
+
if child.type in _PATTERN_CHILD_TYPES:
|
|
599
|
+
# Subjects/patterns are skipped from segment extraction, but
|
|
600
|
+
# ``case foo$(curl evil) in …`` would still execute the
|
|
601
|
+
# substitution before pattern matching. Extract inner commands
|
|
602
|
+
# as segments for policy evaluation.
|
|
603
|
+
yield from _extract_substitution_segments(child, source)
|
|
604
|
+
continue
|
|
605
|
+
yield from _extract_segments(child, source)
|
|
606
|
+
return
|
|
607
|
+
if node.type == "for_statement":
|
|
608
|
+
# Covers ``for v in …`` and ``select v in …`` (same node). The iterable
|
|
609
|
+
# list is opaque text; only the ``do_group`` body holds executable commands.
|
|
610
|
+
# The iterable can trigger substitutions (e.g. ``for f in $(curl evil);
|
|
611
|
+
# do …; done``) which execute before the loop runs — extract their inner
|
|
612
|
+
# commands as segments for policy evaluation.
|
|
613
|
+
for child in node.named_children:
|
|
614
|
+
if child.type == "do_group":
|
|
615
|
+
yield from _extract_segments(child, source)
|
|
616
|
+
continue
|
|
617
|
+
if child.type == "variable_name":
|
|
618
|
+
continue
|
|
619
|
+
if child.type in ("command_substitution", "process_substitution") \
|
|
620
|
+
or _node_contains_substitution(child):
|
|
621
|
+
yield from _extract_substitution_segments(child, source)
|
|
622
|
+
return
|
|
623
|
+
raise _UnsupportedShellError(f"unsupported shell node {node.type!r}")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _build_segment(command_node: Node, source: bytes) -> tuple[Segment, tuple[Segment, ...]]:
|
|
627
|
+
"""Build a ``Segment`` from a ``command`` AST node.
|
|
628
|
+
|
|
629
|
+
Returns ``(segment, substitution_segments)`` — the main command plus any
|
|
630
|
+
segments extracted from command/process substitutions in its arguments.
|
|
631
|
+
Substitution-containing arguments are dropped from argv (their runtime
|
|
632
|
+
value is unknowable); the inner commands are returned so the policy
|
|
633
|
+
evaluator can check them independently.
|
|
634
|
+
"""
|
|
635
|
+
argv: list[str] = []
|
|
636
|
+
inner: list[Segment] = []
|
|
637
|
+
for child in command_node.named_children:
|
|
638
|
+
if child.type in ("command_substitution", "process_substitution") \
|
|
639
|
+
or _node_contains_substitution(child):
|
|
640
|
+
inner.extend(_extract_substitution_segments(child, source))
|
|
641
|
+
continue
|
|
642
|
+
if child.type == "variable_assignment":
|
|
643
|
+
continue
|
|
644
|
+
if child.type == "command_name":
|
|
645
|
+
argv.append(_node_text(child, source))
|
|
646
|
+
continue
|
|
647
|
+
if child.type in _OPAQUE_ARG_TYPES:
|
|
648
|
+
argv.append(_argument_text(child, source))
|
|
649
|
+
continue
|
|
650
|
+
if child.type == "herestring_redirect":
|
|
651
|
+
# ``cmd <<< word`` feeds a string to stdin — input only, no file write.
|
|
652
|
+
# A herestring carrying a substitution is extracted by the branch above.
|
|
653
|
+
continue
|
|
654
|
+
raise _UnsupportedShellError(f"unsupported command part {child.type!r}")
|
|
655
|
+
return Segment(tuple(argv), ()), tuple(inner)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _build_redirected_segment(node: Node, source: bytes) -> Iterator[Segment]:
|
|
659
|
+
# tree-sitter-bash flattens trailing argv into the file_redirect node and
|
|
660
|
+
# wraps any compound left-hand side under a single ``list``/``pipeline``
|
|
661
|
+
# child. ``cmd1 && cmd2 2>file foo`` parses as
|
|
662
|
+
# ``redirected_statement(list(cmd1, &&, cmd2), file_redirect(2>file foo))``
|
|
663
|
+
# even though bash binds the redirect to ``cmd2`` and treats ``foo`` as
|
|
664
|
+
# ``cmd2``'s argv. We invert that here: yield each inner segment, append
|
|
665
|
+
# spillover words to the last segment, and attach all collected redirects
|
|
666
|
+
# to that same last segment.
|
|
667
|
+
inner_segments: list[Segment] = []
|
|
668
|
+
substitution_segments: list[Segment] = []
|
|
669
|
+
redirects: list[Redirect] = []
|
|
670
|
+
spillover: list[str] = []
|
|
671
|
+
last_was_unwrapped_wrapper = False
|
|
672
|
+
for child in node.named_children:
|
|
673
|
+
if child.type == "command":
|
|
674
|
+
segment, sub_segs = _build_segment(child, source)
|
|
675
|
+
# ``zsh -lc "rm -rf /" 2>file`` wraps the inner command; unwrap it like
|
|
676
|
+
# ``_extract_segments`` does, else a deny rule on the inner command can't
|
|
677
|
+
# bite. The trailing redirect then attaches to the last inner segment.
|
|
678
|
+
unwrapped = _unwrap_shell_c(segment)
|
|
679
|
+
if unwrapped is not None:
|
|
680
|
+
inner_segments.extend(unwrapped)
|
|
681
|
+
last_was_unwrapped_wrapper = True
|
|
682
|
+
else:
|
|
683
|
+
# Exec-wrapper spillover (`nohup cmd 2>f extra`) is argv of the inner
|
|
684
|
+
# command, so it must rejoin — unlike a shell -c wrapper's positionals.
|
|
685
|
+
inner_segments.extend(_unwrap_exec_wrapper(segment))
|
|
686
|
+
last_was_unwrapped_wrapper = False
|
|
687
|
+
substitution_segments.extend(sub_segs)
|
|
688
|
+
continue
|
|
689
|
+
if child.type in _REDIRECT_INNER_TYPES:
|
|
690
|
+
inner_segments.extend(_extract_segments(child, source))
|
|
691
|
+
last_was_unwrapped_wrapper = False
|
|
692
|
+
continue
|
|
693
|
+
if child.type == "file_redirect":
|
|
694
|
+
redirect, extras, redirect_subs = _build_redirect(child, source)
|
|
695
|
+
if redirect is not None:
|
|
696
|
+
redirects.append(redirect)
|
|
697
|
+
spillover.extend(extras)
|
|
698
|
+
substitution_segments.extend(redirect_subs)
|
|
699
|
+
continue
|
|
700
|
+
if child.type == "heredoc_redirect":
|
|
701
|
+
# ``cat <<EOF … EOF`` feeds the body to stdin; no file write, no policy
|
|
702
|
+
# impact. Drop it so the wrapped command flows through normal matching.
|
|
703
|
+
# An unquoted heredoc body still expands ``$(…)`` before the command
|
|
704
|
+
# runs — extract inner commands as segments for policy evaluation.
|
|
705
|
+
substitution_segments.extend(_extract_substitution_segments(child, source))
|
|
706
|
+
continue
|
|
707
|
+
raise _UnsupportedShellError(f"unsupported redirected statement part {child.type!r}")
|
|
708
|
+
if not inner_segments:
|
|
709
|
+
raise _UnsupportedShellError("redirected statement missing command")
|
|
710
|
+
# Words after a ``shell -c "…"`` wrapper are the wrapper's positional params
|
|
711
|
+
# ($0, $1, …), not argv of the unwrapped inner command — they vanish with the
|
|
712
|
+
# discarded wrapper. Spillover only rejoins argv when the last segment is a
|
|
713
|
+
# real command tree-sitter split the redirect away from.
|
|
714
|
+
if last_was_unwrapped_wrapper:
|
|
715
|
+
spillover = []
|
|
716
|
+
*head, last = inner_segments
|
|
717
|
+
yield from head
|
|
718
|
+
yield Segment(
|
|
719
|
+
last.argv + tuple(spillover),
|
|
720
|
+
last.redirects + tuple(redirects),
|
|
721
|
+
)
|
|
722
|
+
yield from substitution_segments
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
# Short flags that take no argument and are safe to share a ``-c`` cluster with.
|
|
726
|
+
# Intersection of ``bash(1)`` / ``zsh(1)`` / POSIX ``sh(1)`` no-arg short options.
|
|
727
|
+
# A flag absent from this set forces fall-through (NoOpinion → native prompt)
|
|
728
|
+
# rather than a guess about which cluster element steals ``argv[2]``.
|
|
729
|
+
_NO_ARG_SHELL_FLAGS = frozenset("efilmnpstuvx")
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _is_safe_c_bundle(flag: str) -> bool:
|
|
733
|
+
"""True iff ``flag`` is ``-c`` or a short-flag cluster ending in ``c`` whose
|
|
734
|
+
other chars are all in ``_NO_ARG_SHELL_FLAGS``. Only then is the token after
|
|
735
|
+
it reliably the command string under POSIX cluster semantics: any arg-taking
|
|
736
|
+
flag in the cluster (``-o``, ``-O``…) would steal it instead.
|
|
737
|
+
"""
|
|
738
|
+
if not flag.startswith("-") or flag.startswith("--") or "=" in flag:
|
|
739
|
+
return False
|
|
740
|
+
chars = flag[1:]
|
|
741
|
+
if not chars or chars[-1] != "c":
|
|
742
|
+
return False
|
|
743
|
+
return all(ch in _NO_ARG_SHELL_FLAGS for ch in chars[:-1])
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _is_no_arg_short_cluster(flag: str) -> bool:
|
|
747
|
+
"""True iff ``flag`` is a short-flag cluster of known no-arg flags (``-l``,
|
|
748
|
+
``-i``, ``-xv``…). Such flags consume no following token, so we can skip past
|
|
749
|
+
them when locating ``-c``. Excludes ``-c`` bundles (handled separately) since
|
|
750
|
+
``c`` is not in ``_NO_ARG_SHELL_FLAGS``, long options, and arg-taking flags.
|
|
751
|
+
"""
|
|
752
|
+
if not flag.startswith("-") or flag.startswith("--") or "=" in flag:
|
|
753
|
+
return False
|
|
754
|
+
chars = flag[1:]
|
|
755
|
+
return bool(chars) and all(ch in _NO_ARG_SHELL_FLAGS for ch in chars)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _unwrap_shell_c(segment: Segment) -> tuple[Segment, ...] | None:
|
|
759
|
+
"""``bash -c "ls -la"`` → segments of the inner command. None if the
|
|
760
|
+
wrapper shape is not provably safe to unwrap.
|
|
761
|
+
|
|
762
|
+
Accepts ``-c`` whether bundled (``-lc``, ``-xlc``) or split across preceding
|
|
763
|
+
no-arg short flags (``bash -l -c``, ``zsh -i -x -c``). The command string is
|
|
764
|
+
the token immediately after the ``-c`` flag. Long-option and arg-taking-flag
|
|
765
|
+
forms (``bash --norc -c``, ``bash -O cmdhist -c``, ``zsh -ocorrect``) fall
|
|
766
|
+
through to the native prompt — their arg shapes vary too much to model safely.
|
|
767
|
+
|
|
768
|
+
Tree-sitter string arguments are normalised before this point, so the command
|
|
769
|
+
string is a single argv token. We re-parse it via ``parse_pipeline`` so any
|
|
770
|
+
compound/redirect/control-flow structure inside is faithfully preserved.
|
|
771
|
+
"""
|
|
772
|
+
argv = segment.argv
|
|
773
|
+
if len(argv) < 3 or _basename(argv[0]) not in _SHELL_COMMANDS:
|
|
774
|
+
return None
|
|
775
|
+
idx = 1
|
|
776
|
+
while idx < len(argv) - 1:
|
|
777
|
+
token = argv[idx]
|
|
778
|
+
if _is_safe_c_bundle(token):
|
|
779
|
+
inner = parse_pipeline(argv[idx + 1])
|
|
780
|
+
return inner.segments if inner.parseable else None
|
|
781
|
+
if _is_no_arg_short_cluster(token):
|
|
782
|
+
idx += 1
|
|
783
|
+
continue
|
|
784
|
+
return None
|
|
785
|
+
return None
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _unwrap_exec_wrapper(segment: Segment) -> tuple[Segment, ...]:
|
|
789
|
+
"""``command rm -rf /`` / ``env -i rm -rf /`` → segments of the inner command,
|
|
790
|
+
so a deny rule on it still bites. Returns ``(segment,)`` unchanged when the
|
|
791
|
+
segment is not a decomposable wrapper, or when its options can't be classified
|
|
792
|
+
(then it's flagged at decision time instead — see ``_match_bash``).
|
|
793
|
+
|
|
794
|
+
The inner command is the first token after the wrapper that is neither a known
|
|
795
|
+
no-arg option nor (for ``env``) a ``NAME=value`` assignment. Decomposition
|
|
796
|
+
recurses, so stacked wrappers (``command nice rm -rf /``) fully unwrap.
|
|
797
|
+
"""
|
|
798
|
+
if not segment.argv:
|
|
799
|
+
return (segment,)
|
|
800
|
+
name = _basename(segment.argv[0])
|
|
801
|
+
if name == "eval":
|
|
802
|
+
# ``eval`` joins its args and executes the result as a command — re-parse
|
|
803
|
+
# the joined string like a ``-c`` wrapper. If it isn't statically parseable
|
|
804
|
+
# (e.g. ``eval "$cmd"``), leave it intact for decision-time flagging.
|
|
805
|
+
if len(segment.argv) < 2:
|
|
806
|
+
return (segment,)
|
|
807
|
+
inner = parse_pipeline(" ".join(segment.argv[1:]))
|
|
808
|
+
return inner.segments if inner.parseable else (segment,)
|
|
809
|
+
no_arg = _EXEC_WRAPPER_NO_ARG_OPTS.get(name)
|
|
810
|
+
if no_arg is None:
|
|
811
|
+
return (segment,)
|
|
812
|
+
if _is_command_lookup(segment):
|
|
813
|
+
return (segment,) # `command -v/-V X` resolves X without running it
|
|
814
|
+
argv = segment.argv
|
|
815
|
+
idx = 1
|
|
816
|
+
while idx < len(argv):
|
|
817
|
+
token = argv[idx]
|
|
818
|
+
if token == "--":
|
|
819
|
+
idx += 1
|
|
820
|
+
break
|
|
821
|
+
if _basename(argv[0]) == "env" and _ENV_ASSIGNMENT_RE.match(token):
|
|
822
|
+
idx += 1
|
|
823
|
+
continue
|
|
824
|
+
if token.startswith("-") and len(token) > 1:
|
|
825
|
+
if all(ch in no_arg for ch in token[1:]):
|
|
826
|
+
idx += 1
|
|
827
|
+
continue
|
|
828
|
+
return (segment,) # arg-taking/unknown option — leave intact, flag later
|
|
829
|
+
break
|
|
830
|
+
if idx >= len(argv):
|
|
831
|
+
return (segment,) # wrapper with no inner command (bare ``env`` / ``command``)
|
|
832
|
+
inner = Segment(argv[idx:], segment.redirects)
|
|
833
|
+
unwrapped = _unwrap_shell_c(inner)
|
|
834
|
+
return unwrapped if unwrapped is not None else _unwrap_exec_wrapper(inner)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _is_command_lookup(segment: Segment) -> bool:
|
|
838
|
+
"""True for ``command -v X`` / ``command -V X`` — these resolve ``X`` (like
|
|
839
|
+
``which``) without executing it, so the inner command must not be decomposed
|
|
840
|
+
and policed as if it ran."""
|
|
841
|
+
if not segment.argv or _basename(segment.argv[0]) != "command":
|
|
842
|
+
return False
|
|
843
|
+
for token in segment.argv[1:]:
|
|
844
|
+
if token == "--" or not token.startswith("-"):
|
|
845
|
+
return False
|
|
846
|
+
if "v" in token[1:] or "V" in token[1:]:
|
|
847
|
+
return True
|
|
848
|
+
return False
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _is_opaque_shell_command(segment: Segment) -> bool:
|
|
852
|
+
"""True iff ``segment`` is a shell wrapper (``bash``/``sh``/``zsh``) carrying a
|
|
853
|
+
``-c`` command flag that ``_unwrap_shell_c`` could not safely unwrap — the
|
|
854
|
+
embedded command is hidden, so the segment cannot be analyzed.
|
|
855
|
+
|
|
856
|
+
Unwrappable ``-c`` forms never reach a verdict as a wrapper segment (they were
|
|
857
|
+
expanded into their inner segments upstream), so any ``-c``-bearing shell
|
|
858
|
+
segment seen at decision time is one we declined to unwrap. Plain script /
|
|
859
|
+
interactive invocations (``bash script.sh``, ``zsh -l``) carry no ``-c`` and
|
|
860
|
+
stay NoOpinion.
|
|
861
|
+
|
|
862
|
+
Cluster semantics matter: in a short-flag cluster a ``c`` only means the
|
|
863
|
+
command flag if every preceding char is a no-arg flag. ``-Ocmdhist`` /
|
|
864
|
+
``-ocorrect`` are ``-O``/``-o`` with an *argument* that happens to contain
|
|
865
|
+
``c`` — not a ``-c`` command flag — so they don't count.
|
|
866
|
+
"""
|
|
867
|
+
if not segment.argv or _basename(segment.argv[0]) not in _SHELL_COMMANDS:
|
|
868
|
+
return False
|
|
869
|
+
for token in segment.argv[1:]:
|
|
870
|
+
if token == "--":
|
|
871
|
+
break
|
|
872
|
+
if not token.startswith("-") or token.startswith("--"):
|
|
873
|
+
continue
|
|
874
|
+
for ch in token[1:]:
|
|
875
|
+
if ch == "c":
|
|
876
|
+
return True
|
|
877
|
+
if ch not in _NO_ARG_SHELL_FLAGS:
|
|
878
|
+
break # an arg-taking/unknown flag consumes the rest of the cluster
|
|
879
|
+
return False
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _node_contains_substitution(node: Node) -> bool:
|
|
883
|
+
for child in node.children:
|
|
884
|
+
if child.type in ("command_substitution", "process_substitution"):
|
|
885
|
+
return True
|
|
886
|
+
if _node_contains_substitution(child):
|
|
887
|
+
return True
|
|
888
|
+
return False
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _extract_substitution_segments(node: Node, source: bytes) -> Iterator[Segment]:
|
|
892
|
+
"""Find command/process substitutions in *node* and yield their inner commands as segments."""
|
|
893
|
+
if node.type in ("command_substitution", "process_substitution"):
|
|
894
|
+
for child in node.named_children:
|
|
895
|
+
yield from _extract_segments(child, source)
|
|
896
|
+
return
|
|
897
|
+
for child in node.children:
|
|
898
|
+
yield from _extract_substitution_segments(child, source)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _build_redirect(node: Node, source: bytes) -> tuple[Redirect | None, tuple[str, ...], tuple[Segment, ...]]:
|
|
902
|
+
"""Return the redirect, extra positional words, and any inner substitution segments.
|
|
903
|
+
|
|
904
|
+
tree-sitter-bash will absorb ``b.py`` from ``cmd a 2>/dev/null b.py`` into
|
|
905
|
+
the redirect node as a second ``word`` child, even though bash treats
|
|
906
|
+
``b.py`` as argv to ``cmd``. We take the first ``word``/``number`` after
|
|
907
|
+
the operator as the target and return the rest as spillover for the caller
|
|
908
|
+
to re-attach to the surrounding command.
|
|
909
|
+
|
|
910
|
+
A process-substitution target (``cat < <(rm -rf /)``) is not a file path —
|
|
911
|
+
it's a pipe to a command that runs. The returned ``Redirect`` is ``None`` (no
|
|
912
|
+
file write to police) and the substitution's inner commands are returned as
|
|
913
|
+
segments so a deny rule on them still bites. A command-substitution target
|
|
914
|
+
(``cmd > $(echo f)``), or one nested in a word (``cmd > out$(echo f)``,
|
|
915
|
+
``cmd > "$(echo f)"``), *is* a file path, but computed at runtime: it stays
|
|
916
|
+
the (opaque) target so a write still asks, and its inner command is extracted.
|
|
917
|
+
"""
|
|
918
|
+
fd: int | None = None
|
|
919
|
+
op: str | None = None
|
|
920
|
+
target: str | None = None
|
|
921
|
+
extras: list[str] = []
|
|
922
|
+
substitutions: list[Segment] = []
|
|
923
|
+
for child in node.children:
|
|
924
|
+
if child.type == "file_descriptor":
|
|
925
|
+
fd = int(_node_text(child, source))
|
|
926
|
+
continue
|
|
927
|
+
if child.type in (">", ">>", "<", ">&", "&>", ">|", "&>>", "<&"):
|
|
928
|
+
op = child.type
|
|
929
|
+
continue
|
|
930
|
+
if child.type == "process_substitution":
|
|
931
|
+
substitutions.extend(_extract_substitution_segments(child, source))
|
|
932
|
+
continue
|
|
933
|
+
if child.type == "command_substitution" or _node_contains_substitution(child):
|
|
934
|
+
# A command substitution (bare or nested in a string/concatenation
|
|
935
|
+
# target word): the filename is runtime-computed and unknowable. Keep
|
|
936
|
+
# it as the opaque target so a write still asks, and extract the inner
|
|
937
|
+
# command so a deny rule on it still bites.
|
|
938
|
+
substitutions.extend(_extract_substitution_segments(child, source))
|
|
939
|
+
if target is None:
|
|
940
|
+
target = _node_text(child, source)
|
|
941
|
+
continue
|
|
942
|
+
if child.is_named and child.type in ("word", "number"):
|
|
943
|
+
text = _node_text(child, source)
|
|
944
|
+
if target is None:
|
|
945
|
+
target = text
|
|
946
|
+
else:
|
|
947
|
+
extras.append(text)
|
|
948
|
+
continue
|
|
949
|
+
if op is not None and target is None and substitutions:
|
|
950
|
+
return None, tuple(extras), tuple(substitutions)
|
|
951
|
+
if op is None or target is None:
|
|
952
|
+
raise _UnsupportedShellError("redirect target unparseable")
|
|
953
|
+
return Redirect(fd=fd, op=op, target=target, is_fd_dup=op in (">&", "<&")), tuple(extras), tuple(substitutions)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _argument_text(node: Node, source: bytes) -> str:
|
|
957
|
+
if node.type == "string":
|
|
958
|
+
return _string_text(node, source)
|
|
959
|
+
if node.type == "raw_string":
|
|
960
|
+
# ``raw_string`` is a leaf in tree-sitter-bash (no named children); the
|
|
961
|
+
# body lives in the unnamed bytes between the surrounding single quotes.
|
|
962
|
+
text = _node_text(node, source)
|
|
963
|
+
if len(text) >= 2 and text.startswith("'") and text.endswith("'"):
|
|
964
|
+
return text[1:-1]
|
|
965
|
+
return text
|
|
966
|
+
if node.type == "ansi_c_string":
|
|
967
|
+
# ``$'...'``: strip the ``$'`` prefix and trailing ``'``. Escape sequences
|
|
968
|
+
# aren't interpreted — the literal content is sufficient for argv-prefix
|
|
969
|
+
# rule matching, and not interpreting is the conservative choice.
|
|
970
|
+
text = _node_text(node, source)
|
|
971
|
+
if len(text) >= 3 and text.startswith("$'") and text.endswith("'"):
|
|
972
|
+
return text[2:-1]
|
|
973
|
+
return text
|
|
974
|
+
if node.type in _OPAQUE_ARG_TYPES:
|
|
975
|
+
return _node_text(node, source)
|
|
976
|
+
raise _UnsupportedShellError(f"unsupported argument node {node.type!r}")
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _string_text(node: Node, source: bytes) -> str:
|
|
980
|
+
parts: list[str] = []
|
|
981
|
+
for child in node.named_children:
|
|
982
|
+
if child.type in _STRING_CHILD_TYPES:
|
|
983
|
+
parts.append(_node_text(child, source))
|
|
984
|
+
continue
|
|
985
|
+
raise _UnsupportedShellError(f"unsupported string part {child.type!r}")
|
|
986
|
+
return "".join(parts)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _node_text(node: Node, source: bytes) -> str:
|
|
990
|
+
return source[node.start_byte : node.end_byte].decode()
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
# -----------------------------------------------------------------------------
|
|
994
|
+
# Rule serialization (string/dict <-> Rule)
|
|
995
|
+
# -----------------------------------------------------------------------------
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def parse_rule(raw: JsonValue) -> Rule | None:
|
|
999
|
+
if isinstance(raw, str):
|
|
1000
|
+
return _parse_string_rule(raw)
|
|
1001
|
+
if isinstance(raw, dict):
|
|
1002
|
+
return _parse_dict_rule(raw)
|
|
1003
|
+
return None
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _parse_string_rule(text: str) -> Rule | None:
|
|
1007
|
+
text = text.strip()
|
|
1008
|
+
bash_wildcard = re.fullmatch(r"Bash\((.+):\*\)", text)
|
|
1009
|
+
if bash_wildcard:
|
|
1010
|
+
return BashCommand(tuple(bash_wildcard.group(1).split()), trailing_wildcard=True)
|
|
1011
|
+
bash_exact = re.fullmatch(r"Bash\((.+)\)", text)
|
|
1012
|
+
if bash_exact:
|
|
1013
|
+
return BashCommand(tuple(bash_exact.group(1).split()), trailing_wildcard=False)
|
|
1014
|
+
if text:
|
|
1015
|
+
return NamedTool(text)
|
|
1016
|
+
return None
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _parse_dict_rule(data: JsonObject) -> Rule | None:
|
|
1020
|
+
if data.get("tool") != "Bash":
|
|
1021
|
+
return None
|
|
1022
|
+
commands_raw = data.get("command")
|
|
1023
|
+
if isinstance(commands_raw, str):
|
|
1024
|
+
commands = [commands_raw]
|
|
1025
|
+
elif isinstance(commands_raw, list):
|
|
1026
|
+
commands = [c for c in commands_raw if isinstance(c, str)]
|
|
1027
|
+
else:
|
|
1028
|
+
return None
|
|
1029
|
+
if not commands:
|
|
1030
|
+
return None
|
|
1031
|
+
when = data.get("when")
|
|
1032
|
+
if not isinstance(when, dict):
|
|
1033
|
+
return None
|
|
1034
|
+
options_raw = when.get("hasOption")
|
|
1035
|
+
if isinstance(options_raw, str):
|
|
1036
|
+
options = [options_raw]
|
|
1037
|
+
elif isinstance(options_raw, list):
|
|
1038
|
+
options = [o for o in options_raw if isinstance(o, str)]
|
|
1039
|
+
else:
|
|
1040
|
+
return None
|
|
1041
|
+
if not options:
|
|
1042
|
+
return None
|
|
1043
|
+
reason = data.get("reason")
|
|
1044
|
+
return BashOption(
|
|
1045
|
+
commands=frozenset(commands),
|
|
1046
|
+
options=frozenset(options),
|
|
1047
|
+
rationale=reason if isinstance(reason, str) else "",
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
# -----------------------------------------------------------------------------
|
|
1052
|
+
# Policy I/O
|
|
1053
|
+
# -----------------------------------------------------------------------------
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
class PolicyError(Exception):
|
|
1057
|
+
pass
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
@dataclass(frozen=True)
|
|
1061
|
+
class PolicyFile:
|
|
1062
|
+
"""Round-trips ``.agent-permissions.jsonc`` data, preserving fields we don't model."""
|
|
1063
|
+
|
|
1064
|
+
policy: Policy
|
|
1065
|
+
raw: JsonObject = field(default_factory=dict)
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def load_policy_file(path: Path) -> PolicyFile:
|
|
1069
|
+
text = path.read_text()
|
|
1070
|
+
try:
|
|
1071
|
+
decoded: object = pyjson5.decode(text)
|
|
1072
|
+
except Exception as error:
|
|
1073
|
+
raise PolicyError(f"{path}: invalid JSON/JSONC ({error})") from error
|
|
1074
|
+
data = narrow_json(decoded)
|
|
1075
|
+
if not isinstance(data, dict):
|
|
1076
|
+
raise PolicyError(f"{path}: top-level must be an object")
|
|
1077
|
+
return PolicyFile(policy=_policy_from_dict(data), raw=data)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _policy_from_dict(data: JsonObject) -> Policy:
|
|
1081
|
+
permissions = data.get("permissions")
|
|
1082
|
+
if not isinstance(permissions, dict):
|
|
1083
|
+
return Policy()
|
|
1084
|
+
deny = tuple(_rules_from_list(permissions.get("deny")))
|
|
1085
|
+
ask = tuple(_rules_from_list(permissions.get("ask")))
|
|
1086
|
+
allow = tuple(_rules_from_list(permissions.get("allow")))
|
|
1087
|
+
return Policy(deny=deny, ask=ask, allow=allow)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _rules_from_list(raw: JsonValue) -> Iterator[Rule]:
|
|
1091
|
+
if not isinstance(raw, list):
|
|
1092
|
+
return
|
|
1093
|
+
for item in raw:
|
|
1094
|
+
rule = parse_rule(item)
|
|
1095
|
+
if rule is not None:
|
|
1096
|
+
yield rule
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def save_policy_file(path: Path, policy_file: PolicyFile) -> None:
|
|
1100
|
+
raw: JsonObject = dict(policy_file.raw)
|
|
1101
|
+
raw.setdefault("version", 1)
|
|
1102
|
+
raw["permissions"] = {
|
|
1103
|
+
"allow": [r.serialize() for r in policy_file.policy.allow],
|
|
1104
|
+
"ask": [r.serialize() for r in policy_file.policy.ask],
|
|
1105
|
+
"deny": [r.serialize() for r in policy_file.policy.deny],
|
|
1106
|
+
}
|
|
1107
|
+
_atomic_write(path, json.dumps(raw, indent=2) + "\n")
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def merged_policy(local_root: Path | None) -> Policy:
|
|
1111
|
+
"""Merge global ``~/.agent-permissions.jsonc`` with optional project-local file."""
|
|
1112
|
+
paths: list[Path] = [Path.home() / POLICY_FILENAME]
|
|
1113
|
+
if local_root is not None:
|
|
1114
|
+
candidate = local_root / POLICY_FILENAME
|
|
1115
|
+
if candidate not in paths:
|
|
1116
|
+
paths.append(candidate)
|
|
1117
|
+
policy = Policy()
|
|
1118
|
+
for path in paths:
|
|
1119
|
+
if not path.exists():
|
|
1120
|
+
continue
|
|
1121
|
+
policy = policy.merged_with(load_policy_file(path).policy)
|
|
1122
|
+
return policy
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def project_root(cwd: Path) -> Path:
|
|
1126
|
+
try:
|
|
1127
|
+
output = subprocess.check_output(
|
|
1128
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
1129
|
+
cwd=str(cwd),
|
|
1130
|
+
stderr=subprocess.DEVNULL,
|
|
1131
|
+
text=True,
|
|
1132
|
+
).strip()
|
|
1133
|
+
if output:
|
|
1134
|
+
return Path(output)
|
|
1135
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
1136
|
+
pass
|
|
1137
|
+
return cwd
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def write_default_policy(path: Path) -> None:
|
|
1141
|
+
default: JsonObject = {
|
|
1142
|
+
"version": 1,
|
|
1143
|
+
"permissions": {
|
|
1144
|
+
"allow": [],
|
|
1145
|
+
"ask": [],
|
|
1146
|
+
"deny": [],
|
|
1147
|
+
},
|
|
1148
|
+
}
|
|
1149
|
+
_atomic_write(path, json.dumps(default, indent=2) + "\n")
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
# -----------------------------------------------------------------------------
|
|
1153
|
+
# Agent adapters
|
|
1154
|
+
# -----------------------------------------------------------------------------
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
class AgentAdapter(ABC):
|
|
1158
|
+
name: ClassVar[AgentName]
|
|
1159
|
+
|
|
1160
|
+
def import_native_rules(self) -> Iterator[tuple[Decision, Rule]]:
|
|
1161
|
+
return iter(())
|
|
1162
|
+
|
|
1163
|
+
def parse_event(self, payload: JsonObject, event_name: str) -> Request | None:
|
|
1164
|
+
return None
|
|
1165
|
+
|
|
1166
|
+
def write_verdict(self, verdict: Verdict, event_name: str) -> None:
|
|
1167
|
+
json.dump({}, sys.stdout)
|
|
1168
|
+
|
|
1169
|
+
def install(self, mode: InstallMode, *, dry_run: bool = False) -> list[Path]:
|
|
1170
|
+
"""Wire the bridge into this agent's hook config.
|
|
1171
|
+
|
|
1172
|
+
Returns the list of paths the install touched (or would touch under
|
|
1173
|
+
``dry_run``). An empty list means "already up to date".
|
|
1174
|
+
"""
|
|
1175
|
+
return []
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _mcp_bypass_input(payload: JsonObject) -> JsonObject | None:
|
|
1179
|
+
"""When Claude Code is in bypass mode and the tool call targets an MCP server,
|
|
1180
|
+
return an updated tool input with ``approval-policy: "never"`` so the downstream
|
|
1181
|
+
agent runs full-auto. PreToolUse hooks on the downstream agent still fire, so
|
|
1182
|
+
Deny rules still bite.
|
|
1183
|
+
"""
|
|
1184
|
+
if payload.get("permission_mode") != "bypassPermissions":
|
|
1185
|
+
return None
|
|
1186
|
+
tool_name = payload.get("tool_name")
|
|
1187
|
+
if not isinstance(tool_name, str) or not tool_name.startswith("mcp__codex__"):
|
|
1188
|
+
return None
|
|
1189
|
+
tool_input = payload.get("tool_input")
|
|
1190
|
+
if not isinstance(tool_input, dict):
|
|
1191
|
+
return None
|
|
1192
|
+
return {**tool_input, "approval-policy": "never"}
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _pretooluse_output(decision: Decision, rationale: str) -> JsonObject:
|
|
1196
|
+
return {
|
|
1197
|
+
"hookSpecificOutput": {
|
|
1198
|
+
"hookEventName": "PreToolUse",
|
|
1199
|
+
"permissionDecision": decision.value,
|
|
1200
|
+
"permissionDecisionReason": rationale,
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _permission_request_output(decision: Decision, rationale: str) -> JsonObject:
|
|
1206
|
+
if decision is Decision.Allow:
|
|
1207
|
+
return {"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "allow"}}}
|
|
1208
|
+
if decision is Decision.Deny:
|
|
1209
|
+
return {
|
|
1210
|
+
"hookSpecificOutput": {
|
|
1211
|
+
"hookEventName": "PermissionRequest",
|
|
1212
|
+
"decision": {"behavior": "deny", "message": rationale},
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
return {}
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
# ---------- Claude ----------
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
class ClaudeAdapter(AgentAdapter):
|
|
1222
|
+
name = AgentName.Claude
|
|
1223
|
+
settings_path: ClassVar[Path] = Path.home() / ".claude/settings.json"
|
|
1224
|
+
|
|
1225
|
+
def import_native_rules(self) -> Iterator[tuple[Decision, Rule]]:
|
|
1226
|
+
for path in (self.settings_path, self.settings_path.with_name("settings.local.json")):
|
|
1227
|
+
if not path.exists():
|
|
1228
|
+
continue
|
|
1229
|
+
settings = _read_json(path)
|
|
1230
|
+
permissions = settings.get("permissions")
|
|
1231
|
+
if not isinstance(permissions, dict):
|
|
1232
|
+
continue
|
|
1233
|
+
for decision_key, target_decision in (
|
|
1234
|
+
("deny", Decision.Deny),
|
|
1235
|
+
("ask", Decision.Ask),
|
|
1236
|
+
("allow", Decision.Allow),
|
|
1237
|
+
):
|
|
1238
|
+
raw_list = permissions.get(decision_key)
|
|
1239
|
+
if not isinstance(raw_list, list):
|
|
1240
|
+
continue
|
|
1241
|
+
for raw in raw_list:
|
|
1242
|
+
rule = parse_rule(raw)
|
|
1243
|
+
if rule is not None:
|
|
1244
|
+
yield target_decision, rule
|
|
1245
|
+
|
|
1246
|
+
def parse_event(self, payload: JsonObject, event_name: str) -> Request | None:
|
|
1247
|
+
tool_name = payload.get("tool_name")
|
|
1248
|
+
if not isinstance(tool_name, str):
|
|
1249
|
+
return None
|
|
1250
|
+
if tool_name == "Bash":
|
|
1251
|
+
tool_input = payload.get("tool_input")
|
|
1252
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
1253
|
+
return ShellRequest(parse_pipeline(command if isinstance(command, str) else ""))
|
|
1254
|
+
return ToolRequest(tool_name)
|
|
1255
|
+
|
|
1256
|
+
def write_verdict(
|
|
1257
|
+
self,
|
|
1258
|
+
verdict: Verdict,
|
|
1259
|
+
event_name: str,
|
|
1260
|
+
*,
|
|
1261
|
+
updated_input: JsonObject | None = None,
|
|
1262
|
+
) -> None:
|
|
1263
|
+
if verdict.decision is Decision.NoOpinion:
|
|
1264
|
+
if event_name == "PreToolUse" and updated_input is not None:
|
|
1265
|
+
json.dump(
|
|
1266
|
+
{
|
|
1267
|
+
"hookSpecificOutput": {
|
|
1268
|
+
"hookEventName": "PreToolUse",
|
|
1269
|
+
"updatedInput": updated_input,
|
|
1270
|
+
}
|
|
1271
|
+
},
|
|
1272
|
+
sys.stdout,
|
|
1273
|
+
)
|
|
1274
|
+
return
|
|
1275
|
+
json.dump({}, sys.stdout)
|
|
1276
|
+
return
|
|
1277
|
+
if event_name == "PreToolUse":
|
|
1278
|
+
if verdict.decision is Decision.Deny:
|
|
1279
|
+
json.dump(_pretooluse_output(Decision.Deny, verdict.rationale), sys.stdout)
|
|
1280
|
+
return
|
|
1281
|
+
hook_output: JsonObject = {
|
|
1282
|
+
"hookEventName": "PreToolUse",
|
|
1283
|
+
"permissionDecision": verdict.decision.value,
|
|
1284
|
+
"permissionDecisionReason": verdict.rationale,
|
|
1285
|
+
}
|
|
1286
|
+
if updated_input is not None:
|
|
1287
|
+
hook_output["updatedInput"] = updated_input
|
|
1288
|
+
json.dump({"hookSpecificOutput": hook_output}, sys.stdout)
|
|
1289
|
+
return
|
|
1290
|
+
if event_name == "PermissionRequest":
|
|
1291
|
+
json.dump(_permission_request_output(verdict.decision, verdict.rationale), sys.stdout)
|
|
1292
|
+
return
|
|
1293
|
+
json.dump({}, sys.stdout)
|
|
1294
|
+
|
|
1295
|
+
def install(self, mode: InstallMode, *, dry_run: bool = False) -> list[Path]:
|
|
1296
|
+
# Claude doesn't fire ``PermissionRequest`` — strip any bridge entry that
|
|
1297
|
+
# made it there from an older or third-party installer.
|
|
1298
|
+
if mode is InstallMode.Rulesync:
|
|
1299
|
+
return _merge_rulesync_hooks(
|
|
1300
|
+
block="claudecode",
|
|
1301
|
+
add=[("preToolUse", "PreToolUse", "*")],
|
|
1302
|
+
strip=["permissionRequest"],
|
|
1303
|
+
agent_name="claude",
|
|
1304
|
+
dry_run=dry_run,
|
|
1305
|
+
)
|
|
1306
|
+
return _merge_nested_hooks(
|
|
1307
|
+
self.settings_path,
|
|
1308
|
+
add=[("PreToolUse", "*")],
|
|
1309
|
+
strip=["PermissionRequest"],
|
|
1310
|
+
agent_name="claude",
|
|
1311
|
+
dry_run=dry_run,
|
|
1312
|
+
)
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
# ---------- Codex ----------
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
class CodexAdapter(AgentAdapter):
|
|
1319
|
+
name = AgentName.Codex
|
|
1320
|
+
config_path: ClassVar[Path] = Path.home() / ".codex/config.toml"
|
|
1321
|
+
hooks_path: ClassVar[Path] = Path.home() / ".codex/hooks.json"
|
|
1322
|
+
|
|
1323
|
+
def import_native_rules(self) -> Iterator[tuple[Decision, Rule]]:
|
|
1324
|
+
rules_dir = self.config_path.parent / "rules"
|
|
1325
|
+
if not rules_dir.exists():
|
|
1326
|
+
return
|
|
1327
|
+
for rules_file in sorted(rules_dir.glob("*.rules")):
|
|
1328
|
+
for tokens, decision_text in _parse_codex_prefix_rules(rules_file.read_text()):
|
|
1329
|
+
decision = {"allow": Decision.Allow, "prompt": Decision.Ask, "forbidden": Decision.Deny}.get(
|
|
1330
|
+
decision_text
|
|
1331
|
+
)
|
|
1332
|
+
if decision is None or not tokens:
|
|
1333
|
+
continue
|
|
1334
|
+
yield decision, BashCommand(tuple(tokens))
|
|
1335
|
+
|
|
1336
|
+
def parse_event(self, payload: JsonObject, event_name: str) -> Request | None:
|
|
1337
|
+
if event_name == "PermissionRequest":
|
|
1338
|
+
# Codex 0.128+ ships a Claude-shaped envelope at top level
|
|
1339
|
+
# (``tool_name`` + ``tool_input``). Earlier builds wrapped the
|
|
1340
|
+
# command in ``permission.metadata.command``; we still accept it
|
|
1341
|
+
# for back-compat.
|
|
1342
|
+
permission = payload.get("permission")
|
|
1343
|
+
if isinstance(permission, dict):
|
|
1344
|
+
permission_type = permission.get("type")
|
|
1345
|
+
metadata = permission.get("metadata")
|
|
1346
|
+
if permission_type == "Bash":
|
|
1347
|
+
command = metadata.get("command") if isinstance(metadata, dict) else None
|
|
1348
|
+
return ShellRequest(parse_pipeline(command if isinstance(command, str) else ""))
|
|
1349
|
+
if isinstance(permission_type, str):
|
|
1350
|
+
return ToolRequest(permission_type)
|
|
1351
|
+
return None
|
|
1352
|
+
return ClaudeAdapter().parse_event(payload, event_name)
|
|
1353
|
+
|
|
1354
|
+
def write_verdict(self, verdict: Verdict, event_name: str) -> None:
|
|
1355
|
+
# Codex's two events split responsibilities: PreToolUse is the fast-path
|
|
1356
|
+
# veto (Deny only), PermissionRequest is where we may pre-approve. Allow
|
|
1357
|
+
# / Ask on PreToolUse fall through to Codex's normal flow so the user
|
|
1358
|
+
# still sees a prompt for anything not explicitly denied.
|
|
1359
|
+
if event_name == "PreToolUse":
|
|
1360
|
+
if verdict.decision is Decision.Deny:
|
|
1361
|
+
json.dump(_pretooluse_output(Decision.Deny, verdict.rationale), sys.stdout)
|
|
1362
|
+
return
|
|
1363
|
+
json.dump({}, sys.stdout)
|
|
1364
|
+
return
|
|
1365
|
+
if event_name == "PermissionRequest":
|
|
1366
|
+
if verdict.decision is Decision.Allow:
|
|
1367
|
+
json.dump(_permission_request_output(Decision.Allow, verdict.rationale), sys.stdout)
|
|
1368
|
+
return
|
|
1369
|
+
if verdict.decision is Decision.Deny:
|
|
1370
|
+
json.dump(_permission_request_output(Decision.Deny, verdict.rationale), sys.stdout)
|
|
1371
|
+
return
|
|
1372
|
+
json.dump({}, sys.stdout)
|
|
1373
|
+
|
|
1374
|
+
def install(self, mode: InstallMode, *, dry_run: bool = False) -> list[Path]:
|
|
1375
|
+
if mode is InstallMode.Rulesync:
|
|
1376
|
+
# rulesync owns enabling Codex's hook feature flag; we only emit hook entries.
|
|
1377
|
+
return _merge_rulesync_hooks(
|
|
1378
|
+
block="codexcli",
|
|
1379
|
+
add=[
|
|
1380
|
+
("preToolUse", "PreToolUse", ".*"),
|
|
1381
|
+
("permissionRequest", "PermissionRequest", ".*"),
|
|
1382
|
+
],
|
|
1383
|
+
strip=[],
|
|
1384
|
+
agent_name="codex",
|
|
1385
|
+
dry_run=dry_run,
|
|
1386
|
+
)
|
|
1387
|
+
touched = _merge_nested_hooks(
|
|
1388
|
+
self.hooks_path,
|
|
1389
|
+
add=[("PreToolUse", "Bash"), ("PermissionRequest", "Bash|apply_patch|mcp__.*")],
|
|
1390
|
+
strip=[],
|
|
1391
|
+
agent_name="codex",
|
|
1392
|
+
dry_run=dry_run,
|
|
1393
|
+
)
|
|
1394
|
+
touched.extend(_enable_codex_hooks_feature(self.config_path, dry_run=dry_run))
|
|
1395
|
+
return touched
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def _enable_codex_hooks_feature(path: Path, *, dry_run: bool) -> list[Path]:
|
|
1399
|
+
"""Ensure ``[features] hooks = true`` in ``~/.codex/config.toml``.
|
|
1400
|
+
|
|
1401
|
+
Codex gates hook execution behind this feature flag; the hook entries in
|
|
1402
|
+
``hooks.json`` are inert until it is set. Older Codex versions used
|
|
1403
|
+
``codex_hooks``; migrate that deprecated key away when it is present.
|
|
1404
|
+
"""
|
|
1405
|
+
if path.exists():
|
|
1406
|
+
try:
|
|
1407
|
+
doc = tomlkit.parse(path.read_text())
|
|
1408
|
+
except Exception as error:
|
|
1409
|
+
raise PolicyError(f"{path}: {error}") from error
|
|
1410
|
+
else:
|
|
1411
|
+
doc = tomlkit.document()
|
|
1412
|
+
features = doc.get("features")
|
|
1413
|
+
if not isinstance(features, dict):
|
|
1414
|
+
features = tomlkit.table()
|
|
1415
|
+
doc["features"] = features
|
|
1416
|
+
|
|
1417
|
+
changed = False
|
|
1418
|
+
if features.get("hooks") is not True:
|
|
1419
|
+
features["hooks"] = True
|
|
1420
|
+
changed = True
|
|
1421
|
+
if "codex_hooks" in features:
|
|
1422
|
+
del features["codex_hooks"]
|
|
1423
|
+
changed = True
|
|
1424
|
+
if not changed:
|
|
1425
|
+
return []
|
|
1426
|
+
if not dry_run:
|
|
1427
|
+
_atomic_write(path, tomlkit.dumps(doc))
|
|
1428
|
+
return [path]
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def _parse_codex_prefix_rules(text: str) -> Iterator[tuple[list[str], str]]:
|
|
1432
|
+
for match in re.finditer(r"prefix_rule\((.*?)\)", text, flags=re.DOTALL):
|
|
1433
|
+
body = match.group(1)
|
|
1434
|
+
pattern_match = re.search(r"pattern\s*=\s*\[(.*?)\]", body, flags=re.DOTALL)
|
|
1435
|
+
decision_match = re.search(r"decision\s*=\s*['\"]([^'\"]+)['\"]", body)
|
|
1436
|
+
if pattern_match is None or decision_match is None:
|
|
1437
|
+
continue
|
|
1438
|
+
tokens = re.findall(r"['\"]([^'\"]+)['\"]", pattern_match.group(1))
|
|
1439
|
+
if tokens:
|
|
1440
|
+
yield tokens, decision_match.group(1)
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
# ---------- OpenCode ----------
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
_OPENCODE_PLUGIN_TEMPLATE = """import {{ spawnSync }} from "node:child_process";
|
|
1447
|
+
|
|
1448
|
+
const bridge = {bridge};
|
|
1449
|
+
|
|
1450
|
+
function bridgeDecision(payload) {{
|
|
1451
|
+
const proc = spawnSync(
|
|
1452
|
+
bridge,
|
|
1453
|
+
["check", "--agent", "opencode", "--event", "permission.ask"],
|
|
1454
|
+
{{ input: JSON.stringify(payload), encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }},
|
|
1455
|
+
);
|
|
1456
|
+
if (proc.status !== 0 || !proc.stdout.trim()) return null;
|
|
1457
|
+
try {{ return JSON.parse(proc.stdout); }} catch {{ return null; }}
|
|
1458
|
+
}}
|
|
1459
|
+
|
|
1460
|
+
export const AgentBridgePlugin = async (input) => ({{
|
|
1461
|
+
"permission.ask": async (permission, output) => {{
|
|
1462
|
+
const decision = bridgeDecision({{
|
|
1463
|
+
cwd: input.directory,
|
|
1464
|
+
hook_event_name: "permission.ask",
|
|
1465
|
+
permission,
|
|
1466
|
+
tool_name: permission.type,
|
|
1467
|
+
tool_input: permission.metadata ?? permission,
|
|
1468
|
+
}});
|
|
1469
|
+
if (decision?.status === "allow" || decision?.status === "deny" || decision?.status === "ask") {{
|
|
1470
|
+
output.status = decision.status;
|
|
1471
|
+
}}
|
|
1472
|
+
}},
|
|
1473
|
+
}});
|
|
1474
|
+
"""
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
class OpencodeAdapter(AgentAdapter):
|
|
1478
|
+
name = AgentName.Opencode
|
|
1479
|
+
config_path: ClassVar[Path] = Path.home() / ".config/opencode/opencode.json"
|
|
1480
|
+
plugin_path: ClassVar[Path] = Path.home() / ".config/opencode/plugins/agentperm.js"
|
|
1481
|
+
|
|
1482
|
+
def import_native_rules(self) -> Iterator[tuple[Decision, Rule]]:
|
|
1483
|
+
for path in (self.config_path, self.config_path.with_suffix(".jsonc")):
|
|
1484
|
+
if not path.exists():
|
|
1485
|
+
continue
|
|
1486
|
+
data = _read_json(path)
|
|
1487
|
+
permissions = data.get("permission")
|
|
1488
|
+
if not isinstance(permissions, dict):
|
|
1489
|
+
continue
|
|
1490
|
+
for tool_name, raw_rules in permissions.items():
|
|
1491
|
+
if isinstance(raw_rules, str):
|
|
1492
|
+
rule = _opencode_rule(tool_name, "*")
|
|
1493
|
+
if rule is None:
|
|
1494
|
+
continue
|
|
1495
|
+
decision = _opencode_decision(raw_rules)
|
|
1496
|
+
if decision is not None:
|
|
1497
|
+
yield decision, rule
|
|
1498
|
+
continue
|
|
1499
|
+
if not isinstance(raw_rules, dict):
|
|
1500
|
+
continue
|
|
1501
|
+
for pattern, action in raw_rules.items():
|
|
1502
|
+
if not isinstance(action, str):
|
|
1503
|
+
continue
|
|
1504
|
+
decision = _opencode_decision(action)
|
|
1505
|
+
if decision is None:
|
|
1506
|
+
continue
|
|
1507
|
+
rule = _opencode_rule(tool_name, pattern)
|
|
1508
|
+
if rule is not None:
|
|
1509
|
+
yield decision, rule
|
|
1510
|
+
|
|
1511
|
+
def parse_event(self, payload: JsonObject, event_name: str) -> Request | None:
|
|
1512
|
+
permission = payload.get("permission")
|
|
1513
|
+
if not isinstance(permission, dict):
|
|
1514
|
+
return None
|
|
1515
|
+
permission_type = permission.get("type")
|
|
1516
|
+
metadata_raw = permission.get("metadata")
|
|
1517
|
+
metadata: JsonObject = metadata_raw if isinstance(metadata_raw, dict) else permission
|
|
1518
|
+
if permission_type == "bash":
|
|
1519
|
+
command = metadata.get("command")
|
|
1520
|
+
return ShellRequest(parse_pipeline(command if isinstance(command, str) else ""))
|
|
1521
|
+
if isinstance(permission_type, str):
|
|
1522
|
+
return ToolRequest(permission_type)
|
|
1523
|
+
return None
|
|
1524
|
+
|
|
1525
|
+
def write_verdict(self, verdict: Verdict, event_name: str) -> None:
|
|
1526
|
+
if verdict.decision is Decision.NoOpinion:
|
|
1527
|
+
json.dump({}, sys.stdout)
|
|
1528
|
+
return
|
|
1529
|
+
json.dump({"status": verdict.decision.value, "reason": verdict.rationale}, sys.stdout)
|
|
1530
|
+
|
|
1531
|
+
def install(self, mode: InstallMode, *, dry_run: bool = False) -> list[Path]:
|
|
1532
|
+
"""Always writes the OpenCode plugin shim regardless of ``mode``.
|
|
1533
|
+
|
|
1534
|
+
rulesync has no ``permission.ask`` plugin emitter — there is no schema for
|
|
1535
|
+
it — so the plugin is always installed directly. The plugin embeds the
|
|
1536
|
+
absolute path to ``agentperm`` resolved at install time, JSON-quoted
|
|
1537
|
+
so paths containing backslashes or quotes survive interpolation into a JS
|
|
1538
|
+
string literal.
|
|
1539
|
+
"""
|
|
1540
|
+
bridge_literal = json.dumps(_resolve_bridge_command())
|
|
1541
|
+
contents = _OPENCODE_PLUGIN_TEMPLATE.format(bridge=bridge_literal)
|
|
1542
|
+
if self.plugin_path.exists() and self.plugin_path.read_text() == contents:
|
|
1543
|
+
return []
|
|
1544
|
+
if not dry_run:
|
|
1545
|
+
_atomic_write(self.plugin_path, contents)
|
|
1546
|
+
return [self.plugin_path]
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _opencode_rule(tool: str, pattern: str) -> Rule | None:
|
|
1550
|
+
if tool == "bash":
|
|
1551
|
+
if pattern == "*":
|
|
1552
|
+
return None
|
|
1553
|
+
return BashCommand(tuple(pattern.split()))
|
|
1554
|
+
return NamedTool(
|
|
1555
|
+
{
|
|
1556
|
+
"read": "Read",
|
|
1557
|
+
"grep": "Grep",
|
|
1558
|
+
"glob": "Glob",
|
|
1559
|
+
"edit": "Edit",
|
|
1560
|
+
"write": "Write",
|
|
1561
|
+
"webfetch": "WebFetch",
|
|
1562
|
+
"websearch": "WebSearch",
|
|
1563
|
+
"task": "Task",
|
|
1564
|
+
"skill": "Skill",
|
|
1565
|
+
}.get(tool, tool)
|
|
1566
|
+
)
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def _opencode_decision(action: str) -> Decision | None:
|
|
1570
|
+
return {"allow": Decision.Allow, "ask": Decision.Ask, "deny": Decision.Deny}.get(action)
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
# ---------- Gemini ----------
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
class GeminiAdapter(AgentAdapter):
|
|
1577
|
+
name = AgentName.Gemini
|
|
1578
|
+
settings_path: ClassVar[Path] = Path.home() / ".gemini/settings.json"
|
|
1579
|
+
|
|
1580
|
+
def parse_event(self, payload: JsonObject, event_name: str) -> Request | None:
|
|
1581
|
+
tool_name = payload.get("tool_name")
|
|
1582
|
+
if not isinstance(tool_name, str):
|
|
1583
|
+
return None
|
|
1584
|
+
tool_input = payload.get("tool_input")
|
|
1585
|
+
if tool_name == "run_shell_command":
|
|
1586
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
1587
|
+
return ShellRequest(parse_pipeline(command if isinstance(command, str) else ""))
|
|
1588
|
+
return ToolRequest(_gemini_tool_name(tool_name))
|
|
1589
|
+
|
|
1590
|
+
def write_verdict(self, verdict: Verdict, event_name: str) -> None:
|
|
1591
|
+
if verdict.decision is Decision.NoOpinion:
|
|
1592
|
+
json.dump({}, sys.stdout)
|
|
1593
|
+
return
|
|
1594
|
+
if verdict.decision is Decision.Ask:
|
|
1595
|
+
json.dump({"decision": "deny", "reason": f"approval required: {verdict.rationale}"}, sys.stdout)
|
|
1596
|
+
return
|
|
1597
|
+
json.dump({"decision": verdict.decision.value, "reason": verdict.rationale}, sys.stdout)
|
|
1598
|
+
|
|
1599
|
+
def install(self, mode: InstallMode, *, dry_run: bool = False) -> list[Path]:
|
|
1600
|
+
# rulesync's ``geminicli.preToolUse`` block is materialised as Gemini's
|
|
1601
|
+
# ``BeforeTool`` hook by rulesync itself; the bridge command embedded in
|
|
1602
|
+
# the entry uses ``--event BeforeTool`` since that's what Gemini fires
|
|
1603
|
+
# at runtime. Direct mode writes the same nested-group shape into
|
|
1604
|
+
# ``hooks.BeforeTool`` of ``settings.json`` with the same event arg.
|
|
1605
|
+
if mode is InstallMode.Rulesync:
|
|
1606
|
+
return _merge_rulesync_hooks(
|
|
1607
|
+
block="geminicli",
|
|
1608
|
+
add=[("preToolUse", "BeforeTool", ".*")],
|
|
1609
|
+
strip=[],
|
|
1610
|
+
agent_name="gemini",
|
|
1611
|
+
dry_run=dry_run,
|
|
1612
|
+
)
|
|
1613
|
+
return _merge_nested_hooks(
|
|
1614
|
+
self.settings_path,
|
|
1615
|
+
add=[("BeforeTool", ".*")],
|
|
1616
|
+
strip=[],
|
|
1617
|
+
agent_name="gemini",
|
|
1618
|
+
dry_run=dry_run,
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _gemini_tool_name(name: str) -> str:
|
|
1623
|
+
return {
|
|
1624
|
+
"glob": "Glob",
|
|
1625
|
+
"grep_search": "Grep",
|
|
1626
|
+
"read_file": "Read",
|
|
1627
|
+
"read_many_files": "Read",
|
|
1628
|
+
"list_directory": "LS",
|
|
1629
|
+
"web_fetch": "WebFetch",
|
|
1630
|
+
"google_web_search": "WebSearch",
|
|
1631
|
+
"replace": "Edit",
|
|
1632
|
+
"write_file": "Write",
|
|
1633
|
+
}.get(name, name)
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
_GEMINI_TOOL_NAMES = frozenset(
|
|
1637
|
+
{
|
|
1638
|
+
"run_shell_command",
|
|
1639
|
+
"glob",
|
|
1640
|
+
"grep_search",
|
|
1641
|
+
"read_file",
|
|
1642
|
+
"read_many_files",
|
|
1643
|
+
"list_directory",
|
|
1644
|
+
"web_fetch",
|
|
1645
|
+
"google_web_search",
|
|
1646
|
+
"replace",
|
|
1647
|
+
"write_file",
|
|
1648
|
+
}
|
|
1649
|
+
)
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
ADAPTERS: dict[AgentName, AgentAdapter] = {
|
|
1653
|
+
AgentName.Claude: ClaudeAdapter(),
|
|
1654
|
+
AgentName.Codex: CodexAdapter(),
|
|
1655
|
+
AgentName.Opencode: OpencodeAdapter(),
|
|
1656
|
+
AgentName.Gemini: GeminiAdapter(),
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
# -----------------------------------------------------------------------------
|
|
1661
|
+
# Hook config helpers (used by install)
|
|
1662
|
+
# -----------------------------------------------------------------------------
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
BRIDGE_HOOK_MARKER = "agentperm"
|
|
1666
|
+
|
|
1667
|
+
# Per-agent hook timeouts. Claude/Codex use seconds; Gemini uses milliseconds.
|
|
1668
|
+
_HOOK_TIMEOUTS: dict[str, int] = {
|
|
1669
|
+
"claude": 30,
|
|
1670
|
+
"codex": 30,
|
|
1671
|
+
"gemini": 30000,
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def _resolve_bridge_command() -> str:
|
|
1676
|
+
"""Return the absolute path to ``agentperm`` if findable.
|
|
1677
|
+
|
|
1678
|
+
GUI-launched OpenCode (Raycast/Spotlight) inherits a sparse ``PATH``; baking
|
|
1679
|
+
the resolved absolute path eliminates a class of silent ``ENOENT`` bugs. Falls
|
|
1680
|
+
back to the bare name if nothing is on ``PATH`` at install time, with a stderr
|
|
1681
|
+
warning so the user knows runtime PATH lookup is in play.
|
|
1682
|
+
"""
|
|
1683
|
+
resolved = shutil.which(BRIDGE_HOOK_MARKER)
|
|
1684
|
+
if resolved:
|
|
1685
|
+
return resolved
|
|
1686
|
+
print(
|
|
1687
|
+
f"warning: '{BRIDGE_HOOK_MARKER}' not on PATH at install time; "
|
|
1688
|
+
f"hooks will rely on runtime PATH",
|
|
1689
|
+
file=sys.stderr,
|
|
1690
|
+
)
|
|
1691
|
+
return BRIDGE_HOOK_MARKER
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def _bridge_command_string(agent: str, event: str) -> str:
|
|
1695
|
+
"""Build the shell-safe bridge invocation embedded in hook configs.
|
|
1696
|
+
|
|
1697
|
+
Quotes the resolved path so spaces or shell metacharacters in the install
|
|
1698
|
+
location can't break the command line or smuggle arguments. Agent and event
|
|
1699
|
+
are constrained internally so they need no quoting, but we shlex-quote them
|
|
1700
|
+
anyway as a defensive habit.
|
|
1701
|
+
"""
|
|
1702
|
+
return " ".join(
|
|
1703
|
+
shlex.quote(part)
|
|
1704
|
+
for part in (_resolve_bridge_command(), "check", "--agent", agent, "--event", event)
|
|
1705
|
+
)
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
def _hook_group(matcher: str, *, agent: str, event: str, status_message: str | None = None) -> JsonObject:
|
|
1709
|
+
"""Build a Claude/Codex/Gemini-style nested ``{matcher, hooks: [...]}`` group."""
|
|
1710
|
+
hook: JsonObject = {
|
|
1711
|
+
"type": "command",
|
|
1712
|
+
"command": _bridge_command_string(agent, event),
|
|
1713
|
+
"timeout": _HOOK_TIMEOUTS[agent],
|
|
1714
|
+
}
|
|
1715
|
+
if status_message is not None:
|
|
1716
|
+
hook["statusMessage"] = status_message
|
|
1717
|
+
return {"matcher": matcher, "hooks": [hook]}
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def _rulesync_entry(agent: str, event: str, matcher: str) -> JsonObject:
|
|
1721
|
+
"""Build a flat rulesync-style hook entry."""
|
|
1722
|
+
return {
|
|
1723
|
+
"type": "command",
|
|
1724
|
+
"command": _bridge_command_string(agent, event),
|
|
1725
|
+
"matcher": matcher,
|
|
1726
|
+
"timeout": _HOOK_TIMEOUTS[agent],
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def _is_bridge_hook(hook: JsonValue) -> bool:
|
|
1731
|
+
"""True iff this entry is one the bridge's installer wrote.
|
|
1732
|
+
|
|
1733
|
+
Matches strictly on shape: the command must split into a binary whose
|
|
1734
|
+
basename is ``agentperm`` followed by ``check``. This avoids
|
|
1735
|
+
false-stripping unrelated wrappers whose paths happen to contain the
|
|
1736
|
+
substring ``agentperm``.
|
|
1737
|
+
"""
|
|
1738
|
+
if not isinstance(hook, dict):
|
|
1739
|
+
return False
|
|
1740
|
+
command = hook.get("command")
|
|
1741
|
+
if not isinstance(command, str) or not command.strip():
|
|
1742
|
+
return False
|
|
1743
|
+
try:
|
|
1744
|
+
parts = shlex.split(command, posix=True)
|
|
1745
|
+
except ValueError:
|
|
1746
|
+
return False
|
|
1747
|
+
if len(parts) < 2:
|
|
1748
|
+
return False
|
|
1749
|
+
return Path(parts[0]).name == BRIDGE_HOOK_MARKER and parts[1] == "check"
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def _strip_bridge_groups(groups: JsonArray) -> JsonArray:
|
|
1753
|
+
"""Remove bridge entries from nested ``{matcher, hooks: [...]}`` groups.
|
|
1754
|
+
|
|
1755
|
+
Drops groups whose hooks list is left empty; preserves all non-bridge entries
|
|
1756
|
+
untouched. Idempotency guarantee: re-running ``install`` produces no churn.
|
|
1757
|
+
"""
|
|
1758
|
+
kept: JsonArray = []
|
|
1759
|
+
for group in groups:
|
|
1760
|
+
if not isinstance(group, dict):
|
|
1761
|
+
kept.append(group)
|
|
1762
|
+
continue
|
|
1763
|
+
hooks = group.get("hooks")
|
|
1764
|
+
if not isinstance(hooks, list):
|
|
1765
|
+
kept.append(group)
|
|
1766
|
+
continue
|
|
1767
|
+
remaining: JsonArray = [hook for hook in hooks if not _is_bridge_hook(hook)]
|
|
1768
|
+
if not remaining:
|
|
1769
|
+
continue
|
|
1770
|
+
kept.append({**group, "hooks": remaining})
|
|
1771
|
+
return kept
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def _strip_bridge_entries(entries: JsonArray) -> JsonArray:
|
|
1775
|
+
"""Remove bridge entries from a flat rulesync-style entry list."""
|
|
1776
|
+
return [entry for entry in entries if not _is_bridge_hook(entry)]
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def _section(parent: JsonObject, key: str) -> JsonObject:
|
|
1780
|
+
value = parent.get(key)
|
|
1781
|
+
if isinstance(value, dict):
|
|
1782
|
+
return value
|
|
1783
|
+
section: JsonObject = {}
|
|
1784
|
+
parent[key] = section
|
|
1785
|
+
return section
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def _ensure_list(parent: JsonObject, key: str) -> JsonArray:
|
|
1789
|
+
value = parent.get(key)
|
|
1790
|
+
if isinstance(value, list):
|
|
1791
|
+
return value
|
|
1792
|
+
new_list: JsonArray = []
|
|
1793
|
+
parent[key] = new_list
|
|
1794
|
+
return new_list
|
|
1795
|
+
|
|
1796
|
+
|
|
1797
|
+
def _rulesync_hooks_path() -> Path:
|
|
1798
|
+
return Path.home() / ".rulesync/hooks.json"
|
|
1799
|
+
|
|
1800
|
+
|
|
1801
|
+
def _write_json_if_changed(path: Path, before: JsonObject, after: JsonObject, *, dry_run: bool) -> list[Path]:
|
|
1802
|
+
"""Atomic write iff ``after`` differs structurally from ``before``."""
|
|
1803
|
+
if after == before:
|
|
1804
|
+
return []
|
|
1805
|
+
if not dry_run:
|
|
1806
|
+
_atomic_write(path, json.dumps(after, indent=2) + "\n")
|
|
1807
|
+
return [path]
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
def _merge_rulesync_hooks(
|
|
1811
|
+
*,
|
|
1812
|
+
block: str,
|
|
1813
|
+
add: list[tuple[str, str, str]],
|
|
1814
|
+
strip: list[str],
|
|
1815
|
+
agent_name: str,
|
|
1816
|
+
dry_run: bool,
|
|
1817
|
+
) -> list[Path]:
|
|
1818
|
+
"""Merge bridge entries into ``~/.rulesync/hooks.json`` for one agent block.
|
|
1819
|
+
|
|
1820
|
+
``add`` is a list of ``(rulesync_key, bridge_event, matcher)`` triples. The
|
|
1821
|
+
``rulesync_key`` (camelCase) is where rulesync materialises the hook into the
|
|
1822
|
+
per-tool config; ``bridge_event`` is the per-tool event name the bridge will
|
|
1823
|
+
receive at runtime (e.g. rulesync's ``preToolUse`` for Gemini maps to
|
|
1824
|
+
``BeforeTool``, so we embed ``--event BeforeTool``). ``strip`` removes stale
|
|
1825
|
+
bridge entries (e.g. Claude doesn't fire ``permissionRequest``).
|
|
1826
|
+
"""
|
|
1827
|
+
path = _rulesync_hooks_path()
|
|
1828
|
+
before = _read_json(path)
|
|
1829
|
+
after: JsonObject = json.loads(json.dumps(before))
|
|
1830
|
+
after.setdefault("version", 1)
|
|
1831
|
+
agent_section = _section(after, block)
|
|
1832
|
+
hooks = _section(agent_section, "hooks")
|
|
1833
|
+
for rulesync_key, bridge_event, matcher in add:
|
|
1834
|
+
entries = _strip_bridge_entries(_ensure_list(hooks, rulesync_key))
|
|
1835
|
+
entries.append(_rulesync_entry(agent_name, bridge_event, matcher))
|
|
1836
|
+
hooks[rulesync_key] = entries
|
|
1837
|
+
for event_name in strip:
|
|
1838
|
+
if event_name in hooks:
|
|
1839
|
+
current = hooks[event_name]
|
|
1840
|
+
if isinstance(current, list):
|
|
1841
|
+
stripped = _strip_bridge_entries(current)
|
|
1842
|
+
if stripped:
|
|
1843
|
+
hooks[event_name] = stripped
|
|
1844
|
+
else:
|
|
1845
|
+
del hooks[event_name]
|
|
1846
|
+
return _write_json_if_changed(path, before, after, dry_run=dry_run)
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
def _merge_nested_hooks(
|
|
1850
|
+
path: Path,
|
|
1851
|
+
*,
|
|
1852
|
+
add: list[tuple[str, str]],
|
|
1853
|
+
strip: list[str],
|
|
1854
|
+
agent_name: str,
|
|
1855
|
+
dry_run: bool,
|
|
1856
|
+
) -> list[Path]:
|
|
1857
|
+
"""Merge bridge groups into a Claude-style ``hooks.<Event>`` config file.
|
|
1858
|
+
|
|
1859
|
+
The schema is the nested ``[{matcher, hooks: [...]}]`` group form used by
|
|
1860
|
+
Claude ``settings.json``, Codex ``hooks.json``, and Gemini ``settings.json``.
|
|
1861
|
+
Each entry in ``add`` is ``(event_name, matcher)``; the embedded bridge
|
|
1862
|
+
invocation uses ``event_name`` as the ``--event`` argument since direct-mode
|
|
1863
|
+
keys are the per-tool event names.
|
|
1864
|
+
"""
|
|
1865
|
+
before = _read_json(path)
|
|
1866
|
+
after: JsonObject = json.loads(json.dumps(before))
|
|
1867
|
+
hooks_section = _section(after, "hooks")
|
|
1868
|
+
for event_name, matcher in add:
|
|
1869
|
+
groups = _strip_bridge_groups(_ensure_list(hooks_section, event_name))
|
|
1870
|
+
groups.append(_hook_group(matcher, agent=agent_name, event=event_name))
|
|
1871
|
+
hooks_section[event_name] = groups
|
|
1872
|
+
for event_name in strip:
|
|
1873
|
+
if event_name in hooks_section:
|
|
1874
|
+
current = hooks_section[event_name]
|
|
1875
|
+
if isinstance(current, list):
|
|
1876
|
+
stripped = _strip_bridge_groups(current)
|
|
1877
|
+
if stripped:
|
|
1878
|
+
hooks_section[event_name] = stripped
|
|
1879
|
+
else:
|
|
1880
|
+
del hooks_section[event_name]
|
|
1881
|
+
return _write_json_if_changed(path, before, after, dry_run=dry_run)
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
# -----------------------------------------------------------------------------
|
|
1885
|
+
# File helpers
|
|
1886
|
+
# -----------------------------------------------------------------------------
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
def _read_json(path: Path) -> JsonObject:
|
|
1890
|
+
if not path.exists():
|
|
1891
|
+
return {}
|
|
1892
|
+
try:
|
|
1893
|
+
decoded: object = pyjson5.decode(path.read_text())
|
|
1894
|
+
except Exception as error:
|
|
1895
|
+
raise PolicyError(f"{path}: {error}") from error
|
|
1896
|
+
narrowed = narrow_json(decoded)
|
|
1897
|
+
return narrowed if isinstance(narrowed, dict) else {}
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
1901
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1902
|
+
with tempfile.NamedTemporaryFile("w", delete=False, dir=str(path.parent)) as handle:
|
|
1903
|
+
handle.write(text)
|
|
1904
|
+
tmp_path = Path(handle.name)
|
|
1905
|
+
tmp_path.replace(path)
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
# -----------------------------------------------------------------------------
|
|
1909
|
+
# CLI
|
|
1910
|
+
# -----------------------------------------------------------------------------
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def _package_version() -> str:
|
|
1914
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
1915
|
+
|
|
1916
|
+
try:
|
|
1917
|
+
return version("agentperm")
|
|
1918
|
+
except PackageNotFoundError:
|
|
1919
|
+
return "0+unknown"
|
|
1920
|
+
|
|
1921
|
+
|
|
1922
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1923
|
+
_load_dotenv()
|
|
1924
|
+
parser = argparse.ArgumentParser(prog="agentperm")
|
|
1925
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {_package_version()}")
|
|
1926
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1927
|
+
|
|
1928
|
+
install = sub.add_parser("install", help="wire the bridge into agent hook configs")
|
|
1929
|
+
install.add_argument(
|
|
1930
|
+
"--mode",
|
|
1931
|
+
choices=["auto", "rulesync", "direct"],
|
|
1932
|
+
default="auto",
|
|
1933
|
+
help="auto: detect rulesync; rulesync: write ~/.rulesync/hooks.json; direct: per-tool configs",
|
|
1934
|
+
)
|
|
1935
|
+
install.add_argument(
|
|
1936
|
+
"--dry-run",
|
|
1937
|
+
action="store_true",
|
|
1938
|
+
help="print would-be writes without modifying files",
|
|
1939
|
+
)
|
|
1940
|
+
|
|
1941
|
+
sub.add_parser("import", help="pull native allow/ask/deny rules into ~/.agent-permissions.jsonc")
|
|
1942
|
+
|
|
1943
|
+
check = sub.add_parser("check", help="runtime decision; reads stdin, writes stdout")
|
|
1944
|
+
check.add_argument("--agent", required=True, choices=[a.value for a in AgentName])
|
|
1945
|
+
check.add_argument("--event", required=True)
|
|
1946
|
+
|
|
1947
|
+
sub.add_parser("edit", help="open the policy file in $EDITOR (creates a default if missing)")
|
|
1948
|
+
|
|
1949
|
+
args = parser.parse_args(argv)
|
|
1950
|
+
|
|
1951
|
+
if args.command == "install":
|
|
1952
|
+
return _cmd_install(mode=args.mode, dry_run=args.dry_run)
|
|
1953
|
+
if args.command == "import":
|
|
1954
|
+
return _cmd_import()
|
|
1955
|
+
if args.command == "check":
|
|
1956
|
+
return _cmd_check(AgentName(args.agent), args.event)
|
|
1957
|
+
if args.command == "edit":
|
|
1958
|
+
return _cmd_edit()
|
|
1959
|
+
parser.error(f"unknown command {args.command}")
|
|
1960
|
+
return 2
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
def _resolve_install_mode(mode: str) -> InstallMode:
|
|
1964
|
+
if mode == "rulesync":
|
|
1965
|
+
if not (Path.home() / ".rulesync").exists():
|
|
1966
|
+
raise PolicyError("--mode rulesync requires ~/.rulesync/ to exist")
|
|
1967
|
+
return InstallMode.Rulesync
|
|
1968
|
+
if mode == "direct":
|
|
1969
|
+
return InstallMode.Direct
|
|
1970
|
+
return InstallMode.Rulesync if (Path.home() / ".rulesync").exists() else InstallMode.Direct
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
def _cmd_install(*, mode: str, dry_run: bool) -> int:
|
|
1974
|
+
try:
|
|
1975
|
+
resolved_mode = _resolve_install_mode(mode)
|
|
1976
|
+
except PolicyError as error:
|
|
1977
|
+
print(str(error), file=sys.stderr)
|
|
1978
|
+
return 2
|
|
1979
|
+
print(f"mode: {resolved_mode.value}{' (dry-run)' if dry_run else ''}")
|
|
1980
|
+
failed = False
|
|
1981
|
+
for adapter in ADAPTERS.values():
|
|
1982
|
+
try:
|
|
1983
|
+
touched = adapter.install(resolved_mode, dry_run=dry_run)
|
|
1984
|
+
except Exception as error:
|
|
1985
|
+
print(f"{adapter.name.value}: failed ({error})", file=sys.stderr)
|
|
1986
|
+
failed = True
|
|
1987
|
+
continue
|
|
1988
|
+
if not touched:
|
|
1989
|
+
print(f"{adapter.name.value}: up to date")
|
|
1990
|
+
continue
|
|
1991
|
+
verb = "would write" if dry_run else "wrote"
|
|
1992
|
+
for path in touched:
|
|
1993
|
+
print(f"{adapter.name.value}: {verb} {path}")
|
|
1994
|
+
return 1 if failed else 0
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
def _cmd_import() -> int:
|
|
1998
|
+
policy_path = Path.home() / POLICY_FILENAME
|
|
1999
|
+
if not policy_path.exists():
|
|
2000
|
+
write_default_policy(policy_path)
|
|
2001
|
+
policy_file = load_policy_file(policy_path)
|
|
2002
|
+
seen: set[tuple[Decision, Rule]] = {(d, r) for d, r in policy_file.policy.all_rules()}
|
|
2003
|
+
new_by_decision: dict[Decision, list[Rule]] = {Decision.Allow: [], Decision.Ask: [], Decision.Deny: []}
|
|
2004
|
+
for adapter in ADAPTERS.values():
|
|
2005
|
+
for decision, rule in adapter.import_native_rules():
|
|
2006
|
+
key = (decision, rule)
|
|
2007
|
+
if key in seen:
|
|
2008
|
+
continue
|
|
2009
|
+
seen.add(key)
|
|
2010
|
+
new_by_decision[decision].append(rule)
|
|
2011
|
+
if not any(new_by_decision.values()):
|
|
2012
|
+
print("no new rules")
|
|
2013
|
+
return 0
|
|
2014
|
+
updated = Policy(
|
|
2015
|
+
deny=policy_file.policy.deny + tuple(new_by_decision[Decision.Deny]),
|
|
2016
|
+
ask=policy_file.policy.ask + tuple(new_by_decision[Decision.Ask]),
|
|
2017
|
+
allow=policy_file.policy.allow + tuple(new_by_decision[Decision.Allow]),
|
|
2018
|
+
)
|
|
2019
|
+
save_policy_file(policy_path, PolicyFile(updated, policy_file.raw))
|
|
2020
|
+
for decision, rules in new_by_decision.items():
|
|
2021
|
+
for rule in rules:
|
|
2022
|
+
print(f"+{decision.value} {rule.serialize()!r}")
|
|
2023
|
+
return 0
|
|
2024
|
+
|
|
2025
|
+
|
|
2026
|
+
def _cmd_check(agent: AgentName, event: str) -> int:
|
|
2027
|
+
try:
|
|
2028
|
+
raw_payload: object = json.load(sys.stdin)
|
|
2029
|
+
except json.JSONDecodeError:
|
|
2030
|
+
_trace(agent, event, None, None, "json decode failed")
|
|
2031
|
+
json.dump({}, sys.stdout)
|
|
2032
|
+
return 0
|
|
2033
|
+
try:
|
|
2034
|
+
payload_value = narrow_json(raw_payload)
|
|
2035
|
+
except PolicyError:
|
|
2036
|
+
_trace(agent, event, None, None, "payload narrow failed")
|
|
2037
|
+
json.dump({}, sys.stdout)
|
|
2038
|
+
return 0
|
|
2039
|
+
if not isinstance(payload_value, dict):
|
|
2040
|
+
_trace(agent, event, None, None, "payload not object")
|
|
2041
|
+
json.dump({}, sys.stdout)
|
|
2042
|
+
return 0
|
|
2043
|
+
payload: JsonObject = payload_value
|
|
2044
|
+
event = _effective_event(event, payload)
|
|
2045
|
+
adapter = _select_adapter(agent, event, payload)
|
|
2046
|
+
request = adapter.parse_event(payload, event)
|
|
2047
|
+
if request is None:
|
|
2048
|
+
_trace(agent, event, payload, None, "request unparseable")
|
|
2049
|
+
json.dump({}, sys.stdout)
|
|
2050
|
+
return 0
|
|
2051
|
+
cwd_value = payload.get("cwd")
|
|
2052
|
+
cwd = Path(cwd_value) if isinstance(cwd_value, str) else Path(os.getcwd())
|
|
2053
|
+
try:
|
|
2054
|
+
policy = merged_policy(local_root=project_root(cwd))
|
|
2055
|
+
except PolicyError as error:
|
|
2056
|
+
# Fail closed: prompt rather than auto-allow when the policy file is broken.
|
|
2057
|
+
_trace(agent, event, payload, None, f"policy load failed: {error}")
|
|
2058
|
+
adapter.write_verdict(Verdict(Decision.Ask, f"policy load failed: {error}"), event)
|
|
2059
|
+
return 0
|
|
2060
|
+
verdict = policy.decide(request)
|
|
2061
|
+
verdict = coerce_for_permission_mode(verdict, payload)
|
|
2062
|
+
verdict, coercion = coerce_for_pane_bypass(verdict, os.environ)
|
|
2063
|
+
_trace(agent, event, payload, verdict, None, coercion)
|
|
2064
|
+
if isinstance(adapter, ClaudeAdapter):
|
|
2065
|
+
adapter.write_verdict(verdict, event, updated_input=_mcp_bypass_input(payload))
|
|
2066
|
+
else:
|
|
2067
|
+
adapter.write_verdict(verdict, event)
|
|
2068
|
+
return 0
|
|
2069
|
+
|
|
2070
|
+
|
|
2071
|
+
def _effective_event(event: str, payload: JsonObject) -> str:
|
|
2072
|
+
if event != "auto":
|
|
2073
|
+
return event
|
|
2074
|
+
payload_event = payload.get("hook_event_name")
|
|
2075
|
+
return payload_event if isinstance(payload_event, str) else event
|
|
2076
|
+
|
|
2077
|
+
|
|
2078
|
+
def _select_adapter(agent: AgentName, event: str, payload: JsonObject) -> AgentAdapter:
|
|
2079
|
+
if agent is not AgentName.Auto:
|
|
2080
|
+
return ADAPTERS[agent]
|
|
2081
|
+
if event in ("BeforeTool", "AfterTool"):
|
|
2082
|
+
return ADAPTERS[AgentName.Gemini]
|
|
2083
|
+
tool_name = payload.get("tool_name")
|
|
2084
|
+
if isinstance(tool_name, str) and tool_name in _GEMINI_TOOL_NAMES:
|
|
2085
|
+
return ADAPTERS[AgentName.Gemini]
|
|
2086
|
+
if event == "PermissionRequest" and isinstance(payload.get("permission"), dict):
|
|
2087
|
+
return ADAPTERS[AgentName.Codex]
|
|
2088
|
+
if event == "PermissionRequest":
|
|
2089
|
+
return ADAPTERS[AgentName.Claude]
|
|
2090
|
+
if event in ("permission.ask", "permission.asked"):
|
|
2091
|
+
return ADAPTERS[AgentName.Opencode]
|
|
2092
|
+
return ADAPTERS[AgentName.Claude]
|
|
2093
|
+
|
|
2094
|
+
|
|
2095
|
+
def _load_dotenv() -> None:
|
|
2096
|
+
"""Merge ``<repo>/.env`` into ``os.environ`` for development debugging.
|
|
2097
|
+
|
|
2098
|
+
Resolves ``.env`` three levels above this file (the repo root for editable
|
|
2099
|
+
installs); silently does nothing if it is missing or unreadable. Existing
|
|
2100
|
+
environment variables win so the process environment can still override.
|
|
2101
|
+
"""
|
|
2102
|
+
env_path = Path(__file__).resolve().parent.parent.parent / ".env"
|
|
2103
|
+
try:
|
|
2104
|
+
text = env_path.read_text()
|
|
2105
|
+
except OSError:
|
|
2106
|
+
return
|
|
2107
|
+
for raw in text.splitlines():
|
|
2108
|
+
line = raw.strip()
|
|
2109
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
2110
|
+
continue
|
|
2111
|
+
key, _, value = line.partition("=")
|
|
2112
|
+
key = key.strip()
|
|
2113
|
+
value = value.strip()
|
|
2114
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
2115
|
+
value = value[1:-1]
|
|
2116
|
+
if key and key not in os.environ:
|
|
2117
|
+
os.environ[key] = value
|
|
2118
|
+
|
|
2119
|
+
|
|
2120
|
+
def _trace(
|
|
2121
|
+
agent: AgentName,
|
|
2122
|
+
event: str,
|
|
2123
|
+
payload: JsonObject | None,
|
|
2124
|
+
verdict: Verdict | None,
|
|
2125
|
+
note: str | None,
|
|
2126
|
+
coercion: Coercion | None = None,
|
|
2127
|
+
) -> None:
|
|
2128
|
+
"""Append one JSON line per invocation to ``$AGENTPERM_TRACE`` if set.
|
|
2129
|
+
|
|
2130
|
+
Off by default. Set the env var to a writable path to enable — either in the
|
|
2131
|
+
process environment or in ``<repo>/.env`` (loaded by ``_load_dotenv`` from
|
|
2132
|
+
``main``). Used to debug whether the bridge is actually being called for a
|
|
2133
|
+
given command.
|
|
2134
|
+
"""
|
|
2135
|
+
target = os.environ.get("AGENTPERM_TRACE")
|
|
2136
|
+
if not target:
|
|
2137
|
+
return
|
|
2138
|
+
record: JsonObject = {
|
|
2139
|
+
"agent": agent.value,
|
|
2140
|
+
"event": event,
|
|
2141
|
+
"payload": payload,
|
|
2142
|
+
"note": note,
|
|
2143
|
+
}
|
|
2144
|
+
if verdict is not None:
|
|
2145
|
+
record["verdict"] = {"decision": verdict.decision.value, "rationale": verdict.rationale}
|
|
2146
|
+
if coercion is not None:
|
|
2147
|
+
record["coercion"] = {
|
|
2148
|
+
"by": coercion.by,
|
|
2149
|
+
"pane_id": coercion.pane_id,
|
|
2150
|
+
"session": coercion.session,
|
|
2151
|
+
"original_decision": coercion.original.decision.value,
|
|
2152
|
+
"original_rationale": coercion.original.rationale,
|
|
2153
|
+
}
|
|
2154
|
+
try:
|
|
2155
|
+
with open(target, "a") as fh:
|
|
2156
|
+
fh.write(json.dumps(record) + "\n")
|
|
2157
|
+
except OSError:
|
|
2158
|
+
pass
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
def coerce_for_permission_mode(verdict: Verdict, payload: JsonObject) -> Verdict:
|
|
2162
|
+
"""Under Claude's ``bypassPermissions`` mode, agentperm defers entirely.
|
|
2163
|
+
|
|
2164
|
+
Claude fires ``PreToolUse`` hooks even in bypass mode, but the user has explicitly opted
|
|
2165
|
+
out of permission checks — so the bridge stays out of the way: it returns ``NoOpinion``
|
|
2166
|
+
(an empty ``{}`` envelope) and lets Claude's native bypass proceed. The Claude write path
|
|
2167
|
+
still attaches any MCP-bypass ``updatedInput`` (so bypass propagates to a downstream Codex
|
|
2168
|
+
MCP tool). Pane bypass and non-bypass modes are unaffected.
|
|
2169
|
+
"""
|
|
2170
|
+
if payload.get("permission_mode") == "bypassPermissions":
|
|
2171
|
+
return Verdict(Decision.NoOpinion, "bypass: deferring to host")
|
|
2172
|
+
return verdict
|
|
2173
|
+
|
|
2174
|
+
|
|
2175
|
+
# -----------------------------------------------------------------------------
|
|
2176
|
+
# Per-pane bypass (zellij plugin writes the flag file; agentperm reads it)
|
|
2177
|
+
# -----------------------------------------------------------------------------
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
@dataclass(frozen=True)
|
|
2181
|
+
class Coercion:
|
|
2182
|
+
"""Structured trace metadata for a coerced verdict.
|
|
2183
|
+
|
|
2184
|
+
Captures which mechanism overrode the original decision so the trace log
|
|
2185
|
+
records both the policy's actual answer and the override that suppressed it.
|
|
2186
|
+
"""
|
|
2187
|
+
|
|
2188
|
+
by: str
|
|
2189
|
+
pane_id: str | None
|
|
2190
|
+
session: str | None
|
|
2191
|
+
original: Verdict
|
|
2192
|
+
|
|
2193
|
+
|
|
2194
|
+
def agentperm_bypass_dir(env: Mapping[str, str]) -> Path:
|
|
2195
|
+
"""Resolve the per-pane bypass cache dir, honoring ``XDG_CACHE_HOME``.
|
|
2196
|
+
|
|
2197
|
+
The plugin (writer) and agentperm (reader) must agree on this path; both
|
|
2198
|
+
derive it through this same helper / the same XDG semantics in the plugin.
|
|
2199
|
+
"""
|
|
2200
|
+
base = env.get("XDG_CACHE_HOME") or str(Path(env.get("HOME", str(Path.home()))) / ".cache")
|
|
2201
|
+
return Path(base) / "agentperm" / "bypass"
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
def _bypass_dir_is_safe(path: Path) -> bool:
|
|
2205
|
+
"""True iff the bypass dir is missing OR is owned by current uid and not group/world-writable.
|
|
2206
|
+
|
|
2207
|
+
A missing dir is safe: no flag file can exist, so the bypass check is a no-op.
|
|
2208
|
+
Refusing a g/o-writable dir means another local user cannot drop a flag file
|
|
2209
|
+
that grants themselves silent permission inside our policy mediator.
|
|
2210
|
+
On Windows ``os.getuid`` is absent; the uid check is skipped (different security
|
|
2211
|
+
model) and we still reject if the mode bits indicate world-writable.
|
|
2212
|
+
"""
|
|
2213
|
+
try:
|
|
2214
|
+
st = path.stat()
|
|
2215
|
+
except FileNotFoundError:
|
|
2216
|
+
return True
|
|
2217
|
+
except OSError:
|
|
2218
|
+
return False
|
|
2219
|
+
if hasattr(os, "getuid") and st.st_uid != os.getuid():
|
|
2220
|
+
return False
|
|
2221
|
+
return not st.st_mode & 0o022
|
|
2222
|
+
|
|
2223
|
+
|
|
2224
|
+
def coerce_for_pane_bypass(
|
|
2225
|
+
verdict: Verdict,
|
|
2226
|
+
env: Mapping[str, str],
|
|
2227
|
+
) -> tuple[Verdict, Coercion | None]:
|
|
2228
|
+
"""If the current zellij pane has a bypass flag file, suppress Ask/NoOpinion. Deny still bites.
|
|
2229
|
+
|
|
2230
|
+
Pane is identified by ``(ZELLIJ_SESSION_NAME, ZELLIJ_PANE_ID)`` — both inherited
|
|
2231
|
+
from the zellij pane the agent runs inside. Flag file:
|
|
2232
|
+
``<agentperm_bypass_dir>/<session>/<pane_id>``. Presence = bypass on.
|
|
2233
|
+
|
|
2234
|
+
``NoOpinion`` is coerced too: codex's ``PermissionRequest`` adapter falls
|
|
2235
|
+
through to ``{}`` on ``NoOpinion`` in ``CodexAdapter.write_verdict``, which causes codex to prompt —
|
|
2236
|
+
so the bypass must cover it for "approve everything I haven't denied" to hold.
|
|
2237
|
+
"""
|
|
2238
|
+
if verdict.decision not in (Decision.Ask, Decision.NoOpinion):
|
|
2239
|
+
return verdict, None
|
|
2240
|
+
pane_id = env.get("ZELLIJ_PANE_ID")
|
|
2241
|
+
session = env.get("ZELLIJ_SESSION_NAME")
|
|
2242
|
+
if not pane_id or not session:
|
|
2243
|
+
return verdict, None
|
|
2244
|
+
if any(bad in pane_id or bad in session for bad in ("/", "\\", "..", "\0")):
|
|
2245
|
+
return verdict, None
|
|
2246
|
+
base = agentperm_bypass_dir(env)
|
|
2247
|
+
if not _bypass_dir_is_safe(base):
|
|
2248
|
+
return verdict, None
|
|
2249
|
+
if not (base / session / pane_id).exists():
|
|
2250
|
+
return verdict, None
|
|
2251
|
+
coerced = Verdict(Decision.Allow, f"pane bypass: {verdict.rationale}")
|
|
2252
|
+
return coerced, Coercion(
|
|
2253
|
+
by="zellij_pane_bypass",
|
|
2254
|
+
pane_id=pane_id,
|
|
2255
|
+
session=session,
|
|
2256
|
+
original=verdict,
|
|
2257
|
+
)
|
|
2258
|
+
|
|
2259
|
+
|
|
2260
|
+
def _cmd_edit() -> int:
|
|
2261
|
+
path = Path.home() / POLICY_FILENAME
|
|
2262
|
+
if not path.exists():
|
|
2263
|
+
write_default_policy(path)
|
|
2264
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or _default_editor()
|
|
2265
|
+
return subprocess.call([editor, str(path)])
|
|
2266
|
+
|
|
2267
|
+
|
|
2268
|
+
def _default_editor() -> str:
|
|
2269
|
+
for candidate in ("nvim", "vim", "vi", "nano"):
|
|
2270
|
+
if shutil.which(candidate):
|
|
2271
|
+
return candidate
|
|
2272
|
+
return "vi"
|
|
2273
|
+
|
|
2274
|
+
|
|
2275
|
+
if __name__ == "__main__":
|
|
2276
|
+
sys.exit(main())
|