hx-cli 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.
Files changed (64) hide show
  1. hx/__init__.py +5 -0
  2. hx/agents/__init__.py +1 -0
  3. hx/agents/definitions.py +106 -0
  4. hx/agents/subagent.py +190 -0
  5. hx/cli.py +667 -0
  6. hx/config.py +277 -0
  7. hx/core/__init__.py +1 -0
  8. hx/core/compaction.py +245 -0
  9. hx/core/context.py +271 -0
  10. hx/core/events.py +183 -0
  11. hx/core/lateinject.py +121 -0
  12. hx/core/loop.py +537 -0
  13. hx/core/messages.py +164 -0
  14. hx/core/session.py +208 -0
  15. hx/core/usage.py +129 -0
  16. hx/frontmatter.py +80 -0
  17. hx/mcp/__init__.py +1 -0
  18. hx/mcp/client.py +319 -0
  19. hx/mcp/manager.py +265 -0
  20. hx/paths.py +90 -0
  21. hx/permissions/__init__.py +7 -0
  22. hx/permissions/engine.py +406 -0
  23. hx/permissions/parser.py +306 -0
  24. hx/permissions/sandbox.py +227 -0
  25. hx/providers/__init__.py +1 -0
  26. hx/providers/base.py +77 -0
  27. hx/providers/fake.py +87 -0
  28. hx/providers/models.py +238 -0
  29. hx/providers/openrouter.py +468 -0
  30. hx/skills/__init__.py +1 -0
  31. hx/skills/loader.py +102 -0
  32. hx/skills/runtime.py +84 -0
  33. hx/tools/__init__.py +1 -0
  34. hx/tools/base.py +97 -0
  35. hx/tools/bash.py +544 -0
  36. hx/tools/edit.py +167 -0
  37. hx/tools/glob.py +75 -0
  38. hx/tools/grep.py +165 -0
  39. hx/tools/output.py +133 -0
  40. hx/tools/read.py +142 -0
  41. hx/tools/registry.py +149 -0
  42. hx/tools/task.py +76 -0
  43. hx/tools/todo.py +149 -0
  44. hx/tools/write.py +87 -0
  45. hx/tui/__init__.py +1 -0
  46. hx/tui/app.py +487 -0
  47. hx/tui/commands.py +399 -0
  48. hx/tui/hx.tcss +197 -0
  49. hx/tui/renderers.py +570 -0
  50. hx/tui/theme.py +322 -0
  51. hx/tui/widgets/__init__.py +1 -0
  52. hx/tui/widgets/configure.py +95 -0
  53. hx/tui/widgets/diff.py +25 -0
  54. hx/tui/widgets/input.py +145 -0
  55. hx/tui/widgets/palette.py +130 -0
  56. hx/tui/widgets/permission.py +97 -0
  57. hx/tui/widgets/statusbar.py +212 -0
  58. hx/tui/widgets/todos.py +116 -0
  59. hx/tui/widgets/transcript.py +316 -0
  60. hx/tui/widgets/working.py +78 -0
  61. hx_cli-0.1.0.dist-info/METADATA +430 -0
  62. hx_cli-0.1.0.dist-info/RECORD +64 -0
  63. hx_cli-0.1.0.dist-info/WHEEL +4 -0
  64. hx_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,406 @@
