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
localcode/paths.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Single source of truth for where LocalCode reads/writes state on disk.
|
|
2
|
+
|
|
3
|
+
Two-tier layout:
|
|
4
|
+
|
|
5
|
+
<project_root>/.localcode/ ← per-project state (this module's main job)
|
|
6
|
+
lifecycle.log
|
|
7
|
+
last-pressure-kill.txt
|
|
8
|
+
last_error.log
|
|
9
|
+
notebook/<session>/
|
|
10
|
+
test-results/<timestamp>/
|
|
11
|
+
sessions/ (future move)
|
|
12
|
+
history.db (future move)
|
|
13
|
+
|
|
14
|
+
~/.localcode/ ← truly-global, machine-wide state
|
|
15
|
+
llama-server.pid (one server runs at a time, system-wide)
|
|
16
|
+
stuck-server.txt (D-state marker, system-wide)
|
|
17
|
+
config.toml (user's preferences across projects)
|
|
18
|
+
models cache (10 GB+ files, can't duplicate per project)
|
|
19
|
+
|
|
20
|
+
Why split this way
|
|
21
|
+
------------------
|
|
22
|
+
Per-project: anything specific to "what terminal coding tools did in THIS codebase" — sessions,
|
|
23
|
+
test results, lifecycle of THIS project's runs, the agent's notebook scratch.
|
|
24
|
+
You can `git ignore .localcode/` and the project tree stays clean. When you move
|
|
25
|
+
or rename a project, its conversation history comes with it.
|
|
26
|
+
|
|
27
|
+
Global: anything that's a singleton on the machine — the running llama-server
|
|
28
|
+
(only one on port 8081), the model files (10 GB each), and your global
|
|
29
|
+
preferences (autonomy level, default model). Duplicating these per project
|
|
30
|
+
would waste disk and create state confusion.
|
|
31
|
+
|
|
32
|
+
Project-root detection
|
|
33
|
+
----------------------
|
|
34
|
+
Walks up from cwd looking for, in priority order:
|
|
35
|
+
1. existing `.localcode/` directory (explicit, the user already has one here)
|
|
36
|
+
2. `.git/` (most common — git repo root)
|
|
37
|
+
3. `LOCALCODE.md` (explicit localcode project marker)
|
|
38
|
+
4. Standard project files (pyproject.toml, package.json, Cargo.toml, go.mod)
|
|
39
|
+
Falls back to the cwd itself if nothing is found, so launching from `/tmp` or
|
|
40
|
+
`~` still works (you just get `<cwd>/.localcode/` wherever you ran from).
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import os
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Optional
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
GLOBAL_STATE_DIR = Path.home() / ".localcode"
|
|
50
|
+
|
|
51
|
+
_PROJECT_MARKERS = (
|
|
52
|
+
".localcode",
|
|
53
|
+
".git",
|
|
54
|
+
"LOCALCODE.md",
|
|
55
|
+
"pyproject.toml",
|
|
56
|
+
"package.json",
|
|
57
|
+
"Cargo.toml",
|
|
58
|
+
"go.mod",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def find_project_root(start: Optional[Path] = None) -> Path:
|
|
63
|
+
"""Walk up from `start` (default: cwd) looking for a project marker.
|
|
64
|
+
|
|
65
|
+
Returns the deepest ancestor that contains one of `_PROJECT_MARKERS`.
|
|
66
|
+
If none found, returns `start` (or cwd) — guarantees a usable path.
|
|
67
|
+
"""
|
|
68
|
+
p = (start or Path.cwd()).resolve()
|
|
69
|
+
for ancestor in (p, *p.parents):
|
|
70
|
+
for marker in _PROJECT_MARKERS:
|
|
71
|
+
if (ancestor / marker).exists():
|
|
72
|
+
return ancestor
|
|
73
|
+
return p
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def chmod_quiet(path: Path | str, mode: int) -> None:
|
|
77
|
+
"""Best-effort `chmod`. Permission hardening is a nice-to-have on
|
|
78
|
+
POSIX and a no-op on Windows / exotic filesystems, so a failure here
|
|
79
|
+
must never take the process down. Only OS-level errors are absorbed —
|
|
80
|
+
a programming error (bad type) still raises."""
|
|
81
|
+
try:
|
|
82
|
+
os.chmod(path, mode)
|
|
83
|
+
except (OSError, NotImplementedError):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def write_private(path: Path | str, text: str, encoding: str = "utf-8") -> None:
|
|
88
|
+
"""Write `text` to `path` with 0600 permissions, atomically w.r.t. mode.
|
|
89
|
+
|
|
90
|
+
Uses `os.open(..., 0o600)` so the file is never momentarily
|
|
91
|
+
world-readable between `write_text()` and a follow-up `chmod` — the
|
|
92
|
+
window a `Path.write_text()` + `chmod` pair leaves open. An existing
|
|
93
|
+
file keeps its inode (O_TRUNC), so its mode is re-applied explicitly.
|
|
94
|
+
|
|
95
|
+
Used for anything containing user prompts, model output, or API keys.
|
|
96
|
+
"""
|
|
97
|
+
path = Path(path)
|
|
98
|
+
data = text.encode(encoding, "replace")
|
|
99
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
100
|
+
try:
|
|
101
|
+
os.write(fd, data)
|
|
102
|
+
finally:
|
|
103
|
+
os.close(fd)
|
|
104
|
+
# O_CREAT's mode only applies to a *newly created* file; a pre-existing
|
|
105
|
+
# 0644 file keeps its old mode, so tighten it unconditionally.
|
|
106
|
+
chmod_quiet(path, 0o600)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def project_state_dir(cwd: Optional[Path] = None) -> Path:
|
|
110
|
+
"""Return (and create) `<project_root>/.localcode/`.
|
|
111
|
+
|
|
112
|
+
Idempotent — creating an existing directory is a no-op. The
|
|
113
|
+
directory is materialised on first access so callers can
|
|
114
|
+
immediately write into it without checking existence.
|
|
115
|
+
|
|
116
|
+
The directory holds `events.jsonl` (full prompts, full model
|
|
117
|
+
responses, tool-call slices), session state and error logs — all of
|
|
118
|
+
it inside the USER'S OWN REPO. Two hardening steps happen on
|
|
119
|
+
creation:
|
|
120
|
+
|
|
121
|
+
* a `.gitignore` containing `*` is dropped in, so a stray
|
|
122
|
+
`git add -A` can never stage a multi-megabyte transcript of the
|
|
123
|
+
user's prompts into a repo they're about to push;
|
|
124
|
+
* the directory is chmod 0700, so other accounts on a shared
|
|
125
|
+
machine can't read it.
|
|
126
|
+
|
|
127
|
+
Both are guarded by an existence check because this function is on
|
|
128
|
+
hot paths — the steady-state cost is one extra `stat`.
|
|
129
|
+
"""
|
|
130
|
+
d = find_project_root(cwd) / ".localcode"
|
|
131
|
+
existed = d.is_dir()
|
|
132
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
if not existed:
|
|
134
|
+
chmod_quiet(d, 0o700)
|
|
135
|
+
gitignore = d / ".gitignore"
|
|
136
|
+
if not gitignore.exists():
|
|
137
|
+
try:
|
|
138
|
+
gitignore.write_text("# Created by localcode. Local state only, never commit.\n*\n")
|
|
139
|
+
except OSError:
|
|
140
|
+
pass
|
|
141
|
+
return d
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def global_state_dir() -> Path:
|
|
145
|
+
"""Return (and create) `~/.localcode/`.
|
|
146
|
+
|
|
147
|
+
Reserved for machine-wide singletons (llama-server PID, stuck
|
|
148
|
+
server marker, user-global config, model cache). Anything that
|
|
149
|
+
belongs to a specific project should NOT live here.
|
|
150
|
+
"""
|
|
151
|
+
existed = GLOBAL_STATE_DIR.is_dir()
|
|
152
|
+
GLOBAL_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
if not existed:
|
|
154
|
+
chmod_quiet(GLOBAL_STATE_DIR, 0o700)
|
|
155
|
+
return GLOBAL_STATE_DIR
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── Specific artifact paths ──────────────────────────────────────────
|
|
159
|
+
#
|
|
160
|
+
# Each helper returns the canonical path for one artifact. Consumers
|
|
161
|
+
# should import the helper, never hardcode `Path.home() / ".localcode"`.
|
|
162
|
+
# That way moving an artifact between project / global tiers requires
|
|
163
|
+
# changing exactly one line here, not hunting through the codebase.
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def lifecycle_log_path(cwd: Optional[Path] = None) -> Path:
|
|
167
|
+
"""DEPRECATED — use `events_log_path()`. Kept as a back-compat shim
|
|
168
|
+
while in-flight references are migrated; will be removed once the
|
|
169
|
+
consolidation lands fully and no caller imports this name."""
|
|
170
|
+
return project_state_dir(cwd) / "lifecycle.log"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def events_log_path(cwd: Optional[Path] = None) -> Path:
|
|
174
|
+
"""Single source of truth for ALL structured developer events:
|
|
175
|
+
server lifecycle, tool calls, redactions, pressure kills, stall
|
|
176
|
+
recovery, turn start/end. JSONL — one event per line, each line is
|
|
177
|
+
valid JSON with at minimum `t` (ISO timestamp) and `type` fields.
|
|
178
|
+
|
|
179
|
+
Per-project (lives at `<project_root>/.localcode/events.jsonl`)
|
|
180
|
+
so each project gets its own audit trail. Append-only; never
|
|
181
|
+
truncated. Local-only: nothing is uploaded anywhere. Designed to be
|
|
182
|
+
tail-able with `jq` for live debugging. Any future remote sink would
|
|
183
|
+
be a new opt-in feature that must strip user content first, not a
|
|
184
|
+
flag flip."""
|
|
185
|
+
return project_state_dir(cwd) / "events.jsonl"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def pressure_kill_marker_path(cwd: Optional[Path] = None) -> Path:
|
|
189
|
+
"""Marker the pressure monitor writes when it SIGTERMs the server.
|
|
190
|
+
Per-project so an error message in project A doesn't reference a
|
|
191
|
+
pressure-kill that happened while you were working on project B."""
|
|
192
|
+
return project_state_dir(cwd) / "last-pressure-kill.txt"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def last_error_log_path(cwd: Optional[Path] = None) -> Path:
|
|
196
|
+
"""Verbose technical detail of the most recent user-facing error
|
|
197
|
+
in THIS project — referenced by the error formatter's "(full
|
|
198
|
+
technical detail saved to ...)" line."""
|
|
199
|
+
return project_state_dir(cwd) / "last_error.log"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def notebook_root(cwd: Optional[Path] = None) -> Path:
|
|
203
|
+
"""Per-project notebook root. Each session creates a subdirectory
|
|
204
|
+
`<notebook_root>/<session_id>/`. Per-project so the agent's working
|
|
205
|
+
drafts for project A aren't visible / reachable from project B."""
|
|
206
|
+
return project_state_dir(cwd) / "notebook"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_results_root(cwd: Optional[Path] = None) -> Path:
|
|
210
|
+
"""Where `tests/e2e/run.py` writes per-run reports. Per-project
|
|
211
|
+
because test results pertain to the project being tested, and they
|
|
212
|
+
should be `git ignore`-able (and visible in the project tree)."""
|
|
213
|
+
return project_state_dir(cwd) / "test-results"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ── Global-only paths ────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def server_pid_file() -> Path:
|
|
220
|
+
"""llama-server PID file. STAYS GLOBAL because only one
|
|
221
|
+
llama-server can run on the machine at a time (one port, one chunk
|
|
222
|
+
of GPU memory). If you `cd` between projects mid-session the
|
|
223
|
+
running server stays serving you — the per-project lifecycle log
|
|
224
|
+
just records a fresh `server_started` entry on next launch from
|
|
225
|
+
the new project."""
|
|
226
|
+
return global_state_dir() / "llama-server.pid"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def stuck_server_marker_path() -> Path:
|
|
230
|
+
"""D-state marker for a server that won't die. Global because the
|
|
231
|
+
stuck process is a system-level concern, not a project concern."""
|
|
232
|
+
return global_state_dir() / "stuck-server.txt"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ── Write containment ────────────────────────────────────────────────
|
|
236
|
+
#
|
|
237
|
+
# Every file-writing tool funnels its `path` argument through
|
|
238
|
+
# `contain_write_path()`. Before this existed, `ToolContext.resolve_path`
|
|
239
|
+
# computed `repo / raw` and returned it untouched — and because
|
|
240
|
+
# `Path.__truediv__` DISCARDS the left operand when the right one is
|
|
241
|
+
# absolute, `raw="/Users/victim/.zshrc"` produced exactly that path. A
|
|
242
|
+
# `../../..` prefix escaped just as easily, and a symlink committed inside
|
|
243
|
+
# the repo pointing outside was a third route (pathlib writes follow
|
|
244
|
+
# symlinks). Reads are deliberately NOT contained — the agent is expected to
|
|
245
|
+
# read anywhere on the machine — but writes now stay inside the project.
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class PathContainmentError(ValueError):
|
|
249
|
+
"""A write was aimed outside the project root (or an allowed extra root)."""
|
|
250
|
+
|
|
251
|
+
def __init__(self, raw: str, resolved: Path, root: Path):
|
|
252
|
+
self.raw = raw
|
|
253
|
+
self.resolved = resolved
|
|
254
|
+
self.root = root
|
|
255
|
+
super().__init__(
|
|
256
|
+
f"path escapes the project root: {raw!r} resolves to {resolved} "
|
|
257
|
+
f"which is outside {root}. Write only inside the project."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def is_within(path: Path, root: Path) -> bool:
|
|
262
|
+
"""True if `path`, fully resolved, lives at or under `root`.
|
|
263
|
+
|
|
264
|
+
Symlink-aware in both directions: `.resolve()` follows every component
|
|
265
|
+
(including the final one), so a symlink inside the repo whose target
|
|
266
|
+
escapes resolves to the escaping target and returns False. Resolving
|
|
267
|
+
`root` too keeps `/tmp` → `/private/tmp` style prefixes from producing
|
|
268
|
+
false negatives on macOS.
|
|
269
|
+
"""
|
|
270
|
+
try:
|
|
271
|
+
return Path(path).resolve().is_relative_to(Path(root).resolve())
|
|
272
|
+
except (OSError, ValueError):
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def contain_write_path(
|
|
277
|
+
candidate: Path | str,
|
|
278
|
+
root: Path | str,
|
|
279
|
+
extra_roots: "tuple[Path, ...] | list[Path] | None" = None,
|
|
280
|
+
) -> Path:
|
|
281
|
+
"""Return the resolved write target, or raise `PathContainmentError`.
|
|
282
|
+
|
|
283
|
+
`candidate` is the already-healed absolute path (see
|
|
284
|
+
`ToolContext.resolve_path`); `root` is the project root. `extra_roots`
|
|
285
|
+
are additional sanctioned trees (the agent's notebook scratch dir) that
|
|
286
|
+
may legitimately sit outside the project.
|
|
287
|
+
"""
|
|
288
|
+
cand = Path(candidate)
|
|
289
|
+
try:
|
|
290
|
+
resolved = cand.resolve()
|
|
291
|
+
except (OSError, ValueError):
|
|
292
|
+
resolved = cand
|
|
293
|
+
roots = [Path(root)]
|
|
294
|
+
for extra in (extra_roots or ()):
|
|
295
|
+
roots.append(Path(extra))
|
|
296
|
+
for r in roots:
|
|
297
|
+
if is_within(resolved, r):
|
|
298
|
+
return resolved
|
|
299
|
+
try:
|
|
300
|
+
root_resolved = Path(root).resolve()
|
|
301
|
+
except (OSError, ValueError):
|
|
302
|
+
root_resolved = Path(root)
|
|
303
|
+
raise PathContainmentError(str(candidate), resolved, root_resolved)
|