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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|