delegate-agent-cli 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- delegate_agent/__init__.py +5 -0
- delegate_agent/archived_logs.py +44 -0
- delegate_agent/argv_builders.py +423 -0
- delegate_agent/argv_utils.py +36 -0
- delegate_agent/capability_commands.py +99 -0
- delegate_agent/cli.py +987 -0
- delegate_agent/cli_parser.py +1627 -0
- delegate_agent/command_errors.py +29 -0
- delegate_agent/command_help.py +1264 -0
- delegate_agent/config.py +1117 -0
- delegate_agent/config_commands.py +143 -0
- delegate_agent/constants.py +50 -0
- delegate_agent/describe_payload.py +1018 -0
- delegate_agent/errors.py +32 -0
- delegate_agent/git_utils.py +180 -0
- delegate_agent/harness_events.py +719 -0
- delegate_agent/inspection_commands.py +104 -0
- delegate_agent/isolation.py +316 -0
- delegate_agent/json_types.py +18 -0
- delegate_agent/log_output.py +78 -0
- delegate_agent/private_io.py +109 -0
- delegate_agent/profile_commands.py +42 -0
- delegate_agent/profile_guard.py +116 -0
- delegate_agent/profiles.py +389 -0
- delegate_agent/prompt_instructions.py +39 -0
- delegate_agent/prompt_transport.py +12 -0
- delegate_agent/reasoning.py +916 -0
- delegate_agent/redaction.py +193 -0
- delegate_agent/rendering.py +573 -0
- delegate_agent/request_build.py +1681 -0
- delegate_agent/request_models.py +191 -0
- delegate_agent/retention.py +321 -0
- delegate_agent/run_metadata.py +78 -0
- delegate_agent/run_output_commands.py +645 -0
- delegate_agent/run_registry.py +747 -0
- delegate_agent/run_status.py +300 -0
- delegate_agent/runner.py +1830 -0
- delegate_agent/safe_workspace.py +821 -0
- delegate_agent/snapshot_view.py +229 -0
- delegate_agent/wait_cancel_commands.py +559 -0
- delegate_agent/worktree_commands.py +218 -0
- delegate_agent/worktree_execution.py +654 -0
- delegate_agent/worktree_gc.py +529 -0
- delegate_agent/worktree_mgmt.py +782 -0
- delegate_agent/worktree_records.py +232 -0
- delegate_agent/worktree_remove.py +547 -0
- delegate_agent/worktree_summary.py +242 -0
- delegate_agent/wsl.py +65 -0
- delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
- delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
- delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
- delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
- delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
- delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
"""Temporary safe-mode workspace isolation.
|
|
2
|
+
|
|
3
|
+
Safe-mode runs execute against a throwaway copy of the workspace so the
|
|
4
|
+
delegated harness cannot mutate the real checkout. This module owns that
|
|
5
|
+
machinery: detached-worktree / directory-copy creation, tracked-diff sync,
|
|
6
|
+
external-symlink blocking (so a symlink can't escape the sandbox), and the
|
|
7
|
+
``safe_isolated_request`` context manager that swaps a request onto the
|
|
8
|
+
isolated workspace for the duration of a run and tears it down afterwards.
|
|
9
|
+
|
|
10
|
+
This is the temporary-isolation twin of ``isolation.py`` (which owns
|
|
11
|
+
persistent-worktree planning/creation); both are deliberately kept distinct.
|
|
12
|
+
The security constraints here — symlink containment, ``.git``/``.delegate``
|
|
13
|
+
exclusion, atomic writes — are load-bearing; preserve them exactly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import stat
|
|
22
|
+
import subprocess # nosec B404 - Delegate launches configured git/harness commands with shell=False.
|
|
23
|
+
import tempfile
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
from contextlib import contextmanager, suppress
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from delegate_agent.argv_utils import public_argv
|
|
29
|
+
from delegate_agent.argv_utils import replace_workspace_arg_in_argv as _replace_ws_by_engine
|
|
30
|
+
from delegate_agent.errors import DelegateError
|
|
31
|
+
from delegate_agent.git_utils import (
|
|
32
|
+
GIT_MUTATION_TIMEOUT_SECONDS,
|
|
33
|
+
GIT_QUICK_TIMEOUT_SECONDS,
|
|
34
|
+
)
|
|
35
|
+
from delegate_agent.git_utils import run_git as _run_git
|
|
36
|
+
from delegate_agent.git_utils import run_git_bytes as _run_git_bytes
|
|
37
|
+
from delegate_agent.isolation import IsolationContext
|
|
38
|
+
from delegate_agent.json_types import JsonObject
|
|
39
|
+
from delegate_agent.request_models import Request
|
|
40
|
+
|
|
41
|
+
# Project .cursor/cli.json is permissions-only; global cli-config examples may
|
|
42
|
+
# include other top-level keys such as "version", but Cursor rejects them here.
|
|
43
|
+
CURSOR_SAFE_CLI_CONFIG: JsonObject = {
|
|
44
|
+
"permissions": {
|
|
45
|
+
"allow": [
|
|
46
|
+
"Read(**)",
|
|
47
|
+
"Shell(rg)",
|
|
48
|
+
"Shell(grep)",
|
|
49
|
+
"Shell(cat)",
|
|
50
|
+
"Shell(head)",
|
|
51
|
+
"Shell(tail)",
|
|
52
|
+
"Shell(wc)",
|
|
53
|
+
],
|
|
54
|
+
"deny": [
|
|
55
|
+
"Write(**)",
|
|
56
|
+
"Shell(rm)",
|
|
57
|
+
"Shell(mv)",
|
|
58
|
+
"Shell(tee)",
|
|
59
|
+
"Shell(curl)",
|
|
60
|
+
"Shell(wget)",
|
|
61
|
+
"Read(.env*)",
|
|
62
|
+
"Read(**/.env*)",
|
|
63
|
+
"Read(**/id_rsa*)",
|
|
64
|
+
"Read(**/*.pem)",
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
SAFE_UNBORN_GIT_WARNING = (
|
|
70
|
+
"Git repository has no commits; safe isolation used a directory copy instead "
|
|
71
|
+
"of a detached git worktree."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
SAFE_EXTERNAL_SYMLINK_WARNING_PREFIX = (
|
|
75
|
+
"Safe isolation blocked external symlink(s); placeholder files were used "
|
|
76
|
+
"inside the isolated workspace"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
SAFE_BLOCKED_SYMLINK_PLACEHOLDER = "External symlink blocked by Delegate safe isolation.\n"
|
|
80
|
+
|
|
81
|
+
SAFE_CHECK_IGNORE_FAIL_CLOSED_WARNING = (
|
|
82
|
+
"Safe isolation could not verify gitignore status for one or more untracked "
|
|
83
|
+
"symlink target(s); fail-closed replaced all queried symlinks with placeholders."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _ensure_codex_skip_git_repo_check(argv: list[str]) -> list[str]:
|
|
88
|
+
if "--skip-git-repo-check" in argv:
|
|
89
|
+
return argv
|
|
90
|
+
updated = list(argv)
|
|
91
|
+
# Codex exec options belong before the final prompt argument.
|
|
92
|
+
insert_at = max(len(updated) - 1, 0)
|
|
93
|
+
updated.insert(insert_at, "--skip-git-repo-check")
|
|
94
|
+
return updated
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def replace_safe_workspace_arg_in_argv(
|
|
98
|
+
request: Request,
|
|
99
|
+
argv: list[str],
|
|
100
|
+
isolated_workspace: str,
|
|
101
|
+
*,
|
|
102
|
+
workspace_kind: str | None = None,
|
|
103
|
+
) -> list[str]:
|
|
104
|
+
updated = _replace_ws_by_engine(request.engine, argv, isolated_workspace)
|
|
105
|
+
if request.engine == "codex" and workspace_kind == "directory":
|
|
106
|
+
updated = _ensure_codex_skip_git_repo_check(updated)
|
|
107
|
+
return updated
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def write_text_atomic(path: Path, content: str) -> None:
|
|
111
|
+
temporary_path: Path | None = None
|
|
112
|
+
try:
|
|
113
|
+
with tempfile.NamedTemporaryFile(
|
|
114
|
+
"w",
|
|
115
|
+
encoding="utf-8",
|
|
116
|
+
dir=path.parent,
|
|
117
|
+
prefix=f".{path.name}.",
|
|
118
|
+
suffix=".tmp",
|
|
119
|
+
delete=False,
|
|
120
|
+
) as temporary_file:
|
|
121
|
+
temporary_path = Path(temporary_file.name)
|
|
122
|
+
temporary_file.write(content)
|
|
123
|
+
temporary_path.replace(path)
|
|
124
|
+
except OSError:
|
|
125
|
+
if temporary_path is not None:
|
|
126
|
+
with suppress(OSError):
|
|
127
|
+
temporary_path.unlink()
|
|
128
|
+
raise
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def write_cursor_safe_project_config(workspace: Path) -> None:
|
|
132
|
+
config_dir = workspace / ".cursor"
|
|
133
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
write_text_atomic(
|
|
135
|
+
config_dir / "cli.json",
|
|
136
|
+
json.dumps(CURSOR_SAFE_CLI_CONFIG, indent=2) + "\n",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def read_git_tracked_diff(git_root: str) -> bytes:
|
|
141
|
+
diff = _run_git_bytes(
|
|
142
|
+
git_root,
|
|
143
|
+
["diff", "HEAD", "--binary"],
|
|
144
|
+
timeout_seconds=GIT_MUTATION_TIMEOUT_SECONDS,
|
|
145
|
+
)
|
|
146
|
+
if diff.returncode != 0:
|
|
147
|
+
stderr = diff.stderr.decode(errors="replace").strip()
|
|
148
|
+
raise DelegateError("safe_workspace_sync_failed", f"Failed to read tracked diff: {stderr}")
|
|
149
|
+
return diff.stdout
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def apply_git_tracked_diff(worktree_path: str, diff: bytes) -> None:
|
|
153
|
+
if not diff.strip():
|
|
154
|
+
return
|
|
155
|
+
applied = _run_git_bytes(
|
|
156
|
+
worktree_path,
|
|
157
|
+
["apply", "--whitespace=nowarn"],
|
|
158
|
+
input_bytes=diff,
|
|
159
|
+
timeout_seconds=GIT_MUTATION_TIMEOUT_SECONDS,
|
|
160
|
+
)
|
|
161
|
+
if applied.returncode != 0:
|
|
162
|
+
stderr = applied.stderr.decode(errors="replace").strip()
|
|
163
|
+
raise DelegateError(
|
|
164
|
+
"safe_workspace_sync_failed",
|
|
165
|
+
f"Failed to apply tracked diff to isolated workspace: {stderr}",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _git_lines(git_root: str, args: list[str], *, error: str) -> list[str]:
|
|
170
|
+
result = _run_git(
|
|
171
|
+
git_root,
|
|
172
|
+
args,
|
|
173
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
174
|
+
)
|
|
175
|
+
if result.returncode != 0:
|
|
176
|
+
raise DelegateError("safe_workspace_sync_failed", f"{error}: {result.stderr.strip()}")
|
|
177
|
+
return [line for line in result.stdout.splitlines() if line]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def changed_files_vs_head(git_root: str) -> tuple[str, ...]:
|
|
181
|
+
"""Return tracked HEAD diff paths plus untracked non-ignored paths."""
|
|
182
|
+
paths: list[str] = []
|
|
183
|
+
seen: set[str] = set()
|
|
184
|
+
for line in _git_lines(
|
|
185
|
+
git_root,
|
|
186
|
+
["diff", "HEAD", "--name-only"],
|
|
187
|
+
error="Failed to list tracked changes",
|
|
188
|
+
) + _git_lines(
|
|
189
|
+
git_root,
|
|
190
|
+
["ls-files", "--others", "--exclude-standard"],
|
|
191
|
+
error="Failed to list untracked files",
|
|
192
|
+
):
|
|
193
|
+
if line in seen:
|
|
194
|
+
continue
|
|
195
|
+
seen.add(line)
|
|
196
|
+
paths.append(line)
|
|
197
|
+
return tuple(paths)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _is_relative_to(path: Path, root: Path) -> bool:
|
|
201
|
+
try:
|
|
202
|
+
path.relative_to(root)
|
|
203
|
+
return True
|
|
204
|
+
except ValueError:
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def symlink_target_resolves_outside(path: Path, source_root: Path) -> bool:
|
|
209
|
+
"""Return true when ``path`` is a symlink whose target leaves ``source_root``."""
|
|
210
|
+
if not path.is_symlink():
|
|
211
|
+
return False
|
|
212
|
+
try:
|
|
213
|
+
root_resolved = source_root.resolve(strict=True)
|
|
214
|
+
target = (path.parent / os.readlink(path)).resolve(strict=False)
|
|
215
|
+
except OSError:
|
|
216
|
+
return False
|
|
217
|
+
return not _is_relative_to(target, root_resolved)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _git_check_ignore(git_root: str, paths: list[str]) -> tuple[set[str], bool]:
|
|
221
|
+
"""Return the subset of ``paths`` that Git reports as ignored.
|
|
222
|
+
|
|
223
|
+
Uses a single batched ``git check-ignore -z --stdin`` invocation with
|
|
224
|
+
NUL-separated input and output so a large untracked set never spawns one
|
|
225
|
+
subprocess per path and so paths containing newlines are handled correctly.
|
|
226
|
+
``git check-ignore`` exits 0 when at least one path is ignored, 1 when none
|
|
227
|
+
are, and any other code (e.g. 128) on error.
|
|
228
|
+
|
|
229
|
+
Returns ``(ignored, fail_closed)``:
|
|
230
|
+
- exit 1 -> ``({}, False)`` (clean: nothing ignored).
|
|
231
|
+
- exit 0 -> parse NUL-separated stdout -> ``(ignored, False)``.
|
|
232
|
+
- any other exit -> ``({}, True)``: FAIL CLOSED for the batch. The caller
|
|
233
|
+
must treat every queried target as ignored (placeholder the symlinks) and
|
|
234
|
+
emit a warning, but must not raise and abort the whole sync.
|
|
235
|
+
"""
|
|
236
|
+
if not paths:
|
|
237
|
+
return set(), False
|
|
238
|
+
result = _run_git_bytes(
|
|
239
|
+
git_root,
|
|
240
|
+
["check-ignore", "-z", "--stdin"],
|
|
241
|
+
input_bytes=b"\x00".join(p.encode("utf-8") for p in paths) + b"\x00",
|
|
242
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
243
|
+
)
|
|
244
|
+
if result.returncode == 1:
|
|
245
|
+
return set(), False
|
|
246
|
+
if result.returncode != 0:
|
|
247
|
+
return set(), True
|
|
248
|
+
ignored: set[str] = set()
|
|
249
|
+
for token in result.stdout.decode(errors="replace").split("\x00"):
|
|
250
|
+
if token:
|
|
251
|
+
ignored.add(token)
|
|
252
|
+
return ignored, False
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _classify_untracked_symlink_leaks(
|
|
256
|
+
git_root: str,
|
|
257
|
+
untracked: list[str],
|
|
258
|
+
root: Path,
|
|
259
|
+
root_resolved: Path,
|
|
260
|
+
) -> tuple[set[str], tuple[str, ...]]:
|
|
261
|
+
"""Return relative paths of untracked symlinks blocked by the leak rule.
|
|
262
|
+
|
|
263
|
+
A symlink is recreated only when its readlink target is RELATIVE, resolves
|
|
264
|
+
INSIDE ``git_root``, and the resolved target is NOT gitignored. Anything
|
|
265
|
+
else is a leak risk and is blocked here. External symlinks (target resolves
|
|
266
|
+
outside ``git_root``) are deliberately excluded: they are already caught by
|
|
267
|
+
``symlink_target_resolves_outside`` and reported via
|
|
268
|
+
``external_symlink_warnings``, so reporting them here would duplicate the
|
|
269
|
+
existing warning channel. The remaining cases -- an absolute readlink whose
|
|
270
|
+
target resolves inside the repo, or a relative-inside symlink whose target
|
|
271
|
+
is gitignored -- are the leak cases this function returns so the caller can
|
|
272
|
+
placeholder them and emit one consolidated warning.
|
|
273
|
+
|
|
274
|
+
``git check-ignore`` is invoked once (batched over all inside-root targets)
|
|
275
|
+
rather than per symlink. If that probe fails with an unexpected exit code,
|
|
276
|
+
the batch FAILS CLOSED: every queried inside-root symlink is placeholdered
|
|
277
|
+
and a distinct warning is emitted, but the sync is not aborted.
|
|
278
|
+
|
|
279
|
+
Returns ``(leak_blocked, warnings)`` where ``warnings`` carries any
|
|
280
|
+
fail-closed notice (the per-path blocked-symlink warning is emitted
|
|
281
|
+
separately by the caller via ``_leak_blocked_symlink_warning``).
|
|
282
|
+
"""
|
|
283
|
+
leak_blocked: set[str] = set()
|
|
284
|
+
inside_targets: dict[str, str] = {}
|
|
285
|
+
warnings: tuple[str, ...] = ()
|
|
286
|
+
for relative in untracked:
|
|
287
|
+
if not relative:
|
|
288
|
+
continue
|
|
289
|
+
source = root / relative
|
|
290
|
+
if not source.is_symlink():
|
|
291
|
+
continue
|
|
292
|
+
# External symlinks are handled by the resolves-outside path; skip them
|
|
293
|
+
# so we do not double-report on the existing warning channel.
|
|
294
|
+
if symlink_target_resolves_outside(source, root):
|
|
295
|
+
continue
|
|
296
|
+
try:
|
|
297
|
+
readlink_target = os.readlink(source)
|
|
298
|
+
except OSError:
|
|
299
|
+
leak_blocked.add(relative)
|
|
300
|
+
continue
|
|
301
|
+
if os.path.isabs(readlink_target):
|
|
302
|
+
# Absolute readlink is never recreated, even when it resolves inside
|
|
303
|
+
# the repo (it encodes a host path and can point at gitignored
|
|
304
|
+
# content). External absolute symlinks were skipped above.
|
|
305
|
+
leak_blocked.add(relative)
|
|
306
|
+
continue
|
|
307
|
+
try:
|
|
308
|
+
resolved = (source.parent / readlink_target).resolve(strict=False)
|
|
309
|
+
except OSError:
|
|
310
|
+
leak_blocked.add(relative)
|
|
311
|
+
continue
|
|
312
|
+
if not _is_relative_to(resolved, root_resolved):
|
|
313
|
+
# Escaping relative link; resolves_outside handles it.
|
|
314
|
+
continue
|
|
315
|
+
try:
|
|
316
|
+
inside_targets[relative] = resolved.relative_to(root_resolved).as_posix()
|
|
317
|
+
except ValueError:
|
|
318
|
+
leak_blocked.add(relative)
|
|
319
|
+
if inside_targets:
|
|
320
|
+
ignored, fail_closed = _git_check_ignore(git_root, list(inside_targets.values()))
|
|
321
|
+
if fail_closed:
|
|
322
|
+
# FAIL CLOSED: treat every queried inside-root symlink as a leak
|
|
323
|
+
# risk and placeholder it. Do not raise; the sync still completes.
|
|
324
|
+
leak_blocked.update(inside_targets.keys())
|
|
325
|
+
warnings = (SAFE_CHECK_IGNORE_FAIL_CLOSED_WARNING,)
|
|
326
|
+
else:
|
|
327
|
+
for symlink_rel, target_rel in inside_targets.items():
|
|
328
|
+
if target_rel in ignored:
|
|
329
|
+
leak_blocked.add(symlink_rel)
|
|
330
|
+
return leak_blocked, warnings
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _leak_blocked_symlink_warning(leak_blocked: set[str], *, limit: int = 5) -> tuple[str, ...]:
|
|
334
|
+
"""Emit the existing blocked-symlink warning for leak-rule placeholders."""
|
|
335
|
+
if not leak_blocked:
|
|
336
|
+
return ()
|
|
337
|
+
paths = sorted(leak_blocked)
|
|
338
|
+
preview = ", ".join(paths[:limit])
|
|
339
|
+
remaining = len(paths) - limit
|
|
340
|
+
if remaining > 0:
|
|
341
|
+
preview = f"{preview}, ... (+{remaining} more)"
|
|
342
|
+
return (f"{SAFE_EXTERNAL_SYMLINK_WARNING_PREFIX}: {preview}.",)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def write_blocked_symlink_placeholder(path: Path) -> None:
|
|
346
|
+
if path.exists() or path.is_symlink():
|
|
347
|
+
path.unlink()
|
|
348
|
+
write_text_atomic(path, SAFE_BLOCKED_SYMLINK_PLACEHOLDER)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _symlink_dirnames(current_path: Path, dirnames: list[str]) -> set[str]:
|
|
352
|
+
symlink_names: set[str] = set()
|
|
353
|
+
for name in dirnames:
|
|
354
|
+
try:
|
|
355
|
+
is_symlink = (current_path / name).is_symlink()
|
|
356
|
+
except OSError:
|
|
357
|
+
# Treat unreadable/racing directory entries as non-descendable.
|
|
358
|
+
is_symlink = True
|
|
359
|
+
if is_symlink:
|
|
360
|
+
symlink_names.add(name)
|
|
361
|
+
return symlink_names
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _block_external_symlink_if_needed(
|
|
365
|
+
path: Path,
|
|
366
|
+
*,
|
|
367
|
+
isolated_root: Path,
|
|
368
|
+
source_root: Path,
|
|
369
|
+
containment_root: Path,
|
|
370
|
+
) -> str | None:
|
|
371
|
+
try:
|
|
372
|
+
if not path.is_symlink():
|
|
373
|
+
return None
|
|
374
|
+
relative = path.relative_to(isolated_root)
|
|
375
|
+
source_path = source_root / relative
|
|
376
|
+
if not symlink_target_resolves_outside(source_path, containment_root):
|
|
377
|
+
return None
|
|
378
|
+
write_blocked_symlink_placeholder(path)
|
|
379
|
+
return relative.as_posix()
|
|
380
|
+
except (OSError, ValueError):
|
|
381
|
+
# Filesystem walks can race with edits; skip only the unstable entry.
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def block_external_symlinks(
|
|
386
|
+
isolated_workspace: str | Path,
|
|
387
|
+
source_workspace: str | Path,
|
|
388
|
+
*,
|
|
389
|
+
containment_root: str | Path | None = None,
|
|
390
|
+
limit: int = 5,
|
|
391
|
+
) -> tuple[str, ...]:
|
|
392
|
+
"""Replace external symlinks in a safe workspace with inert placeholders.
|
|
393
|
+
|
|
394
|
+
The isolated tree mirrors the source layout, so each isolated symlink can be
|
|
395
|
+
evaluated against its matching source path. Internal symlinks stay intact;
|
|
396
|
+
links whose source target resolves outside the source workspace are replaced
|
|
397
|
+
with a small placeholder file that does not disclose the external target.
|
|
398
|
+
"""
|
|
399
|
+
isolated_root = Path(isolated_workspace)
|
|
400
|
+
source_root = Path(source_workspace)
|
|
401
|
+
root_for_containment = Path(containment_root) if containment_root is not None else source_root
|
|
402
|
+
blocked: list[str] = []
|
|
403
|
+
# Each filesystem touch is guarded individually so a single unreadable or
|
|
404
|
+
# racing entry skips only itself rather than aborting the whole sweep and
|
|
405
|
+
# silently leaving the remaining external symlinks unblocked.
|
|
406
|
+
for current, dirnames, filenames in os.walk(isolated_root):
|
|
407
|
+
current_path = Path(current)
|
|
408
|
+
original_symlink_dirnames = _symlink_dirnames(current_path, dirnames)
|
|
409
|
+
for name in list(dirnames) + list(filenames):
|
|
410
|
+
path = current_path / name
|
|
411
|
+
blocked_link = _block_external_symlink_if_needed(
|
|
412
|
+
path,
|
|
413
|
+
isolated_root=isolated_root,
|
|
414
|
+
source_root=source_root,
|
|
415
|
+
containment_root=root_for_containment,
|
|
416
|
+
)
|
|
417
|
+
if blocked_link is None:
|
|
418
|
+
continue
|
|
419
|
+
blocked.append(blocked_link)
|
|
420
|
+
dirnames[:] = [
|
|
421
|
+
name
|
|
422
|
+
for name in dirnames
|
|
423
|
+
if name not in {".git", ".delegate"} and name not in original_symlink_dirnames
|
|
424
|
+
]
|
|
425
|
+
if not blocked:
|
|
426
|
+
return ()
|
|
427
|
+
blocked.sort()
|
|
428
|
+
preview = ", ".join(blocked[:limit])
|
|
429
|
+
remaining = len(blocked) - limit
|
|
430
|
+
if remaining > 0:
|
|
431
|
+
preview = f"{preview}, ... (+{remaining} more)"
|
|
432
|
+
return (f"{SAFE_EXTERNAL_SYMLINK_WARNING_PREFIX}: {preview}.",)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def merge_warnings(*groups: tuple[str, ...]) -> tuple[str, ...]:
|
|
436
|
+
merged: list[str] = []
|
|
437
|
+
seen: set[str] = set()
|
|
438
|
+
for group in groups:
|
|
439
|
+
for warning in group:
|
|
440
|
+
if warning in seen:
|
|
441
|
+
continue
|
|
442
|
+
seen.add(warning)
|
|
443
|
+
merged.append(warning)
|
|
444
|
+
return tuple(merged)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def mirror_path_preserving_symlinks(
|
|
448
|
+
source: Path,
|
|
449
|
+
destination: Path,
|
|
450
|
+
*,
|
|
451
|
+
source_root: Path | None = None,
|
|
452
|
+
leak_blocked: set[str] | None = None,
|
|
453
|
+
) -> None:
|
|
454
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
455
|
+
if source.is_symlink():
|
|
456
|
+
relative: str | None = None
|
|
457
|
+
if source_root is not None:
|
|
458
|
+
with suppress(ValueError):
|
|
459
|
+
relative = source.relative_to(source_root).as_posix()
|
|
460
|
+
if relative is not None and relative in (leak_blocked or set()):
|
|
461
|
+
write_blocked_symlink_placeholder(destination)
|
|
462
|
+
return
|
|
463
|
+
if source_root is not None and symlink_target_resolves_outside(source, source_root):
|
|
464
|
+
write_blocked_symlink_placeholder(destination)
|
|
465
|
+
else:
|
|
466
|
+
if destination.exists() or destination.is_symlink():
|
|
467
|
+
destination.unlink()
|
|
468
|
+
os.symlink(os.readlink(source), destination)
|
|
469
|
+
return
|
|
470
|
+
if source.is_dir():
|
|
471
|
+
shutil.copytree(
|
|
472
|
+
source,
|
|
473
|
+
destination,
|
|
474
|
+
dirs_exist_ok=True,
|
|
475
|
+
symlinks=True,
|
|
476
|
+
ignore=_copytree_ignore_safe_workspace,
|
|
477
|
+
)
|
|
478
|
+
if source_root is not None:
|
|
479
|
+
block_external_symlinks(
|
|
480
|
+
destination,
|
|
481
|
+
source,
|
|
482
|
+
containment_root=source_root,
|
|
483
|
+
)
|
|
484
|
+
return
|
|
485
|
+
if not source.is_file():
|
|
486
|
+
return
|
|
487
|
+
shutil.copy2(source, destination)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _copytree_ignore_safe_workspace(directory: str, names: list[str]) -> set[str]:
|
|
491
|
+
ignored = {".git", ".delegate"} & set(names)
|
|
492
|
+
for name in names:
|
|
493
|
+
path = Path(directory) / name
|
|
494
|
+
try:
|
|
495
|
+
mode = path.lstat().st_mode
|
|
496
|
+
except OSError:
|
|
497
|
+
ignored.add(name)
|
|
498
|
+
continue
|
|
499
|
+
if stat.S_ISREG(mode) or stat.S_ISDIR(mode) or stat.S_ISLNK(mode):
|
|
500
|
+
continue
|
|
501
|
+
ignored.add(name)
|
|
502
|
+
return ignored
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _external_symlink_warning_path(path: Path, *, root: Path, root_resolved: Path) -> str | None:
|
|
506
|
+
try:
|
|
507
|
+
if not path.is_symlink():
|
|
508
|
+
return None
|
|
509
|
+
target = (path.parent / os.readlink(path)).resolve(strict=False)
|
|
510
|
+
target.relative_to(root_resolved)
|
|
511
|
+
return None
|
|
512
|
+
except ValueError:
|
|
513
|
+
with suppress(ValueError):
|
|
514
|
+
return path.relative_to(root).as_posix()
|
|
515
|
+
return path.name
|
|
516
|
+
except OSError:
|
|
517
|
+
# Filesystem walks can race with edits; skip only the unstable entry.
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def external_symlink_warnings(source_workspace: str, *, limit: int = 5) -> tuple[str, ...]:
|
|
522
|
+
"""Return a bounded warning when a source tree contains external symlinks."""
|
|
523
|
+
root = Path(source_workspace)
|
|
524
|
+
try:
|
|
525
|
+
root_resolved = root.resolve(strict=True)
|
|
526
|
+
except OSError:
|
|
527
|
+
return ()
|
|
528
|
+
external_links: list[str] = []
|
|
529
|
+
try:
|
|
530
|
+
for current, dirnames, filenames in os.walk(root):
|
|
531
|
+
current_path = Path(current)
|
|
532
|
+
names = list(dirnames) + list(filenames)
|
|
533
|
+
for name in names:
|
|
534
|
+
external_link = _external_symlink_warning_path(
|
|
535
|
+
current_path / name,
|
|
536
|
+
root=root,
|
|
537
|
+
root_resolved=root_resolved,
|
|
538
|
+
)
|
|
539
|
+
if external_link is None:
|
|
540
|
+
continue
|
|
541
|
+
external_links.append(external_link)
|
|
542
|
+
dirnames[:] = [
|
|
543
|
+
name
|
|
544
|
+
for name in dirnames
|
|
545
|
+
if name not in {".git", ".delegate"} and not (current_path / name).is_symlink()
|
|
546
|
+
]
|
|
547
|
+
except OSError:
|
|
548
|
+
return ()
|
|
549
|
+
if not external_links:
|
|
550
|
+
return ()
|
|
551
|
+
external_links.sort()
|
|
552
|
+
preview = ", ".join(external_links[:limit])
|
|
553
|
+
remaining = len(external_links) - limit
|
|
554
|
+
if remaining > 0:
|
|
555
|
+
preview = f"{preview}, ... (+{remaining} more)"
|
|
556
|
+
return (f"{SAFE_EXTERNAL_SYMLINK_WARNING_PREFIX}: {preview}.",)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def sync_git_dirty_snapshot(git_root: str, worktree_path: str) -> tuple[int, tuple[str, ...]]:
|
|
560
|
+
apply_git_tracked_diff(worktree_path, read_git_tracked_diff(git_root))
|
|
561
|
+
changed = changed_files_vs_head(git_root)
|
|
562
|
+
untracked = _git_lines(
|
|
563
|
+
git_root,
|
|
564
|
+
["ls-files", "--others", "--exclude-standard"],
|
|
565
|
+
error="Failed to list untracked files",
|
|
566
|
+
)
|
|
567
|
+
root = Path(git_root)
|
|
568
|
+
try:
|
|
569
|
+
root_resolved = root.resolve(strict=True)
|
|
570
|
+
except OSError:
|
|
571
|
+
root_resolved = root
|
|
572
|
+
leak_blocked, check_ignore_warnings = _classify_untracked_symlink_leaks(
|
|
573
|
+
git_root,
|
|
574
|
+
untracked,
|
|
575
|
+
root,
|
|
576
|
+
root_resolved,
|
|
577
|
+
)
|
|
578
|
+
for relative in untracked:
|
|
579
|
+
if not relative:
|
|
580
|
+
continue
|
|
581
|
+
mirror_path_preserving_symlinks(
|
|
582
|
+
Path(git_root) / relative,
|
|
583
|
+
Path(worktree_path) / relative,
|
|
584
|
+
source_root=Path(git_root),
|
|
585
|
+
leak_blocked=leak_blocked,
|
|
586
|
+
)
|
|
587
|
+
return (
|
|
588
|
+
len(changed),
|
|
589
|
+
merge_warnings(
|
|
590
|
+
external_symlink_warnings(git_root),
|
|
591
|
+
block_external_symlinks(worktree_path, git_root),
|
|
592
|
+
_leak_blocked_symlink_warning(leak_blocked),
|
|
593
|
+
check_ignore_warnings,
|
|
594
|
+
),
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def sync_git_workspace_snapshot(git_root: str, worktree_path: str) -> tuple[str, ...]:
|
|
599
|
+
_count, warnings = sync_git_dirty_snapshot(git_root, worktree_path)
|
|
600
|
+
return warnings
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def git_head_exists(git_root: str) -> bool:
|
|
604
|
+
result = _run_git(
|
|
605
|
+
git_root,
|
|
606
|
+
["rev-parse", "--verify", "HEAD"],
|
|
607
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
608
|
+
)
|
|
609
|
+
return result.returncode == 0
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def discard_git_safe_workspace(
|
|
613
|
+
git_root: str, worktree_path: str, temp_base: str, *, worktree_added: bool
|
|
614
|
+
) -> None:
|
|
615
|
+
if worktree_added:
|
|
616
|
+
remove_git_safe_workspace(git_root, worktree_path)
|
|
617
|
+
shutil.rmtree(temp_base, ignore_errors=True)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def create_git_safe_workspace(
|
|
621
|
+
git_root: str,
|
|
622
|
+
*,
|
|
623
|
+
include_warnings: bool = False,
|
|
624
|
+
) -> tuple[str, str] | tuple[str, str, tuple[str, ...]]:
|
|
625
|
+
temp_base = tempfile.mkdtemp(prefix="delegate-safe-")
|
|
626
|
+
worktree_path = str(Path(temp_base) / "wt")
|
|
627
|
+
worktree_added = False
|
|
628
|
+
warnings: tuple[str, ...] = ()
|
|
629
|
+
try:
|
|
630
|
+
added = _run_git(
|
|
631
|
+
git_root,
|
|
632
|
+
["worktree", "add", "--detach", worktree_path, "HEAD"],
|
|
633
|
+
timeout_seconds=GIT_MUTATION_TIMEOUT_SECONDS,
|
|
634
|
+
)
|
|
635
|
+
if added.returncode != 0:
|
|
636
|
+
raise DelegateError(
|
|
637
|
+
"safe_workspace_create_failed",
|
|
638
|
+
f"Failed to create detached git worktree: {added.stderr.strip()}",
|
|
639
|
+
)
|
|
640
|
+
worktree_added = True
|
|
641
|
+
warnings = sync_git_workspace_snapshot(git_root, worktree_path)
|
|
642
|
+
except Exception:
|
|
643
|
+
discard_git_safe_workspace(
|
|
644
|
+
git_root, worktree_path, temp_base, worktree_added=worktree_added
|
|
645
|
+
)
|
|
646
|
+
raise
|
|
647
|
+
if include_warnings:
|
|
648
|
+
return worktree_path, temp_base, warnings
|
|
649
|
+
return worktree_path, temp_base
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def create_directory_safe_workspace(
|
|
653
|
+
source_workspace: str,
|
|
654
|
+
*,
|
|
655
|
+
include_warnings: bool = False,
|
|
656
|
+
) -> tuple[str, str] | tuple[str, str, tuple[str, ...]]:
|
|
657
|
+
temp_base = tempfile.mkdtemp(prefix="delegate-safe-")
|
|
658
|
+
copy_path = str(Path(temp_base) / "copy")
|
|
659
|
+
warnings: tuple[str, ...] = ()
|
|
660
|
+
try:
|
|
661
|
+
shutil.copytree(
|
|
662
|
+
source_workspace,
|
|
663
|
+
copy_path,
|
|
664
|
+
ignore=_copytree_ignore_safe_workspace,
|
|
665
|
+
dirs_exist_ok=True,
|
|
666
|
+
symlinks=True,
|
|
667
|
+
)
|
|
668
|
+
warnings = merge_warnings(
|
|
669
|
+
external_symlink_warnings(source_workspace),
|
|
670
|
+
block_external_symlinks(copy_path, source_workspace),
|
|
671
|
+
)
|
|
672
|
+
except Exception:
|
|
673
|
+
shutil.rmtree(temp_base, ignore_errors=True)
|
|
674
|
+
raise
|
|
675
|
+
if include_warnings:
|
|
676
|
+
return copy_path, temp_base, warnings
|
|
677
|
+
return copy_path, temp_base
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def remove_git_safe_workspace(git_root: str, worktree_path: str) -> None:
|
|
681
|
+
with suppress(OSError, subprocess.SubprocessError):
|
|
682
|
+
_run_git(
|
|
683
|
+
git_root,
|
|
684
|
+
["worktree", "remove", "--force", worktree_path],
|
|
685
|
+
timeout_seconds=GIT_MUTATION_TIMEOUT_SECONDS,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def cleanup_safe_isolated_workspace(
|
|
690
|
+
*,
|
|
691
|
+
git_root: str | None,
|
|
692
|
+
isolated_workspace: str,
|
|
693
|
+
temp_base: str,
|
|
694
|
+
) -> None:
|
|
695
|
+
if git_root is not None:
|
|
696
|
+
remove_git_safe_workspace(git_root, isolated_workspace)
|
|
697
|
+
shutil.rmtree(temp_base, ignore_errors=True)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@contextmanager
|
|
701
|
+
def safe_isolated_request(request: Request) -> Iterator[Request]:
|
|
702
|
+
"""Context manager that creates a temporary isolated workspace for safe-mode runs.
|
|
703
|
+
|
|
704
|
+
Respects the isolation context:
|
|
705
|
+
- effective_isolation == "none": skip isolation, yield original request.
|
|
706
|
+
- effective_isolation == "worktree": create temp git worktree (or dir copy
|
|
707
|
+
for auto legacy fallback). For cursor, writes .cursor/cli.json in the
|
|
708
|
+
isolated workspace only.
|
|
709
|
+
"""
|
|
710
|
+
ctx = request.isolation_context
|
|
711
|
+
effective = ctx.effective_isolation if ctx is not None else None
|
|
712
|
+
|
|
713
|
+
# No isolation needed.
|
|
714
|
+
if effective != "worktree":
|
|
715
|
+
yield request
|
|
716
|
+
return
|
|
717
|
+
|
|
718
|
+
# Isolation is worktree — create temp workspace.
|
|
719
|
+
isolation_mode = ctx.isolation_mode if ctx is not None else "auto"
|
|
720
|
+
source_git_root = request.workspace if request.workspace_kind == "git" else None
|
|
721
|
+
cleanup_git_root: str | None = None
|
|
722
|
+
workspace_kind = request.workspace_kind
|
|
723
|
+
safe_workspace_method: str | None = None
|
|
724
|
+
warnings_list: list[str] = []
|
|
725
|
+
safe_workspace_warnings: tuple[str, ...] = ()
|
|
726
|
+
|
|
727
|
+
if source_git_root is not None and git_head_exists(source_git_root):
|
|
728
|
+
isolated_workspace, temp_base, safe_workspace_warnings = create_git_safe_workspace(
|
|
729
|
+
source_git_root,
|
|
730
|
+
include_warnings=True,
|
|
731
|
+
)
|
|
732
|
+
cleanup_git_root = source_git_root
|
|
733
|
+
safe_workspace_method = "git-worktree"
|
|
734
|
+
elif source_git_root is not None:
|
|
735
|
+
isolated_workspace, temp_base, safe_workspace_warnings = create_directory_safe_workspace(
|
|
736
|
+
source_git_root,
|
|
737
|
+
include_warnings=True,
|
|
738
|
+
)
|
|
739
|
+
workspace_kind = "directory"
|
|
740
|
+
safe_workspace_method = "directory-copy"
|
|
741
|
+
warnings_list.append(SAFE_UNBORN_GIT_WARNING)
|
|
742
|
+
elif isolation_mode == "auto":
|
|
743
|
+
# Legacy auto fallback for non-git cursor/codex safe: directory copy.
|
|
744
|
+
isolated_workspace, temp_base, safe_workspace_warnings = create_directory_safe_workspace(
|
|
745
|
+
request.workspace,
|
|
746
|
+
include_warnings=True,
|
|
747
|
+
)
|
|
748
|
+
workspace_kind = "directory"
|
|
749
|
+
safe_workspace_method = "directory-copy"
|
|
750
|
+
else:
|
|
751
|
+
raise DelegateError(
|
|
752
|
+
"worktree_requires_git",
|
|
753
|
+
"--isolation worktree requires a Git workspace for safe mode.",
|
|
754
|
+
)
|
|
755
|
+
warnings = merge_warnings(
|
|
756
|
+
tuple(warnings_list),
|
|
757
|
+
safe_workspace_warnings,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
isolation = IsolationContext(
|
|
761
|
+
source_workspace=request.workspace,
|
|
762
|
+
effective_isolation=effective,
|
|
763
|
+
isolation_mode=isolation_mode,
|
|
764
|
+
isolation_lifecycle="temporary",
|
|
765
|
+
preserved_workspace=False,
|
|
766
|
+
source_git_root=source_git_root,
|
|
767
|
+
safe_workspace_method=safe_workspace_method,
|
|
768
|
+
warnings=warnings,
|
|
769
|
+
)
|
|
770
|
+
try:
|
|
771
|
+
if request.engine == "cursor":
|
|
772
|
+
write_cursor_safe_project_config(Path(isolated_workspace))
|
|
773
|
+
isolated_argv = replace_safe_workspace_arg_in_argv(
|
|
774
|
+
request,
|
|
775
|
+
request.argv,
|
|
776
|
+
isolated_workspace,
|
|
777
|
+
workspace_kind=workspace_kind,
|
|
778
|
+
)
|
|
779
|
+
isolated_display_argv = replace_safe_workspace_arg_in_argv(
|
|
780
|
+
request,
|
|
781
|
+
public_argv(request),
|
|
782
|
+
isolated_workspace,
|
|
783
|
+
workspace_kind=workspace_kind,
|
|
784
|
+
)
|
|
785
|
+
yield Request(
|
|
786
|
+
engine=request.engine,
|
|
787
|
+
mode=request.mode,
|
|
788
|
+
workspace=isolated_workspace,
|
|
789
|
+
prompt=request.prompt,
|
|
790
|
+
argv=isolated_argv,
|
|
791
|
+
model=request.model,
|
|
792
|
+
model_alias=request.model_alias,
|
|
793
|
+
dry_run=request.dry_run,
|
|
794
|
+
workspace_kind=workspace_kind,
|
|
795
|
+
isolation_context=isolation,
|
|
796
|
+
reasoning_effort=request.reasoning_effort,
|
|
797
|
+
reasoning_effort_source=request.reasoning_effort_source,
|
|
798
|
+
reasoning_capability_source=request.reasoning_capability_source,
|
|
799
|
+
reasoning_transport=request.reasoning_transport,
|
|
800
|
+
progress=request.progress,
|
|
801
|
+
progress_initial_delay_sec=request.progress_initial_delay_sec,
|
|
802
|
+
progress_interval_sec=request.progress_interval_sec,
|
|
803
|
+
forbid_commit=request.forbid_commit,
|
|
804
|
+
include_dirty=request.include_dirty,
|
|
805
|
+
warnings=request.warnings,
|
|
806
|
+
stdin_text=request.stdin_text,
|
|
807
|
+
prompt_file_text=request.prompt_file_text,
|
|
808
|
+
prompt_transport=request.prompt_transport,
|
|
809
|
+
display_argv=isolated_display_argv,
|
|
810
|
+
env_overrides=request.env_overrides,
|
|
811
|
+
auth_profile=request.auth_profile,
|
|
812
|
+
fallback_auth_profile=request.fallback_auth_profile,
|
|
813
|
+
group=request.group,
|
|
814
|
+
profile_resolution=request.profile_resolution,
|
|
815
|
+
)
|
|
816
|
+
finally:
|
|
817
|
+
cleanup_safe_isolated_workspace(
|
|
818
|
+
git_root=cleanup_git_root,
|
|
819
|
+
isolated_workspace=isolated_workspace,
|
|
820
|
+
temp_base=temp_base,
|
|
821
|
+
)
|