autoforge-cli 0.1.0a0__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 (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,691 @@
1
+ """``doctor``'s outer-sandbox rows (ADR 0005 §2.12, §2.14; #169).
2
+
3
+ Both ``autoforge doctor`` and ``autoforge local doctor`` show, read-only and
4
+ with no GitHub call:
5
+
6
+ - the capability probe's result (D14.1) and what ``sandbox.mode`` will do on
7
+ this host (D12.2, D12.4): ``disabled`` is reported as a risk, logical
8
+ isolation only; ``required``, and ``preferred`` in REMOTE, fail on a host
9
+ that cannot enforce; LOCAL's ``preferred`` warns that it falls back;
10
+ - the policy by launch class (§2.5), as the dry-run plan shows it;
11
+ - per provider, its compiled declaration -- paths and classes, never a
12
+ value -- and, when ``sandbox.mode`` enables the sandbox and the host can
13
+ enforce, its ``--version`` launched inside it (D14.1); a keyring login,
14
+ or a ``dir-rw`` login D7.2 refuses, is named with the supported
15
+ alternatives (D7.3);
16
+ - ``sandbox.read_only_paths`` the run would refuse (D15.4);
17
+ - regular files with more than one link in the writable trees an agent
18
+ gets (D4.4), the sandbox directories and their sizes (D8.5), and the TCP
19
+ listeners on loopback and wildcard addresses, flagging X11's (D9.3).
20
+
21
+ Every walk is bounded; a path is shown, a file's contents never are.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import stat
28
+ import tempfile
29
+ from collections.abc import Iterator
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import TYPE_CHECKING
33
+
34
+ from . import engine as _engine
35
+ from .config import SANDBOX_PREFERRED, AutoForgeConfig, ProfileConfig
36
+ from .errors import AutoForgeError, ConfigurationError, ExecutionError, SandboxPolicyError
37
+ from .executor import ExecutionRequest
38
+ from .profiles import REQUIRED_PROFILES, local_required_profiles
39
+ from .providers import AgentProvider, PiProvider, provider_for
40
+ from .redaction import redact
41
+ from .sandbox import NetworkPosition, compile_launch
42
+ from .sandbox_bwrap import ProbeResult
43
+ from .sandbox_policy import (
44
+ OPERATOR_PATHS_KEY,
45
+ SANDBOX_DIR,
46
+ AuthMode,
47
+ LaunchClass,
48
+ ProviderRequirements,
49
+ class_summary,
50
+ operator_path_problem,
51
+ provider_request,
52
+ rewrite_environment,
53
+ summary_facts,
54
+ )
55
+
56
+ if TYPE_CHECKING:
57
+ from .doctor import CheckResult, Doctor
58
+
59
+ # Every row's name starts with this, so that a caller can tell them apart.
60
+ ROW_PREFIX = "outer sandbox"
61
+
62
+ # Bounds of the read-only walks (D4.4, D8.5): entries visited per tree, and
63
+ # paths shown per row.
64
+ MAX_WALK_ENTRIES = 200_000
65
+ MAX_SHOWN = 20
66
+ # How much of a provider's sandboxed ``--version`` line a row shows.
67
+ VERSION_CHARS = 120
68
+
69
+ # The ports an X server listening on TCP uses (D9.3): 6000 + display.
70
+ X11_PORTS = range(6000, 6064)
71
+
72
+ _TCP_LISTEN = "0A"
73
+
74
+
75
+ @dataclass
76
+ class _Layout:
77
+ """Where this repository's run would keep what an agent writes, or ``None`` outside git."""
78
+
79
+ # The trees an agent writes in: the LOCAL root, or the REMOTE issue worktrees.
80
+ workspaces: list[Path] = field(default_factory=list)
81
+ # ``<git dir>/autoforge/sandbox/<key>`` (D8.4).
82
+ sandbox_dirs: list[Path] = field(default_factory=list)
83
+ # What no launch may see (D13.2): the run's own denied roots, which the
84
+ # gate holds operator paths against (D15.4) and provider probes compile with.
85
+ denied: tuple[str, ...] = ()
86
+
87
+
88
+ def sandbox_checks(doctor: Doctor, local: bool) -> list[CheckResult]:
89
+ """The outer sandbox's rows, in order; none when the configuration did not load."""
90
+ cfg = doctor.config
91
+ if cfg is None:
92
+ return []
93
+ probe = _engine.probe_sandbox()
94
+ layout = _layout(doctor, cfg, local)
95
+ denied = layout.denied if layout is not None else ()
96
+ return [
97
+ capability_row(probe),
98
+ mode_row(cfg, probe, local),
99
+ *policy_rows(cfg, local),
100
+ *provider_rows(doctor, cfg, local, probe, denied),
101
+ operator_paths_row(cfg, layout),
102
+ hard_links_row(layout),
103
+ sandbox_dirs_row(layout),
104
+ listeners_row(doctor.proc_net),
105
+ ]
106
+
107
+
108
+ def _row(name: str, ok: bool, detail: str, required: bool = True) -> CheckResult:
109
+ from .doctor import CheckResult
110
+
111
+ return CheckResult(f"{ROW_PREFIX}: {name}", ok, redact(detail), required=required)
112
+
113
+
114
+ def _skip(name: str, detail: str) -> CheckResult:
115
+ from .doctor import CheckResult
116
+
117
+ return CheckResult.skip(f"{ROW_PREFIX}: {name}", redact(detail))
118
+
119
+
120
+ # -- capability and mode (D12.2, D12.4, D14.1) ---------------------------------
121
+ def capability_row(probe: ProbeResult) -> CheckResult:
122
+ """The probe's result: informational, the mode row decides whether it fails."""
123
+ return _row(
124
+ "capability (one minimal sandboxed launch)", probe.supported, probe.describe(), False
125
+ )
126
+
127
+
128
+ def mode_row(cfg: AutoForgeConfig, probe: ProbeResult, local: bool) -> CheckResult:
129
+ """What ``sandbox.mode`` does on this host: OK, a risk (WARN), or a refusal (FAIL)."""
130
+ sandbox = cfg.sandbox
131
+ name = f"mode ({sandbox.mode})"
132
+ if not sandbox.enabled:
133
+ return _row(
134
+ name,
135
+ False,
136
+ f"{_engine.LOGICAL_ISOLATION_ONLY}: agents and repository-defined commands run as "
137
+ "the operator's UID without the outer sandbox and can reach whatever the operator "
138
+ "can by absolute path, controller state and credentials included (a risk; set "
139
+ "sandbox.mode: required"
140
+ + (" or preferred" if local else "")
141
+ + " where the capability row is supported)",
142
+ required=False,
143
+ )
144
+ if probe.supported:
145
+ return _row(name, True, "every agent launch and repository-defined command is enforced")
146
+ missing = (
147
+ probe.capability.value if probe.capability else "a capability"
148
+ ) + f" ({probe.reason})"
149
+ if sandbox.refuses_unenforced(local):
150
+ how = (
151
+ "required"
152
+ if sandbox.mode != SANDBOX_PREFERRED
153
+ else "preferred, which a REMOTE run treats as required"
154
+ )
155
+ alternatives = f"sandbox.mode: disabled ({_engine.LOGICAL_ISOLATION_ONLY})"
156
+ if local:
157
+ alternatives += ", or preferred (falls back visibly)"
158
+ return _row(
159
+ name,
160
+ False,
161
+ f"sandbox.mode is {how}, and this host cannot provide {missing}: every step that "
162
+ f"launches is refused before anything is saved or charged; make the host capable, "
163
+ f"or set {alternatives}",
164
+ )
165
+ return _row(
166
+ name,
167
+ False,
168
+ f"this host cannot provide {missing}, so under preferred every LOCAL launch falls back "
169
+ f"to {_engine.LOGICAL_ISOLATION_ONLY}, said on stderr at the launch and in its execution "
170
+ "record (enforced: false); it is not sandboxed",
171
+ required=False,
172
+ )
173
+
174
+
175
+ def policy_rows(cfg: AutoForgeConfig, local: bool) -> list[CheckResult]:
176
+ """§2.5's policy for each launch class the run can make, by role (as the dry-run plan)."""
177
+ sandbox = cfg.sandbox
178
+ validation = NetworkPosition(sandbox.validation_network)
179
+ paths = tuple(sandbox.read_only_paths)
180
+ classes = (
181
+ [(LaunchClass.LOCAL_AGENT, validation), (LaunchClass.LOCAL_VALIDATION, validation)]
182
+ if local
183
+ else [
184
+ (LaunchClass.REMOTE_AGENT, validation),
185
+ (LaunchClass.EXPORT_COMMAND, NetworkPosition(sandbox.premerge_network)),
186
+ ]
187
+ )
188
+ return [
189
+ _row(
190
+ f"policy, {launch_class.value}",
191
+ True,
192
+ class_summary(summary_facts(launch_class, network, cache=True, operator_paths=paths)),
193
+ required=False,
194
+ )
195
+ for launch_class, network in classes
196
+ ]
197
+
198
+
199
+ # -- providers (D7.3, D14.1) ---------------------------------------------------
200
+ def _reachable(cfg: AutoForgeConfig, local: bool) -> list[str]:
201
+ if local:
202
+ try:
203
+ return local_required_profiles(cfg)
204
+ except ConfigurationError:
205
+ return []
206
+ return [*REQUIRED_PROFILES, *(n for n in ("update_epic",) if n in cfg.profiles)]
207
+
208
+
209
+ def provider_rows(
210
+ doctor: Doctor,
211
+ cfg: AutoForgeConfig,
212
+ local: bool,
213
+ probe: ProbeResult,
214
+ denied: tuple[str, ...] = (),
215
+ ) -> list[CheckResult]:
216
+ """One row per distinct (provider, command) the run can reach, ``scripted`` excepted.
217
+
218
+ ``denied`` are the run's denied roots: a declaration that projects one
219
+ is refused, as every launch of it would be, before anything is launched.
220
+ """
221
+ groups: dict[tuple[str, str], list[ProfileConfig]] = {}
222
+ for name in _reachable(cfg, local):
223
+ profile = cfg.profiles.get(name)
224
+ if profile is None or profile.provider == "scripted":
225
+ continue
226
+ command = profile.command or profile.provider
227
+ groups.setdefault((profile.provider, command), []).append(profile)
228
+ return [
229
+ _provider_row(doctor, cfg, profiles, probe, denied)
230
+ for (_provider, _command), profiles in groups.items()
231
+ ]
232
+
233
+
234
+ def _provider_row(
235
+ doctor: Doctor,
236
+ cfg: AutoForgeConfig,
237
+ profiles: list[ProfileConfig],
238
+ probe: ProbeResult,
239
+ denied: tuple[str, ...],
240
+ ) -> CheckResult:
241
+ profile = profiles[0]
242
+ name = (
243
+ f"provider {profile.provider} '{profile.command or profile.provider}' "
244
+ f"({', '.join(p.name for p in profiles)})"
245
+ )
246
+ enforced = cfg.sandbox.enabled
247
+ try:
248
+ provider = provider_for(profile)
249
+ version = (
250
+ provider.version_command(profile)
251
+ if isinstance(provider, PiProvider)
252
+ else [profile.command or profile.provider, "--version"]
253
+ )
254
+ except (AutoForgeError, OSError) as exc:
255
+ return _row(name, False, f"its declaration cannot be read: {exc}", required=enforced)
256
+ try:
257
+ # As a launch is (``AgentProvider.execute``): the adapter's isolation
258
+ # over the allow-listed environment, its temporary files kept until
259
+ # the launch has ended.
260
+ with provider.integration_isolation() as isolation:
261
+ return _provider_probe(
262
+ doctor, cfg, provider, profile, version, isolation, probe, denied, name
263
+ )
264
+ except ExecutionError as exc:
265
+ return _row(name, False, f"its launch environment cannot be prepared: {exc}", enforced)
266
+
267
+
268
+ def _provider_probe(
269
+ doctor: Doctor,
270
+ cfg: AutoForgeConfig,
271
+ provider: AgentProvider,
272
+ profile: ProfileConfig,
273
+ version: list[str],
274
+ isolation: dict[str, str],
275
+ probe: ProbeResult,
276
+ denied: tuple[str, ...],
277
+ name: str,
278
+ ) -> CheckResult:
279
+ """The declaration, compiled and launched with the environment a launch gets.
280
+
281
+ That environment is the allow-listed one with the adapter's isolation
282
+ over it (an operator's ``OPENCODE_CONFIG_DIR`` is replaced as at every
283
+ launch), and its path-valued names are spelled where they lead inside
284
+ (``rewrite_environment``, D8.2): a ``CLAUDE_CONFIG_DIR`` reached through
285
+ a link names the directory, which is what the CLI is given.
286
+ """
287
+ enforced = cfg.sandbox.enabled
288
+ allowlist = cfg.execution.environment_names()
289
+ env = {**provider.launch_environment(allowlist), **isolation}
290
+ home = _engine._operator_home()
291
+ try:
292
+ needs = provider.sandbox_requirements(profile, version, env, home)
293
+ except (AutoForgeError, OSError) as exc:
294
+ return _row(name, False, f"its declaration cannot be read: {exc}", required=enforced)
295
+ shown = describe_requirements(needs)
296
+ with tempfile.TemporaryDirectory(prefix="autoforge-doctor-") as scratch:
297
+ workspace = os.path.realpath(scratch)
298
+ try:
299
+ compiled = compile_launch(
300
+ provider_request(
301
+ needs,
302
+ home=home,
303
+ controller_env=os.environ,
304
+ workspace=workspace,
305
+ operator_paths=tuple(cfg.sandbox.read_only_paths),
306
+ denied_roots=denied,
307
+ )
308
+ )
309
+ description = rewrite_environment(compiled, env, needs)
310
+ except SandboxPolicyError as exc:
311
+ return _row(
312
+ name,
313
+ False,
314
+ f"{shown}; every launch of it is refused: {exc}. {_refusal_hint(needs, exc)}",
315
+ required=enforced,
316
+ )
317
+ except (AutoForgeError, OSError) as exc:
318
+ return _row(name, False, f"{shown}; it cannot be compiled: {exc}", enforced)
319
+ if not enforced:
320
+ return _row(
321
+ name,
322
+ True,
323
+ f"{shown}; the declaration compiles (no sandboxed launch under sandbox.mode "
324
+ "disabled)",
325
+ required=False,
326
+ )
327
+ if not probe.supported:
328
+ return _row(
329
+ name,
330
+ True,
331
+ f"{shown}; the declaration compiles; no sandboxed launch (the host cannot enforce)",
332
+ required=False,
333
+ )
334
+ if not needs.executable:
335
+ return _row(
336
+ name,
337
+ False,
338
+ f"{shown}; {version[0]!r} is not on the launch's PATH, so no sandboxed launch",
339
+ required=enforced,
340
+ )
341
+ try:
342
+ result = doctor.runner(
343
+ ExecutionRequest(
344
+ command=[needs.executable, *version[1:]],
345
+ cwd=workspace,
346
+ timeout_seconds=doctor.timeout,
347
+ env_allowlist=provider.launch_allowlist(allowlist),
348
+ env=isolation or None,
349
+ sandbox=description,
350
+ )
351
+ )
352
+ except ExecutionError as exc:
353
+ return _row(name, False, f"{shown}; the sandboxed launch was refused: {exc}", enforced)
354
+ if result.timed_out or result.exit_code != 0:
355
+ why = "timed out" if result.timed_out else f"exited {result.exit_code}"
356
+ return _row(
357
+ name,
358
+ False,
359
+ f"{shown}; {' '.join(version[1:])} inside the sandbox {why}: the declaration does "
360
+ "not give the CLI what it needs",
361
+ required=enforced,
362
+ )
363
+ # Redacted whole before a line is picked and clipped: a clip first could
364
+ # leave a token too short for the redactor to recognize.
365
+ first = (redact(result.stdout).strip().splitlines() or [""])[0][:VERSION_CHARS]
366
+ return _row(name, True, f"{shown}; inside the sandbox: {first or '(no output)'}", False)
367
+
368
+
369
+ def describe_requirements(needs: ProviderRequirements) -> str:
370
+ """A provider's declaration by path and class only: never a variable's value."""
371
+ parts = [f"executable {needs.executable or '(not found)'}"]
372
+ if needs.install_roots or needs.config:
373
+ parts.append("read-only " + ", ".join([*needs.install_roots, *needs.config]))
374
+ if needs.spelled_paths:
375
+ parts.append("reached as written, through its links: " + ", ".join(needs.spelled_paths))
376
+ if needs.home_config:
377
+ parts.append("read-only in the private home " + ", ".join(needs.home_config))
378
+ if needs.state:
379
+ parts.append("writable " + ", ".join(needs.state))
380
+ for item in needs.auth:
381
+ where = f" {item.source}" if item.source and item.mode is not AuthMode.ENV else ""
382
+ parts.append(f"credential {item.mode.value}: {item.label}{where}")
383
+ if not needs.auth:
384
+ parts.append("credential: none declared")
385
+ return "; ".join(parts)
386
+
387
+
388
+ def _refusal_hint(needs: ProviderRequirements, exc: SandboxPolicyError) -> str:
389
+ """What to do about a declaration the policy refuses: the rule decides."""
390
+ if exc.rule.startswith("C4"):
391
+ return (
392
+ "Keep it clear of what no launch may see (ADR 0005 D13.2: the state directory and "
393
+ "its run logs, the lock, the controller's directory under the git common dir, "
394
+ "another worktree, and in REMOTE the operator's checkout), or set sandbox.mode: "
395
+ "disabled."
396
+ )
397
+ return _login_alternatives(needs)
398
+
399
+
400
+ def _login_alternatives(needs: ProviderRequirements) -> str:
401
+ """D7.3: what to use instead of a keyring or refused ``dir-rw`` login."""
402
+ kinds = {item.mode for item in needs.auth}
403
+ if AuthMode.KEYRING in kinds:
404
+ lead = "A keyring login cannot reach a sandboxed launch without exposing the keyring"
405
+ elif AuthMode.DIR_RW in kinds:
406
+ lead = (
407
+ "A dir-rw login may not be the provider's own configuration root, nor lie in it "
408
+ "(ADR 0005 D7.2)"
409
+ )
410
+ else:
411
+ return "Fix the path the message names, or set sandbox.mode: disabled."
412
+ return (
413
+ f"{lead}; use a login file the provider reads (file-ro), a dedicated credential "
414
+ "directory outside its configuration root (dir-rw), or an API key variable (env), "
415
+ "as docs/usage.md describes"
416
+ )
417
+
418
+
419
+ # -- the repository's layout, operator paths (D15.4) ---------------------------
420
+ def _git_lines(doctor: Doctor, *args: str) -> list[str] | None:
421
+ try:
422
+ res = doctor.runner(
423
+ ExecutionRequest(command=["git", *args], cwd=doctor.cwd, timeout_seconds=doctor.timeout)
424
+ )
425
+ except Exception: # a missing git is reported by its own row
426
+ return None
427
+ if res.timed_out or res.exit_code != 0:
428
+ return None
429
+ return [line for line in (res.stdout or "").splitlines() if line.strip()]
430
+
431
+
432
+ def _layout(doctor: Doctor, cfg: AutoForgeConfig, local: bool) -> _Layout | None:
433
+ lines = _git_lines(
434
+ doctor,
435
+ "rev-parse",
436
+ "--path-format=absolute",
437
+ "--show-toplevel",
438
+ "--git-dir",
439
+ "--git-common-dir",
440
+ )
441
+ if lines is None or len(lines) != 3 or not all(line.startswith("/") for line in lines):
442
+ return None
443
+ root, git_dir, common = (Path(os.path.realpath(line)) for line in lines)
444
+ try:
445
+ denied = _run_engine(doctor, cfg, local).gate_denied_roots(root if local else None)
446
+ except (AutoForgeError, OSError):
447
+ return None
448
+ if local:
449
+ workspaces = [root]
450
+ else:
451
+ configured = cfg.execution.worktree_dir
452
+ holder = (
453
+ Path(os.path.realpath(Path(doctor.cwd) / configured))
454
+ if configured
455
+ else common / "autoforge" / "worktrees"
456
+ )
457
+ workspaces = sorted(p for p in _children(holder) if p.is_dir())
458
+ sandbox_dirs: list[Path] = []
459
+ for base in dict.fromkeys([common, git_dir]):
460
+ sandbox_dirs += sorted(p for p in _children(base / SANDBOX_DIR) if p.is_dir())
461
+ return _Layout(workspaces, sandbox_dirs, denied)
462
+
463
+
464
+ def _run_engine(doctor: Doctor, cfg: AutoForgeConfig, local: bool) -> _engine.ControllerEngine:
465
+ """An engine bound as this invocation's run would be, so its denied roots are the run's.
466
+
467
+ The state directory is the one the CLI gives a run (``--state-dir``,
468
+ else ``state_dir``; LOCAL's resolved as ``bind_local_state_dir`` does,
469
+ the default ``<git dir>/autoforge/state``), relative to the working
470
+ directory as there; the lock, the git directories and the registered
471
+ worktrees are read as a launch reads them. Nothing is created or locked.
472
+ """
473
+ eng = _engine.ControllerEngine(
474
+ cfg, state_dir=doctor.state_dir or cfg.state_dir, workdir=doctor.cwd, runner=doctor.runner
475
+ )
476
+ if local:
477
+ eng.paths = _engine.local_state_paths(
478
+ eng.workspace(), explicit=doctor.state_dir, configured=cfg.state_dir
479
+ )
480
+ return eng
481
+
482
+
483
+ def _children(path: Path) -> list[Path]:
484
+ try:
485
+ if path.is_symlink() or not path.is_dir():
486
+ return []
487
+ return [child for child in path.iterdir() if not child.is_symlink()]
488
+ except OSError:
489
+ return []
490
+
491
+
492
+ def operator_paths_row(cfg: AutoForgeConfig, layout: _Layout | None) -> CheckResult:
493
+ """``sandbox.read_only_paths``: what the run would refuse (D15.4)."""
494
+ paths = tuple(cfg.sandbox.read_only_paths)
495
+ name = f"operator read-only paths ({OPERATOR_PATHS_KEY})"
496
+ if not paths:
497
+ return _row(name, True, "(none configured)", required=False)
498
+ if layout is None:
499
+ return _skip(
500
+ name,
501
+ f"{', '.join(paths)}; not checked against the run's denied roots "
502
+ "(not a git repository)",
503
+ )
504
+ problem = operator_path_problem(paths, layout.denied)
505
+ if problem:
506
+ return _row(name, False, f"refused at the first launch: {problem}", cfg.sandbox.enabled)
507
+ return _row(
508
+ name,
509
+ True,
510
+ f"{', '.join(paths)}: read-only in every launch; a token in one of these files is "
511
+ "readable by every tool in the sandbox",
512
+ required=False,
513
+ )
514
+
515
+
516
+ # -- hard links (D4.4), sandbox directories (D8.5) ------------------------------
517
+ def _walk(root: Path, skip_git: bool) -> Iterator[tuple[str, os.stat_result]]:
518
+ """``(path, lstat)`` of every entry below ``root``, never following a link, bounded.
519
+
520
+ Each directory is read an entry at a time and the bound is checked per
521
+ entry, so no more than ``MAX_WALK_ENTRIES`` are ever read, however many
522
+ one directory holds. A directory that cannot be read, or stops being
523
+ readable part way, is skipped from there.
524
+ """
525
+ seen = 0
526
+ stack = [str(root)]
527
+ while stack:
528
+ current = stack.pop()
529
+ try:
530
+ with os.scandir(current) as entries:
531
+ for entry in entries:
532
+ seen += 1
533
+ if seen > MAX_WALK_ENTRIES:
534
+ return
535
+ if skip_git and entry.name == ".git":
536
+ continue
537
+ try:
538
+ info = entry.stat(follow_symlinks=False)
539
+ except OSError:
540
+ continue
541
+ yield entry.path, info
542
+ if stat.S_ISDIR(info.st_mode):
543
+ stack.append(entry.path)
544
+ except OSError:
545
+ continue
546
+
547
+
548
+ def hard_links_row(layout: _Layout | None) -> CheckResult:
549
+ """Regular files with ``st_nlink > 1`` in the trees an agent writes (D4.4)."""
550
+ name = "hard links in writable trees"
551
+ if layout is None:
552
+ return _skip(name, "not a git repository")
553
+ trees = [*layout.workspaces, *layout.sandbox_dirs]
554
+ if not trees:
555
+ return _row(name, True, "nothing to scan (no workspace or sandbox directory yet)", False)
556
+ linked: list[str] = []
557
+ truncated = False
558
+ for tree in trees:
559
+ for path, info in _walk(tree, skip_git=True):
560
+ if stat.S_ISREG(info.st_mode) and info.st_nlink > 1:
561
+ linked.append(path)
562
+ if len(linked) > MAX_SHOWN:
563
+ truncated = True
564
+ break
565
+ if truncated:
566
+ break
567
+ scanned = ", ".join(str(t) for t in trees[:MAX_SHOWN])
568
+ if not linked:
569
+ return _row(name, True, f"none in {scanned}", required=False)
570
+ shown = ", ".join(linked[:MAX_SHOWN]) + (" and more" if truncated else "")
571
+ return _row(
572
+ name,
573
+ False,
574
+ f"{shown}: each still aliases an inode outside, which a sandboxed launch can read and "
575
+ "write through; remove a REMOTE worktree or sandbox directory to have it recreated, "
576
+ "or break the link in the LOCAL tree (ADR 0005 D4.4)",
577
+ required=False,
578
+ )
579
+
580
+
581
+ def _size(root: Path) -> tuple[int, bool]:
582
+ total = 0
583
+ complete = True
584
+ for count, (_path, info) in enumerate(_walk(root, skip_git=False), 1):
585
+ if count >= MAX_WALK_ENTRIES:
586
+ complete = False
587
+ if stat.S_ISREG(info.st_mode):
588
+ total += info.st_size
589
+ return total, complete
590
+
591
+
592
+ def _human(size: int) -> str:
593
+ value = float(size)
594
+ for unit in ("B", "KiB", "MiB", "GiB"):
595
+ if value < 1024 or unit == "GiB":
596
+ return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
597
+ value /= 1024
598
+ return f"{size} B"
599
+
600
+
601
+ def sandbox_dirs_row(layout: _Layout | None) -> CheckResult:
602
+ """The sandbox directories and their sizes; AutoForge never deletes them (D8.5)."""
603
+ name = "sandbox directories"
604
+ if layout is None:
605
+ return _skip(name, "not a git repository")
606
+ if not layout.sandbox_dirs:
607
+ return _row(name, True, "(none yet)", required=False)
608
+ shown = []
609
+ for path in layout.sandbox_dirs[:MAX_SHOWN]:
610
+ size, complete = _size(path)
611
+ shown.append(f"{path} {_human(size)}" + ("" if complete else "+"))
612
+ more = len(layout.sandbox_dirs) - MAX_SHOWN
613
+ return _row(
614
+ name,
615
+ True,
616
+ "; ".join(shown)
617
+ + (f"; and {more} more" if more > 0 else "")
618
+ + " (never deleted by AutoForge; remove one to reclaim it, it is recreated empty)",
619
+ required=False,
620
+ )
621
+
622
+
623
+ # -- TCP listeners (D9.3) --------------------------------------------------------
624
+ def _listeners(proc_net: str) -> list[tuple[str, int]]:
625
+ """``(address, port)`` of the TCP sockets listening on loopback or a wildcard."""
626
+ found: list[tuple[str, int]] = []
627
+ for family in ("tcp", "tcp6"):
628
+ try:
629
+ text = Path(proc_net, family).read_text()
630
+ except OSError:
631
+ continue
632
+ for line in text.splitlines()[1:]:
633
+ fields = line.split()
634
+ if len(fields) < 4 or fields[3] != _TCP_LISTEN:
635
+ continue
636
+ address, _, port = fields[1].partition(":")
637
+ shown = _address(address)
638
+ if shown is not None:
639
+ found.append((shown, int(port, 16)))
640
+ return sorted(set(found), key=lambda item: (item[1], item[0]))
641
+
642
+
643
+ def _address(raw: str) -> str | None:
644
+ """The address of a ``/proc/net/tcp*`` entry when it is loopback or a wildcard."""
645
+ try:
646
+ data = bytes.fromhex(raw)
647
+ except ValueError:
648
+ return None
649
+ # Each 32-bit word is in host byte order (little-endian on every Linux
650
+ # AutoForge runs on).
651
+ words = [data[i : i + 4][::-1] for i in range(0, len(data), 4)]
652
+ packed = b"".join(words)
653
+ if len(packed) == 4:
654
+ if packed == b"\0\0\0\0":
655
+ return "0.0.0.0"
656
+ if packed[0] == 127:
657
+ return ".".join(str(b) for b in packed)
658
+ return None
659
+ if packed == bytes(16):
660
+ return "::"
661
+ if packed == bytes(15) + b"\1":
662
+ return "::1"
663
+ if packed[:12] == bytes(10) + b"\xff\xff" and packed[12] == 127:
664
+ return "::ffff:" + ".".join(str(b) for b in packed[12:])
665
+ return None
666
+
667
+
668
+ def listeners_row(proc_net: str) -> CheckResult:
669
+ """Every loopback and wildcard TCP listener is reachable from inside (D9.3)."""
670
+ name = "TCP listeners reachable from inside"
671
+ if not os.path.isdir(proc_net):
672
+ return _skip(name, f"{proc_net} cannot be read on this host")
673
+ listeners = _listeners(proc_net)
674
+ if not listeners:
675
+ return _row(name, True, "none on loopback or a wildcard address", required=False)
676
+ shown = ", ".join(f"{address} port {port}" for address, port in listeners)
677
+ x11 = sorted({port for _address, port in listeners if port in X11_PORTS})
678
+ if x11:
679
+ return _row(
680
+ name,
681
+ False,
682
+ f"{shown}; X11 port(s) {', '.join(map(str, x11))}: an X server listening on TCP "
683
+ "lets a launch read and inject input; launches share the host network namespace",
684
+ required=False,
685
+ )
686
+ return _row(
687
+ name,
688
+ True,
689
+ f"{shown} (launches share the host network namespace, so each is reachable)",
690
+ required=False,
691
+ )