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.
Files changed (54) hide show
  1. delegate_agent/__init__.py +5 -0
  2. delegate_agent/archived_logs.py +44 -0
  3. delegate_agent/argv_builders.py +423 -0
  4. delegate_agent/argv_utils.py +36 -0
  5. delegate_agent/capability_commands.py +99 -0
  6. delegate_agent/cli.py +987 -0
  7. delegate_agent/cli_parser.py +1627 -0
  8. delegate_agent/command_errors.py +29 -0
  9. delegate_agent/command_help.py +1264 -0
  10. delegate_agent/config.py +1117 -0
  11. delegate_agent/config_commands.py +143 -0
  12. delegate_agent/constants.py +50 -0
  13. delegate_agent/describe_payload.py +1018 -0
  14. delegate_agent/errors.py +32 -0
  15. delegate_agent/git_utils.py +180 -0
  16. delegate_agent/harness_events.py +719 -0
  17. delegate_agent/inspection_commands.py +104 -0
  18. delegate_agent/isolation.py +316 -0
  19. delegate_agent/json_types.py +18 -0
  20. delegate_agent/log_output.py +78 -0
  21. delegate_agent/private_io.py +109 -0
  22. delegate_agent/profile_commands.py +42 -0
  23. delegate_agent/profile_guard.py +116 -0
  24. delegate_agent/profiles.py +389 -0
  25. delegate_agent/prompt_instructions.py +39 -0
  26. delegate_agent/prompt_transport.py +12 -0
  27. delegate_agent/reasoning.py +916 -0
  28. delegate_agent/redaction.py +193 -0
  29. delegate_agent/rendering.py +573 -0
  30. delegate_agent/request_build.py +1681 -0
  31. delegate_agent/request_models.py +191 -0
  32. delegate_agent/retention.py +321 -0
  33. delegate_agent/run_metadata.py +78 -0
  34. delegate_agent/run_output_commands.py +645 -0
  35. delegate_agent/run_registry.py +747 -0
  36. delegate_agent/run_status.py +300 -0
  37. delegate_agent/runner.py +1830 -0
  38. delegate_agent/safe_workspace.py +821 -0
  39. delegate_agent/snapshot_view.py +229 -0
  40. delegate_agent/wait_cancel_commands.py +559 -0
  41. delegate_agent/worktree_commands.py +218 -0
  42. delegate_agent/worktree_execution.py +654 -0
  43. delegate_agent/worktree_gc.py +529 -0
  44. delegate_agent/worktree_mgmt.py +782 -0
  45. delegate_agent/worktree_records.py +232 -0
  46. delegate_agent/worktree_remove.py +547 -0
  47. delegate_agent/worktree_summary.py +242 -0
  48. delegate_agent/wsl.py +65 -0
  49. delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
  50. delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
  51. delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
  52. delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
  53. delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
  54. 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
+ )