1
+ """Permission rule engine.
2
+
3
+ Rules are ``Tool(specifier)`` strings - ``Bash(git commit:*)``, ``Edit(src/**)``,
4
+ ``Read(~/.ssh/**)``. Evaluation order is deny, then ask, then allow: an explicit
5
+ deny can never be overridden by a broader allow, including in bypass mode.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Awaitable, Callable
12
+ from dataclasses import dataclass
13
+ from enum import StrEnum
14
+ from pathlib import Path
15
+ from typing import Any, ClassVar
16
+
17
+ from hx.config import PermissionMode
18
+ from hx.permissions.parser import CommandSegment, is_read_only, match_specifier, parse
19
+
20
+
21
+ class Decision(StrEnum):
22
+ ALLOW = "allow"
23
+ ASK = "ask"
24
+ DENY = "deny"
25
+
26
+
27
+ class GrantScope(StrEnum):
28
+ ONCE = "once"
29
+ SESSION = "session"
30
+ ALWAYS = "always"
31
+ """Persisted to project settings.json."""
32
+
33
+
34
+ DEFAULT_MUTATING_TOOLS = frozenset({"Bash", "Write", "Edit", "MultiEdit", "Task", "NotebookEdit"})
35
+ """Fallback when the caller does not supply the registry's own metadata."""
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class Rule:
40
+ tool: str
41
+ specifier: str | None
42
+ decision: Decision
43
+ source: str
44
+ """Where the rule came from, so ``/permissions`` can show it."""
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class PermissionRequest:
49
+ tool_name: str
50
+ specifier: str | None
51
+ params: dict[str, Any]
52
+ mutating: bool
53
+ description: str
54
+ detail: str = ""
55
+ """Rendered diff or command text shown in the approval modal."""
56
+ origin: str | None = None
57
+ """Which subagent asked, when it was not the main conversation."""
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class PermissionOutcome:
62
+ decision: Decision
63
+ reason: str
64
+ matched_rule: Rule | None = None
65
+
66
+
67
+ @dataclass(slots=True)
68
+ class PermissionAnswer:
69
+ allowed: bool
70
+ scope: GrantScope = GrantScope.ONCE
71
+
72
+
73
+ Asker = Callable[[PermissionRequest], Awaitable[PermissionAnswer]]
74
+ """Supplied by the frontend. Absent means nothing can answer an ASK."""
75
+
76
+
77
+ class PermissionEngine:
78
+ #: Modes that never prompt for a file edit.
79
+ EDIT_TOOLS: ClassVar[frozenset[str]] = frozenset({"Write", "Edit", "MultiEdit", "NotebookEdit"})
80
+
81
+ def __init__(
82
+ self,
83
+ mode: PermissionMode,
84
+ rules: list[Rule],
85
+ cwd: Path,
86
+ asker: Asker | None = None,
87
+ ) -> None:
88
+ self.mode = mode
89
+ self.rules = list(rules)
90
+ self.cwd = cwd
91
+ self.asker = asker
92
+ self._session_grants: set[tuple[str, str | None]] = set()
93
+
94
+ # --- decision ---------------------------------------------------------
95
+
96
+ def evaluate(self, request: PermissionRequest) -> PermissionOutcome:
97
+ """Pure decision - no prompting, no I/O. Ordering: deny, ask, allow, then
98
+ the mode default.
99
+
100
+ For Bash the specifier is decomposed by ``hx.permissions.parser`` and every
101
+ segment must pass; an unparseable command degrades to ASK.
102
+ """
103
+ if denial := self._match(request, Decision.DENY):
104
+ return PermissionOutcome(Decision.DENY, "matched a deny rule", denial)
105
+
106
+ if request.tool_name == "Bash" and request.specifier:
107
+ parsed = parse(request.specifier)
108
+ if parsed.unparseable:
109
+ return PermissionOutcome(
110
+ Decision.ASK,
111
+ "command could not be decomposed; approving it blind is not safe",
112
+ )
113
+
114
+ if (request.tool_name, request.specifier) in self._session_grants:
115
+ return PermissionOutcome(Decision.ALLOW, "granted for this session")
116
+
117
+ if ask_rule := self._match(request, Decision.ASK):
118
+ return PermissionOutcome(Decision.ASK, "matched an ask rule", ask_rule)
119
+
120
+ if allow_rule := self._match_allow(request):
121
+ return PermissionOutcome(Decision.ALLOW, "matched an allow rule", allow_rule)
122
+
123
+ return self._mode_default(request)
124
+
125
+ def _mode_default(self, request: PermissionRequest) -> PermissionOutcome:
126
+ if self.mode is PermissionMode.BYPASS:
127
+ return PermissionOutcome(Decision.ALLOW, "bypass mode")
128
+
129
+ if self.mode is PermissionMode.PLAN:
130
+ if request.mutating:
131
+ return PermissionOutcome(
132
+ Decision.DENY, "plan mode is read-only; no writes or commands"
133
+ )
134
+ return PermissionOutcome(Decision.ALLOW, "read-only tool in plan mode")
135
+
136
+ if not request.mutating:
137
+ return PermissionOutcome(Decision.ALLOW, "read-only tool")
138
+
139
+ if self.mode is PermissionMode.ACCEPT_EDITS and request.tool_name in self.EDIT_TOOLS:
140
+ return PermissionOutcome(Decision.ALLOW, "acceptEdits mode")
141
+
142
+ if request.tool_name == "Bash" and request.specifier:
143
+ parsed = parse(request.specifier)
144
+ if parsed.segments and all(is_read_only(s) for s in parsed.segments):
145
+ if parsed.has_redirect_out:
146
+ return PermissionOutcome(Decision.ASK, "command redirects output to a file")
147
+ return PermissionOutcome(Decision.ALLOW, "read-only command")
148
+
149
+ return PermissionOutcome(Decision.ASK, "mutating tool")
150
+
151
+ def _match(self, request: PermissionRequest, decision: Decision) -> Rule | None:
152
+ for rule in self.rules:
153
+ if rule.decision is not decision or rule.tool not in {request.tool_name, "*"}:
154
+ continue
155
+ if rule.specifier is None or self._specifier_matches(request, rule.specifier):
156
+ return rule
157
+ return None
158
+
159
+ def _match_allow(self, request: PermissionRequest) -> Rule | None:
160
+ """Allow requires *every* segment to be covered.
161
+
162
+ Any-segment matching is right for deny (``safe && danger`` must be
163
+ caught) but catastrophic for allow: an ``allow: Bash(git status:*)``
164
+ rule would green-light ``git status && rm -rf /``. Each segment must
165
+ find its own allow rule, substitutions included.
166
+ """
167
+ candidates = [
168
+ rule
169
+ for rule in self.rules
170
+ if rule.decision is Decision.ALLOW and rule.tool in {request.tool_name, "*"}
171
+ ]
172
+ if not candidates:
173
+ return None
174
+
175
+ if request.tool_name != "Bash" or not request.specifier:
176
+ for rule in candidates:
177
+ if rule.specifier is None or self._specifier_matches(request, rule.specifier):
178
+ return rule
179
+ return None
180
+
181
+ parsed = parse(request.specifier)
182
+ if not parsed.segments or parsed.unparseable:
183
+ return None
184
+
185
+ first: Rule | None = None
186
+ for segment in parsed.segments:
187
+ covering = next(
188
+ (
189
+ rule
190
+ for rule in candidates
191
+ if rule.specifier is None or match_specifier(segment, rule.specifier)
192
+ ),
193
+ None,
194
+ )
195
+ if covering is None:
196
+ return None
197
+ first = first or covering
198
+ return first
199
+
200
+ def _specifier_matches(self, request: PermissionRequest, pattern: str) -> bool:
201
+ if not request.specifier:
202
+ return False
203
+
204
+ if request.tool_name == "Bash":
205
+ # Deny and ask: one dangerous segment is enough to trigger.
206
+ parsed = parse(request.specifier)
207
+ return any(match_specifier(segment, pattern) for segment in parsed.segments)
208
+
209
+ return match_path(request.specifier, pattern, self.cwd)
210
+
211
+ # --- prompting --------------------------------------------------------
212
+
213
+ async def request(self, request: PermissionRequest) -> tuple[bool, str]:
214
+ """Evaluate and, on ASK, prompt through :attr:`asker`.
215
+
216
+ Returns ``(allowed, reason)``. The reason is handed to the model on a
217
+ denial so it can adapt, instead of guessing why a tool went silent.
218
+ """
219
+ outcome = self.evaluate(request)
220
+
221
+ if outcome.decision is Decision.ALLOW:
222
+ return True, outcome.reason
223
+ if outcome.decision is Decision.DENY:
224
+ return False, outcome.reason
225
+ if self.asker is None:
226
+ # Nothing can answer, so the only safe reading of ASK is "no".
227
+ return False, (
228
+ "this tool call needs approval, but this session has no way to ask "
229
+ "(non-interactive). Run hx interactively, or add a permission rule."
230
+ )
231
+
232
+ answer = await self.asker(request)
233
+ if answer.allowed:
234
+ self.grant(request, answer.scope)
235
+ return True, "approved by the user"
236
+ return False, "the user declined"
237
+
238
+ def grant(self, request: PermissionRequest, scope: GrantScope) -> None:
239
+ """Record an approval. ``ALWAYS`` writes a rule into project settings."""
240
+ if scope is GrantScope.ONCE:
241
+ return
242
+
243
+ self._session_grants.add((request.tool_name, request.specifier))
244
+ if scope is not GrantScope.ALWAYS:
245
+ return
246
+
247
+ specifier = self._persistable_specifier(request)
248
+ rule_text = f"{request.tool_name}({specifier})" if specifier else request.tool_name
249
+ self.rules.append(parse_rule(rule_text, "project settings", Decision.ALLOW))
250
+ persist_allow_rule(rule_text, self.cwd)
251
+
252
+ def _persistable_specifier(self, request: PermissionRequest) -> str | None:
253
+ """Generalise a one-off approval into a rule worth keeping.
254
+
255
+ A Bash approval becomes a prefix rule for that executable and
256
+ subcommand, never the exact argument vector - otherwise "always allow"
257
+ would be useless on the very next invocation.
258
+ """
259
+ if not request.specifier:
260
+ return None
261
+ if request.tool_name != "Bash":
262
+ return request.specifier
263
+
264
+ parsed = parse(request.specifier)
265
+ if len(parsed.segments) != 1:
266
+ return request.specifier
267
+ segment = parsed.segments[0]
268
+ head = _rule_prefix(segment)
269
+ return f"{head}:*"
270
+
271
+ # --- modes ------------------------------------------------------------
272
+
273
+ def set_mode(self, mode: PermissionMode) -> None:
274
+ self.mode = mode
275
+
276
+ def allowed_tools(self, all_tools: list[str], mutating: set[str] | None = None) -> set[str]:
277
+ """Tools exposed in the current mode - plan mode hides mutating tools
278
+ entirely rather than letting the model call them and be refused."""
279
+ mutating_set = mutating if mutating is not None else set(DEFAULT_MUTATING_TOOLS)
280
+ if self.mode is not PermissionMode.PLAN:
281
+ return set(all_tools)
282
+ return {name for name in all_tools if name not in mutating_set}
283
+
284
+
285
+ def _rule_prefix(segment: CommandSegment) -> str:
286
+ """``git commit -m x`` -> ``git commit``; ``ls -la`` -> ``ls``."""
287
+ if segment.args and not segment.args[0].startswith("-"):
288
+ return f"{segment.executable} {segment.args[0]}"
289
+ return segment.executable
290
+
291
+
292
+ _RULE_RE = re.compile(r"^(?P<tool>[A-Za-z_][\w-]*|\*)(?:\((?P<spec>.*)\))?$", re.DOTALL)
293
+
294
+
295
+ def parse_rule(text: str, source: str, decision: Decision) -> Rule:
296
+ """Parse ``Tool(specifier)`` or bare ``Tool``.
297
+
298
+ Raises:
299
+ InvalidRule: on malformed syntax - a rule that silently fails to parse is
300
+ a rule that silently does not protect anything.
301
+ """
302
+ match = _RULE_RE.match(text.strip())
303
+ if match is None:
304
+ raise InvalidRule(f"malformed permission rule: {text!r}")
305
+ specifier = match.group("spec")
306
+ return Rule(
307
+ tool=match.group("tool"),
308
+ specifier=specifier.strip() if specifier else None,
309
+ decision=decision,
310
+ source=source,
311
+ )
312
+
313
+
314
+ def match_path(candidate: str, pattern: str, cwd: Path) -> bool:
315
+ """Glob-match a path specifier, with ``**`` spanning directory separators."""
316
+ path = Path(candidate).expanduser()
317
+ absolute = path if path.is_absolute() else (cwd / path)
318
+ expanded = Path(pattern).expanduser()
319
+ target = expanded if expanded.is_absolute() else (cwd / expanded)
320
+
321
+ regex = _glob_regex(str(target))
322
+ if regex.match(str(absolute)):
323
+ return True
324
+
325
+ # Also match against the cwd-relative form, so `Edit(src/**)` works.
326
+ try:
327
+ relative = absolute.relative_to(cwd)
328
+ except ValueError:
329
+ return False
330
+ return bool(_glob_regex(pattern).match(str(relative)))
331
+
332
+
333
+ def _glob_regex(pattern: str) -> re.Pattern[str]:
334
+ """Translate a glob to a regex.
335
+
336
+ ``**`` crosses directory separators and ``*`` does not, and crucially
337
+ ``**/`` also matches *zero* directories - so ``**/.ssh/**`` protects
338
+ ``.ssh/id_rsa`` sitting directly in the project, not only nested copies of
339
+ it. ``fnmatch`` cannot express either rule.
340
+ """
341
+ out: list[str] = []
342
+ index = 0
343
+ while index < len(pattern):
344
+ if pattern.startswith("**/", index):
345
+ out.append("(?:[\\s\\S]*/)?")
346
+ index += 3
347
+ elif pattern.startswith("**", index):
348
+ out.append("[\\s\\S]*")
349
+ index += 2
350
+ elif pattern[index] == "*":
351
+ out.append("[^/]*")
352
+ index += 1
353
+ elif pattern[index] == "?":
354
+ out.append("[^/]")
355
+ index += 1
356
+ elif pattern[index] == "[":
357
+ close = pattern.find("]", index + 1)
358
+ if close == -1:
359
+ out.append(re.escape("["))
360
+ index += 1
361
+ else:
362
+ out.append(pattern[index : close + 1])
363
+ index = close + 1
364
+ else:
365
+ out.append(re.escape(pattern[index]))
366
+ index += 1
367
+ return re.compile("".join(out) + r"\Z")
368
+
369
+
370
+ def load_rules(cwd: Path) -> list[Rule]:
371
+ """Collect rules from user and project settings, project last."""
372
+ from hx.config import read_settings_file
373
+ from hx.paths import project_settings_file, user_settings_file
374
+
375
+ rules: list[Rule] = []
376
+ for path, source in (
377
+ (user_settings_file(), "user settings"),
378
+ (project_settings_file(cwd), "project settings"),
379
+ ):
380
+ permissions = read_settings_file(path).get("permissions") or {}
381
+ for key, decision in (
382
+ ("deny", Decision.DENY),
383
+ ("ask", Decision.ASK),
384
+ ("allow", Decision.ALLOW),
385
+ ):
386
+ for text in permissions.get(key) or []:
387
+ rules.append(parse_rule(str(text), source, decision))
388
+ return rules
389
+
390
+
391
+ def persist_allow_rule(rule_text: str, cwd: Path) -> None:
392
+ """Append an allow rule to the project settings file."""
393
+ from hx.config import read_settings_file, write_settings_file
394
+ from hx.paths import project_settings_file
395
+
396
+ path = project_settings_file(cwd)
397
+ data = read_settings_file(path)
398
+ permissions = data.setdefault("permissions", {})
399
+ allow = permissions.setdefault("allow", [])
400
+ if rule_text not in allow:
401
+ allow.append(rule_text)
402
+ write_settings_file(path, data)
403
+
404
+
405
+ class InvalidRule(Exception):
406
+ pass
@@ -0,0 +1,306 @@
1
+ """Shell command decomposition for permission matching.
2
+
3
+ Matching a rule against the whole command string is unsafe: ``git status && rm
4
+ -rf /`` would match an ``allow: Bash(git status:*)`` rule. Every command is
5
+ split into its individual executed segments and each segment must pass on its
6
+ own.
7
+
8
+ This is a permission-matching aid, not a shell. When it cannot decompose a
9
+ command with confidence it says so, and the caller must treat that as "ask".
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import shlex
15
+ from dataclasses import dataclass
16
+
17
+ OPERATORS = ("&&", "||", ";;", ";", "|&", "|", "\n", "&")
18
+
19
+ DYNAMIC_EXECUTABLES = frozenset({"eval", "exec", "source", ".", "env"})
20
+ """Commands whose real payload is decided at runtime. They cannot be decomposed,
21
+ so a command invoking one is reported unparseable rather than guessed at."""
22
+
23
+ REDIRECT_TOKENS = (">>", ">", "<")
24
+
25
+ READ_ONLY_COMMANDS = frozenset(
26
+ {
27
+ "ls",
28
+ "cat",
29
+ "head",
30
+ "tail",
31
+ "wc",
32
+ "file",
33
+ "stat",
34
+ "du",
35
+ "df",
36
+ "pwd",
37
+ "echo",
38
+ "printf",
39
+ "date",
40
+ "whoami",
41
+ "uname",
42
+ "which",
43
+ "type",
44
+ "grep",
45
+ "rg",
46
+ "find",
47
+ "fd",
48
+ "diff",
49
+ "tree",
50
+ "basename",
51
+ "dirname",
52
+ "sort",
53
+ "uniq",
54
+ "cut",
55
+ "awk",
56
+ "sed",
57
+ "jq",
58
+ "column",
59
+ }
60
+ )
61
+ """Commands that do not mutate state on their own. Used only to relax prompting."""
62
+
63
+ READ_ONLY_SUBCOMMANDS = {
64
+ "git": frozenset({"status", "log", "diff", "show", "branch", "remote", "blame", "describe"}),
65
+ }
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class CommandSegment:
70
+ """One command that will actually execute."""
71
+
72
+ raw: str
73
+ executable: str
74
+ args: tuple[str, ...]
75
+ in_substitution: bool = False
76
+ """True for ``$(...)`` / backtick content, which executes just as readily."""
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class ParsedCommand:
81
+ raw: str
82
+ segments: tuple[CommandSegment, ...]
83
+ has_redirect_out: bool = False
84
+ redirect_targets: tuple[str, ...] = ()
85
+ unparseable: bool = False
86
+ """Set when the command could not be decomposed with confidence. Callers must
87
+ treat this as "ask", never as "allow"."""
88
+
89
+
90
+ def parse(command: str) -> ParsedCommand:
91
+ """Split on ``&&``, ``||``, ``;``, ``|``, newlines, and command substitution,
92
+ respecting quoting.
93
+
94
+ Anything not understood sets ``unparseable`` rather than degrading to a
95
+ naive split - a wrong split here is a real escape.
96
+ """
97
+ try:
98
+ pieces, substitutions, ok = _scan(command)
99
+ except _ScanError:
100
+ return ParsedCommand(raw=command, segments=(), unparseable=True)
101
+
102
+ segments: list[CommandSegment] = []
103
+ redirects: list[str] = []
104
+ unparseable = not ok
105
+
106
+ for text, in_sub in [*[(p, False) for p in pieces], *[(s, True) for s in substitutions]]:
107
+ segment, targets, segment_ok = _to_segment(text, in_sub)
108
+ redirects.extend(targets)
109
+ if not segment_ok:
110
+ unparseable = True
111
+ if segment is not None:
112
+ segments.append(segment)
113
+ if segment.executable in DYNAMIC_EXECUTABLES:
114
+ unparseable = True
115
+
116
+ return ParsedCommand(
117
+ raw=command,
118
+ segments=tuple(segments),
119
+ has_redirect_out=bool(redirects),
120
+ redirect_targets=tuple(redirects),
121
+ unparseable=unparseable or not segments,
122
+ )
123
+
124
+
125
+ def _to_segment(text: str, in_sub: bool) -> tuple[CommandSegment | None, list[str], bool]:
126
+ stripped = text.strip()
127
+ if not stripped:
128
+ return None, [], True
129
+
130
+ try:
131
+ tokens = shlex.split(stripped)
132
+ except ValueError:
133
+ return None, [], False
134
+ if not tokens:
135
+ return None, [], True
136
+
137
+ words: list[str] = []
138
+ redirects: list[str] = []
139
+ index = 0
140
+ while index < len(tokens):
141
+ token = tokens[index]
142
+ if token in REDIRECT_TOKENS or (token[:1].isdigit() and token[1:] in REDIRECT_TOKENS):
143
+ index += 1
144
+ if index < len(tokens):
145
+ redirects.append(tokens[index])
146
+ index += 1
147
+ continue
148
+ for marker in REDIRECT_TOKENS:
149
+ if marker in token and not token.startswith(marker):
150
+ head, _, tail = token.partition(marker)
151
+ words.append(head)
152
+ if tail:
153
+ redirects.append(tail)
154
+ break
155
+ else:
156
+ words.append(token)
157
+ index += 1
158
+
159
+ words = [word for word in words if word]
160
+ if not words:
161
+ return None, redirects, True
162
+
163
+ executable = words[0]
164
+ # A name produced by expansion is only known at runtime.
165
+ ok = "$" not in executable and "`" not in executable
166
+ return (
167
+ CommandSegment(
168
+ raw=stripped,
169
+ executable=executable,
170
+ args=tuple(words[1:]),
171
+ in_substitution=in_sub,
172
+ ),
173
+ redirects,
174
+ ok,
175
+ )
176
+
177
+
178
+ class _ScanError(Exception):
179
+ pass
180
+
181
+
182
+ def _scan(text: str) -> tuple[list[str], list[str], bool]:
183
+ """Split into top-level pieces and extracted substitution bodies.
184
+
185
+ Returns ``(pieces, substitutions, ok)``. ``ok`` is False when quoting is
186
+ unbalanced or a substitution never closes.
187
+ """
188
+ pieces: list[str] = []
189
+ substitutions: list[str] = []
190
+ buffer: list[str] = []
191
+ ok = True
192
+
193
+ index = 0
194
+ length = len(text)
195
+ quote: str | None = None
196
+
197
+ while index < length:
198
+ char = text[index]
199
+
200
+ if char == "\\" and quote != "'":
201
+ buffer.append(text[index : index + 2])
202
+ index += 2
203
+ continue
204
+
205
+ if quote:
206
+ if char == quote:
207
+ quote = None
208
+ # Substitutions are live inside double quotes.
209
+ elif quote == '"' and (text.startswith("$(", index) or char == "`"):
210
+ body, consumed, closed = _read_substitution(text, index)
211
+ substitutions.extend(_recurse(body, substitutions))
212
+ ok = ok and closed
213
+ index += consumed
214
+ continue
215
+ buffer.append(char)
216
+ index += 1
217
+ continue
218
+
219
+ if char in "'\"":
220
+ quote = char
221
+ buffer.append(char)
222
+ index += 1
223
+ continue
224
+
225
+ if text.startswith("$(", index) or char == "`":
226
+ body, consumed, closed = _read_substitution(text, index)
227
+ substitutions.extend(_recurse(body, substitutions))
228
+ ok = ok and closed
229
+ index += consumed
230
+ continue
231
+
232
+ if char in "()":
233
+ # Subshell grouping: the contents run at this level.
234
+ index += 1
235
+ continue
236
+
237
+ operator = next((op for op in OPERATORS if text.startswith(op, index)), None)
238
+ if operator:
239
+ pieces.append("".join(buffer))
240
+ buffer.clear()
241
+ index += len(operator)
242
+ continue
243
+
244
+ buffer.append(char)
245
+ index += 1
246
+
247
+ if quote:
248
+ ok = False
249
+ pieces.append("".join(buffer))
250
+ return [piece for piece in pieces if piece.strip()], substitutions, ok
251
+
252
+
253
+ def _recurse(body: str, seen: list[str]) -> list[str]:
254
+ """Substitution bodies are themselves commands; flatten them one level deep."""
255
+ inner_pieces, inner_subs, _ = _scan(body)
256
+ return [*inner_pieces, *[s for s in inner_subs if s not in seen]]
257
+
258
+
259
+ def _read_substitution(text: str, index: int) -> tuple[str, int, bool]:
260
+ """Read a ``$(...)`` or backtick substitution starting at ``index``."""
261
+ if text[index] == "`":
262
+ end = text.find("`", index + 1)
263
+ if end == -1:
264
+ return text[index + 1 :], len(text) - index, False
265
+ return text[index + 1 : end], end - index + 1, True
266
+
267
+ depth = 0
268
+ cursor = index + 1 # points at "("
269
+ while cursor < len(text):
270
+ if text[cursor] == "(":
271
+ depth += 1
272
+ elif text[cursor] == ")":
273
+ depth -= 1
274
+ if depth == 0:
275
+ return text[index + 2 : cursor], cursor - index + 1, True
276
+ cursor += 1
277
+ return text[index + 2 :], len(text) - index, False
278
+
279
+
280
+ def match_specifier(segment: CommandSegment, pattern: str) -> bool:
281
+ """Match a segment against a rule specifier such as ``git commit:*`` or ``ls``.
282
+
283
+ ``:*`` is a prefix match on the whole command line; a bare executable name
284
+ matches any invocation of it.
285
+ """
286
+ if pattern in {"*", ":*"}:
287
+ return True
288
+
289
+ command_line = " ".join([segment.executable, *segment.args])
290
+ if pattern.endswith(":*"):
291
+ prefix = pattern[:-2].strip()
292
+ return command_line == prefix or command_line.startswith(prefix + " ")
293
+ if " " in pattern:
294
+ return command_line == pattern
295
+ return segment.executable == pattern
296
+
297
+
298
+ def is_read_only(segment: CommandSegment) -> bool:
299
+ """Heuristic allowlist of non-mutating commands (``ls``, ``cat``, ``git status``).
300
+
301
+ Used only to relax prompting, never to grant something an explicit deny rule
302
+ covers.
303
+ """
304
+ if subcommands := READ_ONLY_SUBCOMMANDS.get(segment.executable):
305
+ return bool(segment.args) and segment.args[0] in subcommands
306
+ return segment.executable in READ_ONLY_COMMANDS