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())