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.
- hx/__init__.py +5 -0
- hx/agents/__init__.py +1 -0
- hx/agents/definitions.py +106 -0
- hx/agents/subagent.py +190 -0
- hx/cli.py +667 -0
- hx/config.py +277 -0
- hx/core/__init__.py +1 -0
- hx/core/compaction.py +245 -0
- hx/core/context.py +271 -0
- hx/core/events.py +183 -0
- hx/core/lateinject.py +121 -0
- hx/core/loop.py +537 -0
- hx/core/messages.py +164 -0
- hx/core/session.py +208 -0
- hx/core/usage.py +129 -0
- hx/frontmatter.py +80 -0
- hx/mcp/__init__.py +1 -0
- hx/mcp/client.py +319 -0
- hx/mcp/manager.py +265 -0
- hx/paths.py +90 -0
- hx/permissions/__init__.py +7 -0
- hx/permissions/engine.py +406 -0
- hx/permissions/parser.py +306 -0
- hx/permissions/sandbox.py +227 -0
- hx/providers/__init__.py +1 -0
- hx/providers/base.py +77 -0
- hx/providers/fake.py +87 -0
- hx/providers/models.py +238 -0
- hx/providers/openrouter.py +468 -0
- hx/skills/__init__.py +1 -0
- hx/skills/loader.py +102 -0
- hx/skills/runtime.py +84 -0
- hx/tools/__init__.py +1 -0
- hx/tools/base.py +97 -0
- hx/tools/bash.py +544 -0
- hx/tools/edit.py +167 -0
- hx/tools/glob.py +75 -0
- hx/tools/grep.py +165 -0
- hx/tools/output.py +133 -0
- hx/tools/read.py +142 -0
- hx/tools/registry.py +149 -0
- hx/tools/task.py +76 -0
- hx/tools/todo.py +149 -0
- hx/tools/write.py +87 -0
- hx/tui/__init__.py +1 -0
- hx/tui/app.py +487 -0
- hx/tui/commands.py +399 -0
- hx/tui/hx.tcss +197 -0
- hx/tui/renderers.py +570 -0
- hx/tui/theme.py +322 -0
- hx/tui/widgets/__init__.py +1 -0
- hx/tui/widgets/configure.py +95 -0
- hx/tui/widgets/diff.py +25 -0
- hx/tui/widgets/input.py +145 -0
- hx/tui/widgets/palette.py +130 -0
- hx/tui/widgets/permission.py +97 -0
- hx/tui/widgets/statusbar.py +212 -0
- hx/tui/widgets/todos.py +116 -0
- hx/tui/widgets/transcript.py +316 -0
- hx/tui/widgets/working.py +78 -0
- hx_cli-0.1.0.dist-info/METADATA +430 -0
- hx_cli-0.1.0.dist-info/RECORD +64 -0
- hx_cli-0.1.0.dist-info/WHEEL +4 -0
- hx_cli-0.1.0.dist-info/entry_points.txt +2 -0
hx/permissions/engine.py
ADDED
|
@@ -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
|
hx/permissions/parser.py
ADDED
|
@@ -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
|