loom-code 0.1.1__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.
- loom_code/__init__.py +22 -0
- loom_code/_post_commit.py +119 -0
- loom_code/agent.py +544 -0
- loom_code/approval.py +616 -0
- loom_code/browse/__init__.py +291 -0
- loom_code/browse/act.py +467 -0
- loom_code/browse/observe.py +249 -0
- loom_code/browse/session.py +96 -0
- loom_code/browse/verify.py +194 -0
- loom_code/checkpoint.py +283 -0
- loom_code/cli.py +495 -0
- loom_code/code_index.py +703 -0
- loom_code/compact.py +143 -0
- loom_code/consent.py +47 -0
- loom_code/credentials.py +527 -0
- loom_code/edit_tool.py +635 -0
- loom_code/extensions.py +522 -0
- loom_code/file_history.py +322 -0
- loom_code/file_tools.py +93 -0
- loom_code/git_hook.py +200 -0
- loom_code/grep_tool.py +430 -0
- loom_code/hooks.py +297 -0
- loom_code/loominit/__init__.py +23 -0
- loom_code/loominit/_ast_walk.py +429 -0
- loom_code/loominit/_files.py +284 -0
- loom_code/loominit/_graph.py +141 -0
- loom_code/loominit/_resolve.py +392 -0
- loom_code/loominit/_tests_map.py +108 -0
- loom_code/loominit/extractor.py +332 -0
- loom_code/loominit/repomap.py +225 -0
- loom_code/loominit/schema.py +242 -0
- loom_code/lsp_tools.py +396 -0
- loom_code/mcp_host.py +79 -0
- loom_code/operator.py +449 -0
- loom_code/paste.py +97 -0
- loom_code/paths.py +52 -0
- loom_code/permissions.py +177 -0
- loom_code/project.py +104 -0
- loom_code/prompts.py +451 -0
- loom_code/render.py +783 -0
- loom_code/repl.py +4080 -0
- loom_code/rules.py +267 -0
- loom_code/sandboxed_bash.py +176 -0
- loom_code/scribe.py +88 -0
- loom_code/skills/__init__.py +16 -0
- loom_code/skills/graphify/SKILL.md +97 -0
- loom_code/skills/graphify/tools.py +570 -0
- loom_code/trust.py +216 -0
- loom_code/turn.py +169 -0
- loom_code/web_fetch.py +370 -0
- loom_code/workers.py +758 -0
- loom_code/worktree.py +134 -0
- loom_code-0.1.1.dist-info/METADATA +224 -0
- loom_code-0.1.1.dist-info/RECORD +58 -0
- loom_code-0.1.1.dist-info/WHEEL +5 -0
- loom_code-0.1.1.dist-info/entry_points.txt +2 -0
- loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
- loom_code-0.1.1.dist-info/top_level.txt +1 -0
loom_code/approval.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""The terminal diff-approval gate.
|
|
2
|
+
|
|
3
|
+
When ``StandardPermissions`` flags a destructive tool call
|
|
4
|
+
(``write`` / ``edit`` / ``bash``), loomflow routes it to the
|
|
5
|
+
Agent's ``approval_handler``. This module is that handler: it
|
|
6
|
+
renders WHAT the agent wants to do — a unified diff for edits, the
|
|
7
|
+
full content for writes, the command for bash — and asks the user
|
|
8
|
+
y / n / a (allow-all-this-session).
|
|
9
|
+
|
|
10
|
+
Pure UI. The decision logic loomflow owns; we just collect the
|
|
11
|
+
human's answer.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import difflib
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import anyio
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.syntax import Syntax
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
from .render import console
|
|
28
|
+
|
|
29
|
+
# History-/repo-destroying shell commands. These are NOT covered by the
|
|
30
|
+
# "allow all this session" choice — even after the user picks 'a', one
|
|
31
|
+
# of these still demands a fresh, explicit confirmation, because they're
|
|
32
|
+
# irreversible in a way ordinary edits aren't. A real incident motivated
|
|
33
|
+
# this: the agent ran ``rm -rf .git`` on request and silently deleted a
|
|
34
|
+
# repo's entire history with no extra friction. Patterns are matched
|
|
35
|
+
# against the normalized (lowercased, whitespace-collapsed) command.
|
|
36
|
+
_DANGER_PATTERNS: tuple[str, ...] = (
|
|
37
|
+
"rm -rf .git",
|
|
38
|
+
"rm -r .git",
|
|
39
|
+
"rm -fr .git",
|
|
40
|
+
"rm -rf .git/",
|
|
41
|
+
"git reset --hard",
|
|
42
|
+
"git clean -fd",
|
|
43
|
+
"git clean -xfd",
|
|
44
|
+
"git push --force",
|
|
45
|
+
"git push -f",
|
|
46
|
+
"git push --force-with-lease",
|
|
47
|
+
"git branch -d", # force-delete branch
|
|
48
|
+
"git update-ref -d",
|
|
49
|
+
"rm -rf /",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_danger_command(tool: str, args: dict[str, Any]) -> str | None:
|
|
54
|
+
"""Return a human label if this call is a history-/repo-destroying
|
|
55
|
+
bash command, else None. Only ``bash`` can carry these — edits and
|
|
56
|
+
writes are bounded to a single file and already gated.
|
|
57
|
+
|
|
58
|
+
The normalized match collapses ``rm -rf .git`` and quoting
|
|
59
|
+
variants down so a stray space can't slip a destructive command
|
|
60
|
+
past the check. False positives are acceptable here: an extra
|
|
61
|
+
confirmation on a benign ``git reset --hard`` to a known-safe ref
|
|
62
|
+
costs one keypress; a missed ``rm -rf .git`` costs the repo."""
|
|
63
|
+
if tool != "bash":
|
|
64
|
+
return None
|
|
65
|
+
cmd = str(args.get("command", "")).lower()
|
|
66
|
+
norm = " ".join(cmd.split())
|
|
67
|
+
for pat in _DANGER_PATTERNS:
|
|
68
|
+
if pat in norm:
|
|
69
|
+
return pat
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_key_raw(fd: int) -> str:
|
|
74
|
+
"""Read one LOGICAL key from an ALREADY-raw ``fd``: 'up' / 'down'
|
|
75
|
+
/ 'enter' / 'esc' / 'eof' / a single lowercased printable char.
|
|
76
|
+
|
|
77
|
+
Arrow keys arrive as an escape sequence — ``ESC [ A/B`` (normal)
|
|
78
|
+
or ``ESC O A/B`` (application-cursor mode, common over SSH/tmux).
|
|
79
|
+
The bytes can also SPLIT across reads on a slow PTY, so after ESC
|
|
80
|
+
we poll-and-read up to two more bytes rather than assuming they
|
|
81
|
+
land in one ``os.read`` (the earlier one-shot ``os.read(fd, 2)``
|
|
82
|
+
turned a split ↓ into 'esc' → an accidental deny, and left the
|
|
83
|
+
trailing 'A'/'B' in the buffer to be misread as the 'a' hotkey).
|
|
84
|
+
|
|
85
|
+
``os.read`` on the raw fd, never ``sys.stdin.read`` — Python's
|
|
86
|
+
stdin buffers ahead, hiding continuation bytes from ``select``.
|
|
87
|
+
An empty read is EOF (terminal hangup) → 'eof', which callers
|
|
88
|
+
treat as a safe cancel, never an approval."""
|
|
89
|
+
import os
|
|
90
|
+
import select
|
|
91
|
+
|
|
92
|
+
def _more(timeout: float) -> str:
|
|
93
|
+
r, _, _ = select.select([fd], [], [], timeout)
|
|
94
|
+
if not r:
|
|
95
|
+
return ""
|
|
96
|
+
return os.read(fd, 1).decode("utf-8", "ignore")
|
|
97
|
+
|
|
98
|
+
data = os.read(fd, 1)
|
|
99
|
+
if not data: # EOF / hangup
|
|
100
|
+
return "eof"
|
|
101
|
+
ch = data.decode("utf-8", "ignore")
|
|
102
|
+
if ch in ("\r", "\n"):
|
|
103
|
+
return "enter"
|
|
104
|
+
if ch == "\x03": # Ctrl-C
|
|
105
|
+
return "esc"
|
|
106
|
+
if ch == "\x1b":
|
|
107
|
+
intro = _more(0.05)
|
|
108
|
+
if intro in ("[", "O"): # CSI or SS3 arrows
|
|
109
|
+
final = _more(0.05)
|
|
110
|
+
return {"A": "up", "B": "down"}.get(final, "esc")
|
|
111
|
+
return "esc"
|
|
112
|
+
return ch.lower()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _read_key() -> str:
|
|
116
|
+
"""Single-key read that manages its own raw-mode window. Prefer
|
|
117
|
+
:func:`_read_key_raw` inside a selector that enters raw mode ONCE
|
|
118
|
+
(no per-key termios churn); this wrapper is for one-off reads.
|
|
119
|
+
|
|
120
|
+
Non-TTY (piped/CI/tests): a line read whose EOF maps to 'eof'
|
|
121
|
+
(fail-closed), NOT 'enter'."""
|
|
122
|
+
if not sys.stdin.isatty():
|
|
123
|
+
try:
|
|
124
|
+
line = sys.stdin.readline()
|
|
125
|
+
except Exception:
|
|
126
|
+
return "eof"
|
|
127
|
+
if line == "":
|
|
128
|
+
return "eof"
|
|
129
|
+
ch = line.strip()[:1].lower()
|
|
130
|
+
return ch or "enter"
|
|
131
|
+
try:
|
|
132
|
+
import termios
|
|
133
|
+
import tty
|
|
134
|
+
|
|
135
|
+
fd = sys.stdin.fileno()
|
|
136
|
+
old = termios.tcgetattr(fd)
|
|
137
|
+
try:
|
|
138
|
+
tty.setraw(fd)
|
|
139
|
+
return _read_key_raw(fd)
|
|
140
|
+
finally:
|
|
141
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
142
|
+
except Exception:
|
|
143
|
+
return _read_single_key() or "eof"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _select_option(options: list[tuple[str, str]], default: int = 0) -> str:
|
|
147
|
+
"""A Claude-Code-style vertical selector. ``options`` is a list of
|
|
148
|
+
``(key, label)``; returns the chosen key.
|
|
149
|
+
|
|
150
|
+
Navigation: ↑/↓ move, Enter confirms, 1-9 jump-select, the
|
|
151
|
+
option's own hotkey (y/n/a) selects directly. Esc / Ctrl-C / EOF
|
|
152
|
+
pick the LAST option — which callers make the SAFE choice ("No"),
|
|
153
|
+
so a closed stdin, a hangup, or a startled Ctrl-C can never
|
|
154
|
+
approve. Rendering is plain ANSI — the block redraws in place on
|
|
155
|
+
every keypress, which works in any VT terminal and deliberately
|
|
156
|
+
avoids nesting a Rich Live inside the REPL's spinner (a known
|
|
157
|
+
recursion hazard)."""
|
|
158
|
+
n = len(options)
|
|
159
|
+
if not sys.stdin.isatty():
|
|
160
|
+
# Non-interactive: a single letter picks by hotkey; EOF/empty
|
|
161
|
+
# is the SAFE last option (never the default-yes).
|
|
162
|
+
ch = _read_key()
|
|
163
|
+
for key, _label in options:
|
|
164
|
+
if ch == key:
|
|
165
|
+
return key
|
|
166
|
+
if ch == "enter":
|
|
167
|
+
return options[default][0]
|
|
168
|
+
return options[-1][0] # eof / esc / unknown → safe
|
|
169
|
+
|
|
170
|
+
idx = default
|
|
171
|
+
|
|
172
|
+
def _draw(first: bool) -> None:
|
|
173
|
+
out = sys.stdout
|
|
174
|
+
if not first:
|
|
175
|
+
out.write(f"\x1b[{n}A") # cursor up n rows, to block start
|
|
176
|
+
for i, (_key, label) in enumerate(options):
|
|
177
|
+
# ``\r`` first: in RAW mode ``tty.setraw`` disables NL→CRNL
|
|
178
|
+
# translation, so a bare ``\n`` drops a row WITHOUT
|
|
179
|
+
# returning to column 0 — each line would start further
|
|
180
|
+
# right than the last (the staircase). Carriage-return to
|
|
181
|
+
# column 0, clear the whole line, then print.
|
|
182
|
+
out.write("\r\x1b[2K")
|
|
183
|
+
if i == idx:
|
|
184
|
+
out.write(f" \x1b[36;1m❯ {i + 1}. {label}\x1b[0m")
|
|
185
|
+
else:
|
|
186
|
+
out.write(f" \x1b[2m{i + 1}. {label}\x1b[0m")
|
|
187
|
+
out.write("\r\n") # explicit CR+LF for raw mode
|
|
188
|
+
out.flush()
|
|
189
|
+
|
|
190
|
+
# Enter raw mode ONCE for the whole selector session — no
|
|
191
|
+
# per-keypress termios churn, and no cooked-mode gap between keys
|
|
192
|
+
# where type-ahead would echo raw escape bytes onto the prompt.
|
|
193
|
+
import termios
|
|
194
|
+
import tty
|
|
195
|
+
|
|
196
|
+
fd = sys.stdin.fileno()
|
|
197
|
+
try:
|
|
198
|
+
old = termios.tcgetattr(fd)
|
|
199
|
+
except Exception:
|
|
200
|
+
old = None
|
|
201
|
+
if old is not None:
|
|
202
|
+
tty.setraw(fd)
|
|
203
|
+
try:
|
|
204
|
+
_draw(first=True)
|
|
205
|
+
while True:
|
|
206
|
+
key = _read_key_raw(fd) if old is not None else _read_key()
|
|
207
|
+
if key == "up":
|
|
208
|
+
idx = (idx - 1) % n
|
|
209
|
+
elif key == "down":
|
|
210
|
+
idx = (idx + 1) % n
|
|
211
|
+
elif key == "enter":
|
|
212
|
+
return options[idx][0]
|
|
213
|
+
elif key in ("esc", "eof"):
|
|
214
|
+
return options[-1][0]
|
|
215
|
+
elif key.isdigit() and 1 <= int(key) <= n:
|
|
216
|
+
return options[int(key) - 1][0]
|
|
217
|
+
else:
|
|
218
|
+
for k, _label in options:
|
|
219
|
+
if key == k:
|
|
220
|
+
return k
|
|
221
|
+
continue # unknown key: ignore, keep waiting
|
|
222
|
+
_draw(first=False)
|
|
223
|
+
finally:
|
|
224
|
+
if old is not None:
|
|
225
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _read_single_key() -> str:
|
|
229
|
+
"""Read ONE keypress without waiting for Enter.
|
|
230
|
+
|
|
231
|
+
POSIX raw-mode read; falls back to ``msvcrt`` on Windows and to a
|
|
232
|
+
line read when stdin isn't a TTY (piped input, tests). Returning a
|
|
233
|
+
single character lets the approval prompt act like a button row —
|
|
234
|
+
the user reported the type-then-Enter form as a real obstacle.
|
|
235
|
+
"""
|
|
236
|
+
if not sys.stdin.isatty():
|
|
237
|
+
# Non-interactive (piped/CI/tests) — degrade to a line read so
|
|
238
|
+
# the gate still resolves instead of blocking forever.
|
|
239
|
+
try:
|
|
240
|
+
return sys.stdin.readline().strip()[:1].lower()
|
|
241
|
+
except Exception:
|
|
242
|
+
return ""
|
|
243
|
+
try:
|
|
244
|
+
import termios
|
|
245
|
+
import tty
|
|
246
|
+
|
|
247
|
+
fd = sys.stdin.fileno()
|
|
248
|
+
old = termios.tcgetattr(fd)
|
|
249
|
+
try:
|
|
250
|
+
tty.setraw(fd)
|
|
251
|
+
return sys.stdin.read(1)
|
|
252
|
+
finally:
|
|
253
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
254
|
+
except Exception:
|
|
255
|
+
try:
|
|
256
|
+
import msvcrt
|
|
257
|
+
|
|
258
|
+
return msvcrt.getch().decode("utf-8", "ignore")
|
|
259
|
+
except Exception:
|
|
260
|
+
try:
|
|
261
|
+
return sys.stdin.readline().strip()[:1].lower()
|
|
262
|
+
except Exception:
|
|
263
|
+
return ""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _question_for(tool: str) -> str:
|
|
267
|
+
"""The bold question above the option list, per tool."""
|
|
268
|
+
return {
|
|
269
|
+
"edit": "Apply this edit?",
|
|
270
|
+
"multi_edit": "Apply these edits?",
|
|
271
|
+
"write": "Write this file?",
|
|
272
|
+
"bash": "Run this command?",
|
|
273
|
+
}.get(tool, f"Allow {tool}?")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def auto_approve(call: Any, user_id: str | None = None) -> bool:
|
|
277
|
+
"""A non-interactive approval handler that allows everything.
|
|
278
|
+
|
|
279
|
+
For unattended runs — CI, scripted use — where there's no
|
|
280
|
+
human at a TTY to answer the y/n/a prompt and the working tree
|
|
281
|
+
is disposable. Wired in via ``loom-code --yes``.
|
|
282
|
+
|
|
283
|
+
NEVER point this at a repo you care about: it lets the agent
|
|
284
|
+
write / edit / run shell commands with no gate at all.
|
|
285
|
+
"""
|
|
286
|
+
return True
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class ApprovalGate:
|
|
290
|
+
"""Stateful approval handler — remembers an 'allow all this
|
|
291
|
+
session' choice so the user isn't asked twice for the same
|
|
292
|
+
kind of risk.
|
|
293
|
+
|
|
294
|
+
Pass :meth:`handler` as the Agent's ``approval_handler``.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def __init__(
|
|
298
|
+
self,
|
|
299
|
+
*,
|
|
300
|
+
pause_spinner: Callable[[], None] | None = None,
|
|
301
|
+
resume_spinner: Callable[[], None] | None = None,
|
|
302
|
+
rules: Any | None = None,
|
|
303
|
+
mode: Any | None = None,
|
|
304
|
+
project_root: Any | None = None,
|
|
305
|
+
) -> None:
|
|
306
|
+
# Once the user picks 'a' (allow all), every subsequent
|
|
307
|
+
# destructive call this session auto-approves. Reset only
|
|
308
|
+
# by restarting loom-code.
|
|
309
|
+
self._allow_all = False
|
|
310
|
+
# Permission rules (allow/ask/deny globs) + session mode
|
|
311
|
+
# (default/accept-edits/plan/yolo). Imported here to keep the
|
|
312
|
+
# module import-light. The REPL swaps ``mode`` via /mode.
|
|
313
|
+
from .permissions import Mode, Rules
|
|
314
|
+
|
|
315
|
+
self.rules = rules if rules is not None else Rules()
|
|
316
|
+
self.mode = mode if mode is not None else Mode.DEFAULT
|
|
317
|
+
# Project root — used to force a confirm on edits OUTSIDE it
|
|
318
|
+
# even in auto-approve modes. None disables the check (the
|
|
319
|
+
# outside-edit path just behaves like any other edit then).
|
|
320
|
+
self.project_root = (
|
|
321
|
+
Path(project_root).resolve() if project_root else None
|
|
322
|
+
)
|
|
323
|
+
# The REPL drives a ``console.status`` spinner for the whole
|
|
324
|
+
# turn. Its Live refresh shares the cursor line, so leaving it
|
|
325
|
+
# running corrupts the approval prompt's keystrokes (mangled
|
|
326
|
+
# input → endless "select an option" re-prompt). We pause it
|
|
327
|
+
# around the prompt and resume after.
|
|
328
|
+
self._pause_spinner = pause_spinner or (lambda: None)
|
|
329
|
+
self._resume_spinner = resume_spinner or (lambda: None)
|
|
330
|
+
|
|
331
|
+
async def handler(
|
|
332
|
+
self, call: Any, user_id: str | None = None
|
|
333
|
+
) -> bool:
|
|
334
|
+
"""The ``approval_handler`` loomflow calls. ``call`` is a
|
|
335
|
+
``ToolCall``; return True to allow, False to deny.
|
|
336
|
+
|
|
337
|
+
Policy resolves in ONE place — :func:`permissions.decide` — so
|
|
338
|
+
precedence is consistent: **deny > ask > allow > mode**. Then:
|
|
339
|
+
|
|
340
|
+
* DENY → refuse, unconditionally (a deny rule / plan mode is
|
|
341
|
+
absolute; nothing below overrides it).
|
|
342
|
+
* ASK → the interactive prompt.
|
|
343
|
+
* ALLOW → skip the prompt, UNLESS the command is one of the
|
|
344
|
+
irreversible history-/repo-destroyers, which ALWAYS get a
|
|
345
|
+
fresh high-friction confirm even under allow-all / yolo.
|
|
346
|
+
|
|
347
|
+
Session 'allow all' is modelled as an effective yolo mode
|
|
348
|
+
INSIDE ``decide`` (not a shortcut above it), so an explicit
|
|
349
|
+
``ask`` rule the user configured still forces the prompt —
|
|
350
|
+
one careless 'a' can't silently defeat their own ask-rules."""
|
|
351
|
+
tool = getattr(call, "tool", "?")
|
|
352
|
+
args = getattr(call, "args", {}) or {}
|
|
353
|
+
|
|
354
|
+
from .permissions import Decision, Mode, decide
|
|
355
|
+
|
|
356
|
+
effective_mode = Mode.YOLO if self._allow_all else self.mode
|
|
357
|
+
decision = decide(tool, args, self.rules, effective_mode)
|
|
358
|
+
|
|
359
|
+
# An edit/write to a file OUTSIDE the project always shows the
|
|
360
|
+
# diff and asks — even in accept-edits / yolo / allow-all /
|
|
361
|
+
# --yes. Consent (an @-mention) lets the edit tool TARGET the
|
|
362
|
+
# file; it does not waive the human confirmation. Without this,
|
|
363
|
+
# /mode accept-edits + an @-mention of ~/.zshrc would silently
|
|
364
|
+
# mutate a dotfile. Never UPGRADES a deny.
|
|
365
|
+
if (
|
|
366
|
+
decision is Decision.ALLOW
|
|
367
|
+
and tool in ("edit", "multi_edit", "write")
|
|
368
|
+
and self._is_outside_project(args.get("path"))
|
|
369
|
+
):
|
|
370
|
+
decision = Decision.ASK
|
|
371
|
+
|
|
372
|
+
if decision is Decision.DENY:
|
|
373
|
+
self._pause_spinner()
|
|
374
|
+
try:
|
|
375
|
+
console.print(
|
|
376
|
+
Text(
|
|
377
|
+
f" ⊘ {tool} denied by permission policy "
|
|
378
|
+
f"({self.mode.value})",
|
|
379
|
+
style="red",
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
finally:
|
|
383
|
+
self._resume_spinner()
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
# Danger gate — irreversible commands ALWAYS re-confirm, even
|
|
387
|
+
# when policy said ALLOW. It never UPGRADES a deny (handled
|
|
388
|
+
# above) — only forces friction on an otherwise-allowed
|
|
389
|
+
# destructive command.
|
|
390
|
+
danger = _is_danger_command(tool, args)
|
|
391
|
+
if danger is not None:
|
|
392
|
+
return await self._confirm_danger(danger, args)
|
|
393
|
+
|
|
394
|
+
if decision is Decision.ALLOW:
|
|
395
|
+
return True
|
|
396
|
+
|
|
397
|
+
# Pause the spinner BEFORE any console output — even the
|
|
398
|
+
# header lines below get garbled if the Live is still
|
|
399
|
+
# repainting the cursor line.
|
|
400
|
+
self._pause_spinner()
|
|
401
|
+
try:
|
|
402
|
+
console.print()
|
|
403
|
+
self._render_header(tool, args)
|
|
404
|
+
self._render_preview(tool, args)
|
|
405
|
+
console.print()
|
|
406
|
+
console.print(
|
|
407
|
+
Text(f" {_question_for(tool)}", style="bold")
|
|
408
|
+
)
|
|
409
|
+
# Selector runs on a worker thread so the raw-mode key
|
|
410
|
+
# reads don't stall the anyio event loop.
|
|
411
|
+
choice = await anyio.to_thread.run_sync(self._ask)
|
|
412
|
+
finally:
|
|
413
|
+
self._resume_spinner()
|
|
414
|
+
# ``_ask`` already echoed the choice — no second line here
|
|
415
|
+
# (the old "→ denied" after "→ no" read as a double refusal).
|
|
416
|
+
if choice == "a":
|
|
417
|
+
self._allow_all = True
|
|
418
|
+
return True
|
|
419
|
+
return choice == "y"
|
|
420
|
+
|
|
421
|
+
# ---- internals ------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
def _is_outside_project(self, path: Any) -> bool:
|
|
424
|
+
"""True if ``path`` resolves outside the project root. False
|
|
425
|
+
when no root is configured or the path is unusable (fail
|
|
426
|
+
toward the normal in-project flow — the edit tool's own
|
|
427
|
+
workdir guard still refuses genuinely-outside writes)."""
|
|
428
|
+
if self.project_root is None or not path:
|
|
429
|
+
return False
|
|
430
|
+
try:
|
|
431
|
+
target = (self.project_root / Path(path)).resolve()
|
|
432
|
+
target.relative_to(self.project_root)
|
|
433
|
+
return False
|
|
434
|
+
except ValueError:
|
|
435
|
+
return True
|
|
436
|
+
except OSError:
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
async def _confirm_danger(
|
|
440
|
+
self, label: str, args: dict[str, Any]
|
|
441
|
+
) -> bool:
|
|
442
|
+
"""High-friction confirm for an irreversible command. Unlike the
|
|
443
|
+
normal gate there is NO 'allow all', and the default (Enter /
|
|
444
|
+
any non-'y' key / Esc) is DENY — the user must deliberately type
|
|
445
|
+
'y' to proceed. Never auto-approves, regardless of session state
|
|
446
|
+
or ``--yes``-style handlers wrapped around this gate."""
|
|
447
|
+
self._pause_spinner()
|
|
448
|
+
try:
|
|
449
|
+
console.print()
|
|
450
|
+
console.print(
|
|
451
|
+
Text(
|
|
452
|
+
f" ⛔ DESTRUCTIVE: this would run '{label}' — it is "
|
|
453
|
+
"IRREVERSIBLE",
|
|
454
|
+
style="bold red",
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
cmd = str(args.get("command", ""))
|
|
458
|
+
console.print(Syntax(cmd, "bash", theme="ansi_dark"))
|
|
459
|
+
console.print(
|
|
460
|
+
Text(
|
|
461
|
+
" This permanently destroys history / data and is "
|
|
462
|
+
"NOT covered by 'allow all'.",
|
|
463
|
+
style="red",
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
console.print(
|
|
467
|
+
" [bold]Type 'y' to confirm, anything else cancels:[/bold]"
|
|
468
|
+
" ",
|
|
469
|
+
end="",
|
|
470
|
+
highlight=False,
|
|
471
|
+
)
|
|
472
|
+
choice = await anyio.to_thread.run_sync(_read_single_key)
|
|
473
|
+
finally:
|
|
474
|
+
self._resume_spinner()
|
|
475
|
+
if choice in ("y", "Y"):
|
|
476
|
+
console.print("[red]confirmed[/red]")
|
|
477
|
+
return True
|
|
478
|
+
console.print("[green]cancelled[/green]")
|
|
479
|
+
return False
|
|
480
|
+
|
|
481
|
+
def _ask(self) -> str:
|
|
482
|
+
"""Blocking option-selector. Runs on a worker thread.
|
|
483
|
+
|
|
484
|
+
Claude-Code-style vertical menu: ↑/↓ + Enter, number keys, or
|
|
485
|
+
the y/a/n hotkeys. Esc / Ctrl-C picks "No" so a startled user
|
|
486
|
+
can always back out safely. Returns 'y' / 'a' / 'n'."""
|
|
487
|
+
choice = _select_option(
|
|
488
|
+
[
|
|
489
|
+
("y", "Yes"),
|
|
490
|
+
("a", "Yes, and don't ask again this session"),
|
|
491
|
+
("n", "No (esc)"),
|
|
492
|
+
],
|
|
493
|
+
default=0,
|
|
494
|
+
)
|
|
495
|
+
echo = {
|
|
496
|
+
"y": Text(" → yes", style="green"),
|
|
497
|
+
"a": Text(" → yes, allowing all this session", "yellow"),
|
|
498
|
+
"n": Text(" → no", style="red"),
|
|
499
|
+
}[choice]
|
|
500
|
+
console.print(echo)
|
|
501
|
+
return choice
|
|
502
|
+
|
|
503
|
+
def _render_header(self, tool: str, args: dict[str, Any]) -> None:
|
|
504
|
+
"""One bold title line naming the action + its target —
|
|
505
|
+
``● Edit src/main.py`` — Claude-Code-style, replacing the
|
|
506
|
+
old '⚠ tool wants to run:' warning shout."""
|
|
507
|
+
target = (
|
|
508
|
+
str(args.get("command", "")).strip()
|
|
509
|
+
if tool == "bash"
|
|
510
|
+
else str(args.get("path", "")).strip()
|
|
511
|
+
)
|
|
512
|
+
if len(target) > 60:
|
|
513
|
+
target = target[:60] + "…"
|
|
514
|
+
label = {
|
|
515
|
+
"edit": "Edit",
|
|
516
|
+
"multi_edit": "Edit",
|
|
517
|
+
"write": "Write",
|
|
518
|
+
"bash": "Run",
|
|
519
|
+
}.get(tool, tool)
|
|
520
|
+
console.print(
|
|
521
|
+
Text.assemble(
|
|
522
|
+
(" ● ", "cyan"),
|
|
523
|
+
(f"{label}", "bold"),
|
|
524
|
+
(" ", ""),
|
|
525
|
+
(target, "dim"),
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
def _render_preview(self, tool: str, args: dict[str, Any]) -> None:
|
|
530
|
+
"""Show the user exactly what's about to happen, inside a
|
|
531
|
+
rounded panel so the preview reads as one contained artifact
|
|
532
|
+
(Claude-Code-style) rather than loose lines."""
|
|
533
|
+
body: Any
|
|
534
|
+
if tool == "edit":
|
|
535
|
+
body = self._edit_diff_renderable(args)
|
|
536
|
+
elif tool == "write":
|
|
537
|
+
path = args.get("path", "?")
|
|
538
|
+
content = str(args.get("content", ""))
|
|
539
|
+
preview = content if len(content) <= 800 else (
|
|
540
|
+
content[:800] + f"\n… (+{len(content) - 800} chars)"
|
|
541
|
+
)
|
|
542
|
+
body = Syntax(
|
|
543
|
+
preview, _lexer_for(path), theme="ansi_dark",
|
|
544
|
+
line_numbers=False,
|
|
545
|
+
)
|
|
546
|
+
elif tool == "bash":
|
|
547
|
+
cmd = str(args.get("command", ""))
|
|
548
|
+
body = Syntax(cmd, "bash", theme="ansi_dark")
|
|
549
|
+
else:
|
|
550
|
+
# Unknown destructive tool — show raw args.
|
|
551
|
+
lines = []
|
|
552
|
+
for k, v in args.items():
|
|
553
|
+
sv = str(v)
|
|
554
|
+
if len(sv) > 200:
|
|
555
|
+
sv = sv[:200] + "…"
|
|
556
|
+
lines.append(f"{k} = {sv}")
|
|
557
|
+
body = Text("\n".join(lines), style="dim")
|
|
558
|
+
console.print(
|
|
559
|
+
Panel(
|
|
560
|
+
body,
|
|
561
|
+
border_style="dim",
|
|
562
|
+
padding=(0, 1),
|
|
563
|
+
expand=False,
|
|
564
|
+
)
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
def _edit_diff_renderable(self, args: dict[str, Any]) -> Any:
|
|
568
|
+
"""A unified-diff renderable for an ``edit`` call so the user
|
|
569
|
+
sees the change in context, not two opaque strings."""
|
|
570
|
+
path = args.get("path", "?")
|
|
571
|
+
old = str(args.get("old_string", ""))
|
|
572
|
+
new = str(args.get("new_string", ""))
|
|
573
|
+
# No keepends + join on "\n": with ``lineterm=""`` the header
|
|
574
|
+
# lines carry no newline of their own, so keepends-content
|
|
575
|
+
# mixed with them used to collapse the whole diff onto one
|
|
576
|
+
# wrapped line.
|
|
577
|
+
diff = difflib.unified_diff(
|
|
578
|
+
old.splitlines(),
|
|
579
|
+
new.splitlines(),
|
|
580
|
+
fromfile=f"{path} (before)",
|
|
581
|
+
tofile=f"{path} (after)",
|
|
582
|
+
lineterm="",
|
|
583
|
+
)
|
|
584
|
+
body = "\n".join(diff)
|
|
585
|
+
if not body.strip():
|
|
586
|
+
return Text(f"edit {path} (no textual change?)", "dim")
|
|
587
|
+
return Syntax(body, "diff", theme="ansi_dark")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _lexer_for(path: str) -> str:
|
|
591
|
+
"""Best-effort lexer name from a file extension."""
|
|
592
|
+
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
593
|
+
return {
|
|
594
|
+
"py": "python",
|
|
595
|
+
"js": "javascript",
|
|
596
|
+
"ts": "typescript",
|
|
597
|
+
"tsx": "tsx",
|
|
598
|
+
"jsx": "jsx",
|
|
599
|
+
"rs": "rust",
|
|
600
|
+
"go": "go",
|
|
601
|
+
"rb": "ruby",
|
|
602
|
+
"java": "java",
|
|
603
|
+
"c": "c",
|
|
604
|
+
"h": "c",
|
|
605
|
+
"cpp": "cpp",
|
|
606
|
+
"sh": "bash",
|
|
607
|
+
"bash": "bash",
|
|
608
|
+
"json": "json",
|
|
609
|
+
"toml": "toml",
|
|
610
|
+
"yaml": "yaml",
|
|
611
|
+
"yml": "yaml",
|
|
612
|
+
"md": "markdown",
|
|
613
|
+
"html": "html",
|
|
614
|
+
"css": "css",
|
|
615
|
+
"sql": "sql",
|
|
616
|
+
}.get(ext, "text")
|