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,1107 @@
|
|
|
1
|
+
"""Which host resources a launch gets, by launch class (ADR 0005 §2.5, #168).
|
|
2
|
+
|
|
3
|
+
The engine-layer mapping of D13.2. The engine supplies its facts as data
|
|
4
|
+
(:class:`LaunchFacts`), a provider declares what its CLI needs as data
|
|
5
|
+
(:class:`ProviderRequirements`, D13.1), and :func:`sandbox_request` turns the
|
|
6
|
+
two into the compiler's :class:`~autoforge.sandbox.SandboxRequest`. The
|
|
7
|
+
launch class alone decides which rows of §2.5's table apply: there is no
|
|
8
|
+
provider branch here and no workflow decision, and a provider never emits a
|
|
9
|
+
mount (D7.1).
|
|
10
|
+
|
|
11
|
+
- **REMOTE agent:** the issue worktree and its own admin dir writable, as
|
|
12
|
+
carve-outs; the worktree's ``.git`` file and the admin dir's ``commondir``
|
|
13
|
+
and ``gitdir`` pinned read-only (D6.6); the common dir read-only with
|
|
14
|
+
``autoforge/``, ``worktrees/`` and ``modules/`` masked; an overlay at
|
|
15
|
+
``<common>/objects`` whose upper layer is the issue's private object
|
|
16
|
+
directory (D6.1); the read context read-only; the issue's cache at
|
|
17
|
+
``~/.cache`` for the launches that produce a change.
|
|
18
|
+
- **LOCAL agent and LOCAL validation command:** the repository root
|
|
19
|
+
writable, its ``.git`` entry, git dir and common dir read-only (so a LOCAL
|
|
20
|
+
agent cannot commit, stash or branch), ``<git dir>/autoforge``,
|
|
21
|
+
``<common>/autoforge`` and ``<common>/worktrees`` masked (the checkout's own
|
|
22
|
+
admin dir carved out read-only); the checkout's cache at ``~/.cache`` for
|
|
23
|
+
the launches that produce a change and for validation.
|
|
24
|
+
- **Export command (pre-merge verification):** the exported tree writable,
|
|
25
|
+
nothing else of the repository, no persistent cache.
|
|
26
|
+
|
|
27
|
+
Every class gets the system runtime, a private home and temp (the
|
|
28
|
+
compiler's, D8.1), the directory #165 points the child's ``gh``, git, curl,
|
|
29
|
+
wget and zsh at (read-only), and the engine's denied roots. Agents run on
|
|
30
|
+
the host network; a repository-defined command runs on an isolated one when
|
|
31
|
+
the operator asks for it (D9.2).
|
|
32
|
+
|
|
33
|
+
:func:`describe_launch` compiles the request and rewrites the launch's
|
|
34
|
+
path-valued environment (D8.2): a name AutoForge knows to be a path passes
|
|
35
|
+
only when what it names is visible inside, ``PATH`` keeps only its visible
|
|
36
|
+
entries, each is spelled where it is inside (a link on its host path the
|
|
37
|
+
sandbox does not recreate, a relocated projection), and every unset name is
|
|
38
|
+
recorded. A refusal of the compiler becomes a launch refusal
|
|
39
|
+
(:class:`~autoforge.errors.SandboxLaunchRefusedError`), on the path a
|
|
40
|
+
failure to spawn takes.
|
|
41
|
+
|
|
42
|
+
:func:`config_file_problem` answers the converse question for the
|
|
43
|
+
controller's own input (#280): whether the ``--config`` file lies where an
|
|
44
|
+
enforced launch writes, the repository's trees and what the providers'
|
|
45
|
+
declarations make writable (:func:`declared_writable`), so that the next
|
|
46
|
+
command would run on what an agent left there.
|
|
47
|
+
|
|
48
|
+
Pure except for the compiler's ``realpath`` and ``lstat``, ``realpath`` of
|
|
49
|
+
the environment's path values and ``lstat`` and ``readlink`` on their way,
|
|
50
|
+
the first line of an executable read for its interpreter, and the
|
|
51
|
+
configuration file's lookup walk (``lstat`` and ``readlink``, from the
|
|
52
|
+
working directory for a relative path), its ``stat`` and link count;
|
|
53
|
+
nothing is created or launched here.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import dataclasses
|
|
59
|
+
import enum
|
|
60
|
+
import os
|
|
61
|
+
import posixpath
|
|
62
|
+
import re
|
|
63
|
+
import shutil
|
|
64
|
+
from collections.abc import Mapping
|
|
65
|
+
from dataclasses import dataclass
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
|
|
68
|
+
from .child_env import OVERRIDE_PATH_NAMES
|
|
69
|
+
from .errors import SandboxLaunchRefusedError, SandboxPolicyError
|
|
70
|
+
from .redaction import redact
|
|
71
|
+
from .sandbox import (
|
|
72
|
+
Access,
|
|
73
|
+
LaunchDescription,
|
|
74
|
+
NetworkPosition,
|
|
75
|
+
Origin,
|
|
76
|
+
OverlayRequest,
|
|
77
|
+
ProjectionRequest,
|
|
78
|
+
SandboxFacts,
|
|
79
|
+
SandboxRequest,
|
|
80
|
+
compile_launch,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Where the controller-owned sandbox directories live (D8.4): under
|
|
84
|
+
# ``<git common dir>/autoforge/sandbox/<key>`` for REMOTE (the key is the
|
|
85
|
+
# worktree's name) and ``<git dir>/autoforge/sandbox/local`` for LOCAL.
|
|
86
|
+
SANDBOX_DIR = "autoforge/sandbox"
|
|
87
|
+
LOCAL_SANDBOX_KEY = "local"
|
|
88
|
+
PRIVATE_OBJECTS = "objects"
|
|
89
|
+
OVERLAY_WORK = "overlay-work"
|
|
90
|
+
CACHE = "cache"
|
|
91
|
+
# The admin dir's pointer files pinned read-only for a REMOTE agent (D6.6).
|
|
92
|
+
PINNED_ADMIN_FILES = ("commondir", "gitdir")
|
|
93
|
+
|
|
94
|
+
# The CA-bundle names of ``DEFAULT_ENV_ALLOWLIST``: path-valued, so they pass
|
|
95
|
+
# into a sandboxed launch only when what they name is visible (D8.2).
|
|
96
|
+
CA_BUNDLE_NAMES = (
|
|
97
|
+
"SSL_CERT_FILE",
|
|
98
|
+
"SSL_CERT_DIR",
|
|
99
|
+
"NODE_EXTRA_CA_CERTS",
|
|
100
|
+
"REQUESTS_CA_BUNDLE",
|
|
101
|
+
"CURL_CA_BUNDLE",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# How much of an executable is read to find its interpreter (``#!``).
|
|
105
|
+
_SHEBANG_BYTES = 256
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class LaunchClass(enum.Enum):
|
|
109
|
+
"""The rows of ADR 0005 §2.5 a launch gets."""
|
|
110
|
+
|
|
111
|
+
REMOTE_AGENT = "REMOTE agent"
|
|
112
|
+
LOCAL_AGENT = "LOCAL agent"
|
|
113
|
+
LOCAL_VALIDATION = "LOCAL validation command"
|
|
114
|
+
EXPORT_COMMAND = "export command"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AuthMode(enum.Enum):
|
|
118
|
+
"""How a provider's model credential reaches a sandboxed launch (D7.1)."""
|
|
119
|
+
|
|
120
|
+
NONE = "none"
|
|
121
|
+
# A variable of the launch's environment (passed by the allow-list,
|
|
122
|
+
# subject to #165's refusals); nothing to project.
|
|
123
|
+
ENV = "env"
|
|
124
|
+
# One regular file, read-only, at its host path or a declared path in
|
|
125
|
+
# the private home.
|
|
126
|
+
FILE_RO = "file-ro"
|
|
127
|
+
# One provider state directory, writable, never on the provider's
|
|
128
|
+
# operator configuration root (D7.2).
|
|
129
|
+
DIR_RW = "dir-rw"
|
|
130
|
+
# A keychain or Secret Service login: declared so that it is refused.
|
|
131
|
+
KEYRING = "keyring"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class AuthItem:
|
|
136
|
+
"""One declared credential item (D7.1): what it is, never its contents.
|
|
137
|
+
|
|
138
|
+
``source`` is the host path (``file-ro``, ``dir-rw``) or the login's
|
|
139
|
+
name (``keyring``); ``target`` the path inside, ``None`` for the host
|
|
140
|
+
path; ``env_name`` a variable that names the item, set to its path
|
|
141
|
+
inside.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
mode: AuthMode
|
|
145
|
+
label: str
|
|
146
|
+
source: str = ""
|
|
147
|
+
target: str | None = None
|
|
148
|
+
env_name: str = ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class ProviderRequirements:
|
|
153
|
+
"""What a provider's CLI needs from the host, as data (ADR 0005 D13.1).
|
|
154
|
+
|
|
155
|
+
``executable`` is the resolved host path the launch names (empty: the
|
|
156
|
+
command is left as it is); ``install_roots`` and ``config`` are
|
|
157
|
+
projected read-only where their host paths resolve, ``home_config``
|
|
158
|
+
(files in the operator's home the CLI finds by that path) read-only at
|
|
159
|
+
that path in the private home, wherever it resolves on the host (C7,
|
|
160
|
+
D8.3), ``state`` (directories the adapter made for this launch)
|
|
161
|
+
writable; ``path_dirs`` are put first on ``PATH``; ``path_names`` are
|
|
162
|
+
the provider's path-valued variables (D8.2). ``operator_config_roots``
|
|
163
|
+
are where the CLI loads the operator's own settings, hooks and
|
|
164
|
+
extensions from (D7.2). ``spelled_paths`` are paths the launch names as
|
|
165
|
+
written (a script's absolute ``#!`` interpreter): each leads inside
|
|
166
|
+
where it leads on the host, through the links on its way (#225).
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
executable: str = ""
|
|
170
|
+
install_roots: tuple[str, ...] = ()
|
|
171
|
+
path_dirs: tuple[str, ...] = ()
|
|
172
|
+
spelled_paths: tuple[str, ...] = ()
|
|
173
|
+
config: tuple[str, ...] = ()
|
|
174
|
+
home_config: tuple[str, ...] = ()
|
|
175
|
+
state: tuple[str, ...] = ()
|
|
176
|
+
auth: tuple[AuthItem, ...] = ()
|
|
177
|
+
operator_config_roots: tuple[str, ...] = ()
|
|
178
|
+
path_names: tuple[str, ...] = ()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class LaunchFacts:
|
|
183
|
+
"""The controller's facts about one launch, as data (ADR 0005 D13.2).
|
|
184
|
+
|
|
185
|
+
``workspace`` is what the launch works in (the issue worktree, the
|
|
186
|
+
repository root, the exported tree). ``git_entry`` is the workspace's
|
|
187
|
+
``.git``; ``git_dir`` is its own git directory (a REMOTE worktree's
|
|
188
|
+
admin dir, a LOCAL checkout's git dir) and ``common_dir`` the shared
|
|
189
|
+
one. ``private_objects`` and ``overlay_work`` are the issue's object
|
|
190
|
+
overlay layers, ``cache`` the persistent cache when the launch gets one
|
|
191
|
+
(empty: its cache is inside the private home), ``read_context`` the
|
|
192
|
+
launch's read context, ``controller_config`` the directory #165's
|
|
193
|
+
overrides point into. ``home`` is the operator's home and
|
|
194
|
+
``controller_env`` the controller's environment, for D4.3.
|
|
195
|
+
``operator_paths`` are ``sandbox.read_only_paths``, as configured
|
|
196
|
+
(D15.4): read-only for every launch class.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
launch_class: LaunchClass
|
|
200
|
+
workspace: str
|
|
201
|
+
home: str
|
|
202
|
+
controller_env: Mapping[str, str]
|
|
203
|
+
git_entry: str = ""
|
|
204
|
+
git_dir: str = ""
|
|
205
|
+
common_dir: str = ""
|
|
206
|
+
private_objects: str = ""
|
|
207
|
+
overlay_work: str = ""
|
|
208
|
+
cache: str = ""
|
|
209
|
+
read_context: str = ""
|
|
210
|
+
controller_config: str = ""
|
|
211
|
+
denied_roots: tuple[str, ...] = ()
|
|
212
|
+
network: NetworkPosition = NetworkPosition.HOST
|
|
213
|
+
operator_paths: tuple[str, ...] = ()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class SandboxPlan:
|
|
218
|
+
"""What the engine hands a provider for one agent launch: the facts.
|
|
219
|
+
|
|
220
|
+
The provider adds its declaration for each process it starts (the
|
|
221
|
+
agent, Pi's auth preflight) and gets the description to launch it with.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
facts: LaunchFacts
|
|
225
|
+
|
|
226
|
+
def describe(self, needs: ProviderRequirements, env: Mapping[str, str]) -> LaunchDescription:
|
|
227
|
+
"""The description of one process of the launch (:func:`describe_launch`)."""
|
|
228
|
+
return describe_launch(self.facts, needs, env)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _inside(path: str, root: str) -> bool:
|
|
232
|
+
return path == root or path.startswith(root.rstrip("/") + "/")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def sandbox_request(
|
|
236
|
+
facts: LaunchFacts, needs: ProviderRequirements | None = None
|
|
237
|
+
) -> SandboxRequest:
|
|
238
|
+
"""§2.5's rows for ``facts.launch_class``, with the provider's declaration.
|
|
239
|
+
|
|
240
|
+
``needs`` applies to an agent launch only; a repository-defined command
|
|
241
|
+
gets no provider resource whatever is passed.
|
|
242
|
+
"""
|
|
243
|
+
projections: list[ProjectionRequest] = []
|
|
244
|
+
masks: list[str] = []
|
|
245
|
+
overlays: list[OverlayRequest] = []
|
|
246
|
+
writable, read_only = Access.WRITABLE, Access.READ_ONLY
|
|
247
|
+
|
|
248
|
+
def engine(
|
|
249
|
+
path: str,
|
|
250
|
+
access: Access,
|
|
251
|
+
label: str,
|
|
252
|
+
*,
|
|
253
|
+
carve_out: bool = True,
|
|
254
|
+
target: str | None = None,
|
|
255
|
+
) -> None:
|
|
256
|
+
projections.append(
|
|
257
|
+
ProjectionRequest(
|
|
258
|
+
path, access, Origin.ENGINE, target=target, carve_out=carve_out, label=label
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
kind = facts.launch_class
|
|
263
|
+
common = facts.common_dir
|
|
264
|
+
cache_target = posixpath.join(facts.home, ".cache")
|
|
265
|
+
if kind is LaunchClass.REMOTE_AGENT:
|
|
266
|
+
# The worktree lies in the masked ``<common>/autoforge`` unless
|
|
267
|
+
# ``execution.worktree_dir`` puts it elsewhere, where it is no carve-out.
|
|
268
|
+
in_common = _inside(facts.workspace, common)
|
|
269
|
+
engine(facts.workspace, writable, "the issue worktree", carve_out=in_common)
|
|
270
|
+
engine(facts.git_entry, read_only, "the worktree's .git file", carve_out=in_common)
|
|
271
|
+
engine(common, read_only, "the git common dir")
|
|
272
|
+
engine(facts.git_dir, writable, "the worktree's admin dir")
|
|
273
|
+
for name in PINNED_ADMIN_FILES:
|
|
274
|
+
pinned = posixpath.join(facts.git_dir, name)
|
|
275
|
+
if os.path.isfile(pinned) and not os.path.islink(pinned):
|
|
276
|
+
engine(pinned, read_only, f"the admin dir's {name}")
|
|
277
|
+
masks += [posixpath.join(common, name) for name in ("autoforge", "worktrees", "modules")]
|
|
278
|
+
overlays.append(
|
|
279
|
+
OverlayRequest(
|
|
280
|
+
posixpath.join(common, "objects"),
|
|
281
|
+
facts.private_objects,
|
|
282
|
+
facts.overlay_work,
|
|
283
|
+
carve_out=True,
|
|
284
|
+
label="the shared object store",
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
if facts.read_context:
|
|
288
|
+
engine(facts.read_context, read_only, "the read context")
|
|
289
|
+
elif kind in (LaunchClass.LOCAL_AGENT, LaunchClass.LOCAL_VALIDATION):
|
|
290
|
+
engine(facts.workspace, writable, "the repository root", carve_out=False)
|
|
291
|
+
engine(facts.git_entry, read_only, "the root's .git", carve_out=False)
|
|
292
|
+
if facts.git_dir != facts.git_entry:
|
|
293
|
+
# A linked worktree: its admin dir, carved out of the masked
|
|
294
|
+
# ``<common>/worktrees``.
|
|
295
|
+
engine(facts.git_dir, read_only, "the checkout's git dir")
|
|
296
|
+
if common not in (facts.git_entry, facts.git_dir):
|
|
297
|
+
engine(common, read_only, "the git common dir")
|
|
298
|
+
for path in (
|
|
299
|
+
posixpath.join(facts.git_dir, "autoforge"),
|
|
300
|
+
posixpath.join(common, "autoforge"),
|
|
301
|
+
posixpath.join(common, "worktrees"),
|
|
302
|
+
):
|
|
303
|
+
if path not in masks:
|
|
304
|
+
masks.append(path)
|
|
305
|
+
else:
|
|
306
|
+
engine(facts.workspace, writable, "the exported tree", carve_out=False)
|
|
307
|
+
if facts.cache and kind is not LaunchClass.EXPORT_COMMAND:
|
|
308
|
+
engine(facts.cache, writable, "the cache", target=cache_target)
|
|
309
|
+
if facts.controller_config:
|
|
310
|
+
engine(
|
|
311
|
+
facts.controller_config,
|
|
312
|
+
read_only,
|
|
313
|
+
"the launch's gh, git and shell configuration",
|
|
314
|
+
carve_out=False,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
agent = kind in (LaunchClass.REMOTE_AGENT, LaunchClass.LOCAL_AGENT)
|
|
318
|
+
declared = _declared(needs if agent else None, facts.operator_paths, facts.home)
|
|
319
|
+
projections += declared.projections
|
|
320
|
+
network = NetworkPosition.HOST if agent else facts.network
|
|
321
|
+
return SandboxRequest(
|
|
322
|
+
home=facts.home,
|
|
323
|
+
controller_env=facts.controller_env,
|
|
324
|
+
projections=tuple(projections),
|
|
325
|
+
masks=tuple(masks),
|
|
326
|
+
overlays=tuple(overlays),
|
|
327
|
+
denied_roots=tuple(dict.fromkeys(facts.denied_roots)),
|
|
328
|
+
operator_config_roots=declared.operator_roots,
|
|
329
|
+
keyring_items=declared.keyring,
|
|
330
|
+
spelled_paths=declared.spelled,
|
|
331
|
+
network=network,
|
|
332
|
+
env=declared.env,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
OPERATOR_PATHS_KEY = "sandbox.read_only_paths"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def operator_path_problem(operator_paths: tuple[str, ...], denied_roots: tuple[str, ...]) -> str:
|
|
340
|
+
"""The first operator read-only path that overlaps a denied root, as a reason; "" if none.
|
|
341
|
+
|
|
342
|
+
ADR 0005 D15.4: an operator path may not name, sit inside or contain
|
|
343
|
+
what no launch may see -- the state directory, the lock, the
|
|
344
|
+
controller's directory under the git common dir, another worktree, the
|
|
345
|
+
operator's checkout in REMOTE. The loader cannot know these (they are
|
|
346
|
+
the run's), so the engine's gate and ``doctor`` ask before a launch
|
|
347
|
+
would; the compiler refuses the same overlap at the launch regardless.
|
|
348
|
+
Both sides are compared resolved, as the compiler compares them.
|
|
349
|
+
"""
|
|
350
|
+
denied = [(root, os.path.realpath(root)) for root in denied_roots]
|
|
351
|
+
for index, path in enumerate(operator_paths):
|
|
352
|
+
real = os.path.realpath(path)
|
|
353
|
+
for root, resolved in denied:
|
|
354
|
+
if _inside(real, resolved) or _inside(resolved, real):
|
|
355
|
+
return (
|
|
356
|
+
f"{OPERATOR_PATHS_KEY}[{index}] {path!r} overlaps {root!r}, which no "
|
|
357
|
+
"launch may see (ADR 0005 D15.4: the state directory, the lock, the "
|
|
358
|
+
"controller's directory under the git common dir, another worktree, and "
|
|
359
|
+
"in REMOTE the operator's checkout)"
|
|
360
|
+
)
|
|
361
|
+
return ""
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# The links a lookup follows before it is a loop, as the kernel counts them
|
|
365
|
+
# (``MAXSYMLINKS``): past it ``open`` fails, so nothing further is reached.
|
|
366
|
+
_MAX_LOOKUP_LINKS = 40
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def absolute_spelling(path: str | os.PathLike[str]) -> str:
|
|
370
|
+
"""``path`` as the configuration loader opens it, made absolute with every ``..`` kept (#280).
|
|
371
|
+
|
|
372
|
+
The loader opens ``Path(path)``, which drops only ``.`` components and
|
|
373
|
+
repeated slashes, and a relative path from the working directory: so
|
|
374
|
+
the working directory is joined to it and nothing else is collapsed.
|
|
375
|
+
``os.path.abspath`` would remove ``name/..`` before ``name`` is
|
|
376
|
+
followed, and when ``name`` is a symbolic link what is left names
|
|
377
|
+
another file than the one ``open`` reaches. Whatever checks where the
|
|
378
|
+
file lies, records it or hands it to another process spells it so.
|
|
379
|
+
"""
|
|
380
|
+
return os.fspath(Path(path).absolute())
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def lookup_directories(path: str) -> tuple[str, ...]:
|
|
384
|
+
"""Each directory, resolved, in which opening ``path`` looks up a name (#280).
|
|
385
|
+
|
|
386
|
+
Whoever can write one of them can replace what ``path`` opens: move the
|
|
387
|
+
entry away and put another file or a link in its place. A relative path
|
|
388
|
+
is walked from the working directory, and the path is taken as spelled
|
|
389
|
+
(:func:`absolute_spelling`): a ``..`` leads to the parent of the
|
|
390
|
+
directory the walk has reached, any link before it already followed, as
|
|
391
|
+
the kernel takes it, and a directory a name was looked up in stays
|
|
392
|
+
listed when a ``..`` leads back out of it. Symbolic links are followed
|
|
393
|
+
as the kernel does (a relative target from the directory holding the
|
|
394
|
+
link, an absolute one from ``/``), so a link inside a tree and a link
|
|
395
|
+
into one both show. It stops at a name that is not there, and after more
|
|
396
|
+
links than a lookup follows: nothing past either is reached yet, and the
|
|
397
|
+
directory the name was looked up in is already listed. ``lstat`` and
|
|
398
|
+
``readlink`` only; nothing is opened.
|
|
399
|
+
"""
|
|
400
|
+
return _lookup(path)[0]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _lookup(path: str) -> tuple[tuple[str, ...], str]:
|
|
404
|
+
""":func:`lookup_directories`, and the entry the walk ends at, resolved.
|
|
405
|
+
|
|
406
|
+
The entry is "" when the walk stops before the last name.
|
|
407
|
+
"""
|
|
408
|
+
looked_up: list[str] = []
|
|
409
|
+
current = "/"
|
|
410
|
+
pending = [part for part in absolute_spelling(path).split("/") if part]
|
|
411
|
+
links = 0
|
|
412
|
+
while pending:
|
|
413
|
+
name = pending.pop(0)
|
|
414
|
+
if name == ".":
|
|
415
|
+
continue
|
|
416
|
+
if name == "..":
|
|
417
|
+
current = posixpath.dirname(current)
|
|
418
|
+
continue
|
|
419
|
+
if current not in looked_up:
|
|
420
|
+
looked_up.append(current)
|
|
421
|
+
entry = posixpath.join(current, name)
|
|
422
|
+
try:
|
|
423
|
+
is_link = os.path.islink(entry)
|
|
424
|
+
if not is_link and not os.path.lexists(entry):
|
|
425
|
+
return tuple(looked_up), ""
|
|
426
|
+
target = os.readlink(entry) if is_link else ""
|
|
427
|
+
except OSError:
|
|
428
|
+
return tuple(looked_up), ""
|
|
429
|
+
if not is_link:
|
|
430
|
+
current = entry
|
|
431
|
+
continue
|
|
432
|
+
links += 1
|
|
433
|
+
if links > _MAX_LOOKUP_LINKS:
|
|
434
|
+
return tuple(looked_up), ""
|
|
435
|
+
if target.startswith("/"):
|
|
436
|
+
current = "/"
|
|
437
|
+
pending[:0] = [part for part in target.split("/") if part]
|
|
438
|
+
return tuple(looked_up), current
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def declared_writable(needs: ProviderRequirements) -> tuple[tuple[str, str], ...]:
|
|
442
|
+
"""Each host directory a provider's declaration makes writable, with what it is (#280).
|
|
443
|
+
|
|
444
|
+
Its state directories and the directory of every ``dir-rw`` credential
|
|
445
|
+
item, as :func:`sandbox_request` projects them read-write: what a
|
|
446
|
+
launch of the provider writes on the host besides the engine's roots,
|
|
447
|
+
a dedicated ``CLAUDE_CONFIG_DIR``, ``PI_CODING_AGENT_DIR`` or OpenCode
|
|
448
|
+
database directory among them. Each is ``(path, phrase)``, the path as
|
|
449
|
+
declared.
|
|
450
|
+
"""
|
|
451
|
+
state = [(path, f"a state directory its provider declares ({path})") for path in needs.state]
|
|
452
|
+
auth = [(item.source, item.label) for item in needs.auth if item.mode is AuthMode.DIR_RW]
|
|
453
|
+
return (*state, *auth)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def config_file_problem(
|
|
457
|
+
config_file: str,
|
|
458
|
+
written_roots: tuple[str, ...],
|
|
459
|
+
mode: str,
|
|
460
|
+
declared: Mapping[str, str] | None = None,
|
|
461
|
+
) -> str:
|
|
462
|
+
"""Why an enforced launch could rewrite the configuration file, as a refusal; "" if none.
|
|
463
|
+
|
|
464
|
+
``written_roots`` are the trees the repository's enforced launches write
|
|
465
|
+
and that outlive the launch: its working trees (the checkout, and every
|
|
466
|
+
worktree registered to it, the agent worktrees included) and its sandbox
|
|
467
|
+
directories (the caches). ``declared`` are
|
|
468
|
+
the directories the providers' declarations make writable
|
|
469
|
+
(:func:`declared_writable`), each with the phrase that says what it is
|
|
470
|
+
and whose launches write it. A file is within reach when opening it
|
|
471
|
+
looks up a name in one of either (:func:`lookup_directories`), and when
|
|
472
|
+
it has a second hard link, which may lie in one (ADR 0005 D4.4). The
|
|
473
|
+
next command would run on what such a file says: the sandbox mode, the
|
|
474
|
+
controller's GitHub command and the commands of every profile
|
|
475
|
+
``doctor`` checks (#280). Both sides are compared resolved.
|
|
476
|
+
|
|
477
|
+
``config_file`` is the path the loader was given, relative or not. It
|
|
478
|
+
is walked as spelled (:func:`absolute_spelling`), never normalized
|
|
479
|
+
first: a ``..`` after a link is taken where the link leads, so a file
|
|
480
|
+
in a root is not passed under the name of one beside it, and a path
|
|
481
|
+
that looks up a name in a root and leaves it again is within reach
|
|
482
|
+
there. The file examined is the one the path opens: the walk has to end
|
|
483
|
+
at it, or nothing shows where it is reached from and it is refused, and
|
|
484
|
+
its own link count is read.
|
|
485
|
+
"""
|
|
486
|
+
config_file = absolute_spelling(config_file)
|
|
487
|
+
direct = os.path.realpath(config_file) == config_file
|
|
488
|
+
roots = [(root, os.path.realpath(root), "") for root in written_roots]
|
|
489
|
+
roots += [(root, os.path.realpath(root), what) for root, what in (declared or {}).items()]
|
|
490
|
+
walked, reached = _lookup(config_file)
|
|
491
|
+
for directory in walked:
|
|
492
|
+
for root, resolved, what in roots:
|
|
493
|
+
if _inside(directory, resolved):
|
|
494
|
+
# The deepest directory of the walk in this root names the lookup.
|
|
495
|
+
deepest = [d for d in walked if _inside(d, resolved)][-1]
|
|
496
|
+
where = "lies in" if direct else f"is reached through {deepest}, in"
|
|
497
|
+
if what:
|
|
498
|
+
return (
|
|
499
|
+
f"the configuration file {config_file} {where} {root}, {what} "
|
|
500
|
+
f"(sandbox.mode is {mode}): a sandboxed agent could rewrite it, and the "
|
|
501
|
+
"next command would run on what it says, sandbox.mode, github.command "
|
|
502
|
+
"and the profiles' commands included (#280). Move it outside that "
|
|
503
|
+
"directory and every working tree of this repository (for example to "
|
|
504
|
+
"~/.config/autoforge/) and pass that path with --config, give the "
|
|
505
|
+
"provider another directory, or set sandbox.mode: disabled"
|
|
506
|
+
)
|
|
507
|
+
return (
|
|
508
|
+
f"the configuration file {config_file} {where} {root}, a tree that "
|
|
509
|
+
f"launches inside the outer sandbox write (sandbox.mode is {mode}): a "
|
|
510
|
+
"sandboxed agent could rewrite it, and the next command would run on what "
|
|
511
|
+
"it says, sandbox.mode, github.command and the profiles' commands "
|
|
512
|
+
"included (#280). Move it outside every working tree of this repository "
|
|
513
|
+
"(for example to ~/.config/autoforge/) and pass that path with --config, "
|
|
514
|
+
"or set sandbox.mode: disabled"
|
|
515
|
+
)
|
|
516
|
+
try:
|
|
517
|
+
opened = os.stat(config_file)
|
|
518
|
+
except OSError:
|
|
519
|
+
return ""
|
|
520
|
+
try:
|
|
521
|
+
followed = bool(reached) and os.path.samestat(opened, os.stat(reached))
|
|
522
|
+
except OSError:
|
|
523
|
+
followed = False
|
|
524
|
+
if not followed:
|
|
525
|
+
return _not_followed(config_file, mode)
|
|
526
|
+
links = opened.st_nlink
|
|
527
|
+
if links > 1:
|
|
528
|
+
return (
|
|
529
|
+
f"the configuration file {config_file} has {links} hard links, and another one "
|
|
530
|
+
"may lie in a tree that launches inside the outer sandbox write (sandbox.mode is "
|
|
531
|
+
f"{mode}; ADR 0005 D4.4): a sandboxed agent could rewrite it through that link "
|
|
532
|
+
"(#280). Copy it to a file of its own outside every working tree of this "
|
|
533
|
+
"repository and every directory a provider's launches write, and pass that path "
|
|
534
|
+
"with --config, or set sandbox.mode: disabled"
|
|
535
|
+
)
|
|
536
|
+
return ""
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _not_followed(config_file: str, mode: str) -> str:
|
|
540
|
+
return (
|
|
541
|
+
f"the configuration file {config_file} opens another file than its lookup walk was "
|
|
542
|
+
"followed to, so nothing shows that it lies outside every tree that launches inside "
|
|
543
|
+
f"the outer sandbox write (sandbox.mode is {mode}; #280). Pass the file's own path "
|
|
544
|
+
"with --config, or set sandbox.mode: disabled"
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
@dataclass(frozen=True)
|
|
549
|
+
class _Declared:
|
|
550
|
+
projections: tuple[ProjectionRequest, ...]
|
|
551
|
+
env: tuple[tuple[str, str], ...]
|
|
552
|
+
keyring: tuple[str, ...]
|
|
553
|
+
operator_roots: tuple[str, ...]
|
|
554
|
+
spelled: tuple[str, ...] = ()
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _declared(
|
|
558
|
+
needs: ProviderRequirements | None, operator_paths: tuple[str, ...], home: str
|
|
559
|
+
) -> _Declared:
|
|
560
|
+
"""The declared projections of a launch: the provider's, then the operator's.
|
|
561
|
+
|
|
562
|
+
Every one is :attr:`Origin.DECLARED`, so C6 decides its overlaps. The
|
|
563
|
+
provider's read-only install roots and configuration are kept outermost,
|
|
564
|
+
as before; an operator path outside the home that one of them already
|
|
565
|
+
shows is dropped for this launch, and a provider root inside an
|
|
566
|
+
operator path is dropped in its favour (both are read-only, so the outer
|
|
567
|
+
one shows the inner). An operator path is projected by its spelling
|
|
568
|
+
(the compiler resolves it, C1), so a launch class without a provider
|
|
569
|
+
sees exactly the configured list; one inside the home appears at that
|
|
570
|
+
spelling in the private home (C7, D15.4), as a provider's home
|
|
571
|
+
configuration does.
|
|
572
|
+
"""
|
|
573
|
+
projections: list[ProjectionRequest] = []
|
|
574
|
+
env: list[tuple[str, str]] = []
|
|
575
|
+
keyring: list[str] = []
|
|
576
|
+
in_home = [p for p in operator_paths if _inside(p, home) and p != home]
|
|
577
|
+
elsewhere = [p for p in operator_paths if p not in in_home]
|
|
578
|
+
roots = _outermost([*needs.install_roots, *needs.config]) if needs is not None else []
|
|
579
|
+
if roots:
|
|
580
|
+
real = {p: os.path.realpath(p) for p in elsewhere}
|
|
581
|
+
elsewhere = [p for p in elsewhere if not any(_inside(real[p], r) for r in roots)]
|
|
582
|
+
roots = [r for r in roots if not any(_inside(r, real[p]) for p in elsewhere)]
|
|
583
|
+
for path in roots:
|
|
584
|
+
projections.append(
|
|
585
|
+
ProjectionRequest(path, Access.READ_ONLY, Origin.DECLARED, label=f"{path} (provider)")
|
|
586
|
+
)
|
|
587
|
+
for path in elsewhere:
|
|
588
|
+
projections.append(
|
|
589
|
+
ProjectionRequest(
|
|
590
|
+
path, Access.READ_ONLY, Origin.DECLARED, label=f"{path} ({OPERATOR_PATHS_KEY})"
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
for path in in_home:
|
|
594
|
+
projections.append(
|
|
595
|
+
ProjectionRequest(
|
|
596
|
+
path,
|
|
597
|
+
Access.READ_ONLY,
|
|
598
|
+
Origin.DECLARED,
|
|
599
|
+
target=path,
|
|
600
|
+
label=f"{path} ({OPERATOR_PATHS_KEY})",
|
|
601
|
+
)
|
|
602
|
+
)
|
|
603
|
+
if needs is None:
|
|
604
|
+
return _Declared(tuple(projections), (), (), ())
|
|
605
|
+
for path in needs.home_config:
|
|
606
|
+
projections.append(
|
|
607
|
+
ProjectionRequest(
|
|
608
|
+
path, Access.READ_ONLY, Origin.DECLARED, target=path, label=f"{path} (provider)"
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
for path in needs.state:
|
|
612
|
+
projections.append(
|
|
613
|
+
ProjectionRequest(path, Access.WRITABLE, Origin.DECLARED, label=f"{path} (provider)")
|
|
614
|
+
)
|
|
615
|
+
for item in needs.auth:
|
|
616
|
+
if item.mode is AuthMode.KEYRING:
|
|
617
|
+
keyring.append(item.source or item.label)
|
|
618
|
+
continue
|
|
619
|
+
if item.mode not in (AuthMode.FILE_RO, AuthMode.DIR_RW):
|
|
620
|
+
continue
|
|
621
|
+
access = Access.WRITABLE if item.mode is AuthMode.DIR_RW else Access.READ_ONLY
|
|
622
|
+
projections.append(
|
|
623
|
+
ProjectionRequest(
|
|
624
|
+
item.source,
|
|
625
|
+
access,
|
|
626
|
+
Origin.DECLARED,
|
|
627
|
+
target=item.target,
|
|
628
|
+
label=f"{item.label} ({item.mode.value})",
|
|
629
|
+
)
|
|
630
|
+
)
|
|
631
|
+
if item.env_name:
|
|
632
|
+
env.append((item.env_name, item.target or item.source))
|
|
633
|
+
return _Declared(
|
|
634
|
+
tuple(projections),
|
|
635
|
+
tuple(env),
|
|
636
|
+
tuple(keyring),
|
|
637
|
+
tuple(needs.operator_config_roots),
|
|
638
|
+
tuple(needs.spelled_paths),
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def provider_request(
|
|
643
|
+
needs: ProviderRequirements,
|
|
644
|
+
*,
|
|
645
|
+
home: str,
|
|
646
|
+
controller_env: Mapping[str, str],
|
|
647
|
+
workspace: str,
|
|
648
|
+
operator_paths: tuple[str, ...] = (),
|
|
649
|
+
denied_roots: tuple[str, ...] = (),
|
|
650
|
+
) -> SandboxRequest:
|
|
651
|
+
"""A provider's declaration alone, around a scratch ``workspace`` (ADR 0005 D14.1).
|
|
652
|
+
|
|
653
|
+
What ``doctor`` compiles to say whether a declaration is allowed, and
|
|
654
|
+
launches the provider's ``--version`` in to prove it is enough: the
|
|
655
|
+
rows every agent launch gets from its provider and the operator, the
|
|
656
|
+
workspace writable, and none of a workflow's (no repository, no cache,
|
|
657
|
+
no read context). The same rules as :func:`sandbox_request`'s.
|
|
658
|
+
"""
|
|
659
|
+
declared = _declared(needs, operator_paths, home)
|
|
660
|
+
workspace_projection = ProjectionRequest(
|
|
661
|
+
workspace, Access.WRITABLE, Origin.ENGINE, label="the scratch workspace"
|
|
662
|
+
)
|
|
663
|
+
return SandboxRequest(
|
|
664
|
+
home=home,
|
|
665
|
+
controller_env=controller_env,
|
|
666
|
+
projections=(workspace_projection, *declared.projections),
|
|
667
|
+
denied_roots=tuple(dict.fromkeys(denied_roots)),
|
|
668
|
+
operator_config_roots=declared.operator_roots,
|
|
669
|
+
keyring_items=declared.keyring,
|
|
670
|
+
spelled_paths=declared.spelled,
|
|
671
|
+
env=declared.env,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# The phrases :func:`class_summary` shows for a role path (:func:`role`).
|
|
676
|
+
_ROLE_RE = re.compile(r"/@([a-z-]+)")
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _shown(path: str) -> str:
|
|
680
|
+
return _ROLE_RE.sub(lambda m: f"<{m.group(1).replace('-', ' ')}>", path)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def summary_facts(
|
|
684
|
+
launch_class: LaunchClass,
|
|
685
|
+
network: NetworkPosition,
|
|
686
|
+
*,
|
|
687
|
+
cache: bool,
|
|
688
|
+
operator_paths: tuple[str, ...] = (),
|
|
689
|
+
) -> LaunchFacts:
|
|
690
|
+
"""A launch class's facts with every controller path a role name, for display.
|
|
691
|
+
|
|
692
|
+
The shape :meth:`ControllerEngine` builds for a launch of the class
|
|
693
|
+
(D13.2), before anything is created: what the dry-run plan and
|
|
694
|
+
``doctor`` describe. ``cache`` says whether the launch gets the
|
|
695
|
+
persistent cache (§2.5).
|
|
696
|
+
"""
|
|
697
|
+
facts = LaunchFacts(
|
|
698
|
+
launch_class=launch_class,
|
|
699
|
+
workspace=role("workspace"),
|
|
700
|
+
home=role("home"),
|
|
701
|
+
controller_env={},
|
|
702
|
+
controller_config=role("controller-config"),
|
|
703
|
+
denied_roots=(role("denied-roots"),),
|
|
704
|
+
network=network,
|
|
705
|
+
operator_paths=operator_paths,
|
|
706
|
+
cache=role("cache") if cache and launch_class is not LaunchClass.EXPORT_COMMAND else "",
|
|
707
|
+
)
|
|
708
|
+
if launch_class is LaunchClass.EXPORT_COMMAND:
|
|
709
|
+
return facts
|
|
710
|
+
if launch_class is LaunchClass.REMOTE_AGENT:
|
|
711
|
+
return dataclasses.replace(
|
|
712
|
+
facts,
|
|
713
|
+
git_entry=posixpath.join(role("workspace"), ".git"),
|
|
714
|
+
git_dir=role("admin-dir"),
|
|
715
|
+
common_dir=role("common-dir"),
|
|
716
|
+
private_objects=role("private-objects"),
|
|
717
|
+
overlay_work=role("overlay-work"),
|
|
718
|
+
read_context=role("read-context"),
|
|
719
|
+
)
|
|
720
|
+
entry = posixpath.join(role("workspace"), ".git")
|
|
721
|
+
return dataclasses.replace(facts, git_entry=entry, git_dir=entry, common_dir=entry)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def class_summary(facts: LaunchFacts) -> str:
|
|
725
|
+
"""What a launch of ``facts.launch_class`` gets, in words, from §2.5's mapping.
|
|
726
|
+
|
|
727
|
+
Derived from :func:`sandbox_request` over :func:`summary_facts`, so the
|
|
728
|
+
words cannot drift from what a launch is given: the writable and
|
|
729
|
+
read-only projections by label, the masks, the overlays, what the
|
|
730
|
+
compiler adds to every launch, the denied roots and the network. A
|
|
731
|
+
provider's own declaration is named, never listed: it is per provider
|
|
732
|
+
(``doctor`` lists it).
|
|
733
|
+
"""
|
|
734
|
+
request = sandbox_request(facts)
|
|
735
|
+
writable = [p.label for p in request.projections if p.access is Access.WRITABLE]
|
|
736
|
+
read_only = [p.label for p in request.projections if p.access is Access.READ_ONLY]
|
|
737
|
+
parts = []
|
|
738
|
+
if writable:
|
|
739
|
+
parts.append("writable: " + ", ".join(writable))
|
|
740
|
+
if read_only:
|
|
741
|
+
parts.append("read-only: " + ", ".join(read_only))
|
|
742
|
+
if request.masks:
|
|
743
|
+
parts.append("masked: " + ", ".join(_shown(m) for m in request.masks))
|
|
744
|
+
for overlay in request.overlays:
|
|
745
|
+
parts.append(
|
|
746
|
+
f"overlay: {overlay.label or _shown(overlay.lower)}, its writes landing in "
|
|
747
|
+
f"{_shown(overlay.upper)}"
|
|
748
|
+
)
|
|
749
|
+
if facts.launch_class in (LaunchClass.REMOTE_AGENT, LaunchClass.LOCAL_AGENT):
|
|
750
|
+
parts.append(
|
|
751
|
+
"the provider's executable, install roots, configuration and model credential "
|
|
752
|
+
"as its adapter declares them"
|
|
753
|
+
)
|
|
754
|
+
parts.append(
|
|
755
|
+
"always: /usr and /etc read-only, a private home and /tmp per launch, a new /proc, "
|
|
756
|
+
"a minimal /dev, /sys read-only"
|
|
757
|
+
)
|
|
758
|
+
parts.append(
|
|
759
|
+
"absent: the state directory and run logs, the lock, <git common dir>/autoforge "
|
|
760
|
+
"but the launch's own carve-outs, every other worktree"
|
|
761
|
+
+ (" and the controller checkout" if facts.launch_class is LaunchClass.REMOTE_AGENT else "")
|
|
762
|
+
+ "; credentials, keyrings, /run and everything not listed"
|
|
763
|
+
)
|
|
764
|
+
parts.append(
|
|
765
|
+
"network: "
|
|
766
|
+
+ (
|
|
767
|
+
"isolated (no loopback, no abstract socket, no egress)"
|
|
768
|
+
if request.network is NetworkPosition.ISOLATED
|
|
769
|
+
else "the host's (abstract sockets of the host unreachable; not sandboxed)"
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
return "; ".join(parts)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# The version label of :func:`launch_template`'s encoding. The compiler's
|
|
776
|
+
# fixed choices (a private home and temp, a new /proc, a minimal /dev) are
|
|
777
|
+
# not in a request, so a change to one of them is a change of this label.
|
|
778
|
+
TEMPLATE_VERSION = "autoforge-sandbox-template-v1"
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def role(name: str) -> str:
|
|
782
|
+
"""The stand-in path of role ``name`` in a policy template (ADR 0006 D8.1)."""
|
|
783
|
+
return f"/@{name}"
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def template_facts(
|
|
787
|
+
launch_class: LaunchClass,
|
|
788
|
+
network: NetworkPosition,
|
|
789
|
+
*,
|
|
790
|
+
separate_git_dir: bool = False,
|
|
791
|
+
linked: bool = False,
|
|
792
|
+
operator_paths: tuple[str, ...] = (),
|
|
793
|
+
) -> LaunchFacts:
|
|
794
|
+
"""A repository-defined command's facts with every concrete path a role name.
|
|
795
|
+
|
|
796
|
+
The role-level inputs the engine passes the compiler for a validator
|
|
797
|
+
(ADR 0006 D8.1), before any path is bound: the export or the repository
|
|
798
|
+
root is ``candidate-root``, and so on, and every denied root is one
|
|
799
|
+
``denied-roots`` role, since how many worktrees are registered is not a
|
|
800
|
+
fact of the policy. For LOCAL validation, ``separate_git_dir`` says the
|
|
801
|
+
root's ``.git`` is a file naming the git directory and ``linked`` that
|
|
802
|
+
the checkout is a linked worktree with a common dir of its own: the
|
|
803
|
+
layout decides which of them §2.5 projects, never where they are.
|
|
804
|
+
``operator_paths`` (``sandbox.read_only_paths``, D15.4) are
|
|
805
|
+
configuration, not paths of the launch: they are in the template as
|
|
806
|
+
configured.
|
|
807
|
+
"""
|
|
808
|
+
workspace = role("candidate-root")
|
|
809
|
+
facts = LaunchFacts(
|
|
810
|
+
launch_class=launch_class,
|
|
811
|
+
workspace=workspace,
|
|
812
|
+
home=role("home"),
|
|
813
|
+
controller_env={},
|
|
814
|
+
controller_config=role("controller-config"),
|
|
815
|
+
denied_roots=(role("denied-roots"),),
|
|
816
|
+
network=network,
|
|
817
|
+
operator_paths=operator_paths,
|
|
818
|
+
)
|
|
819
|
+
if launch_class is LaunchClass.EXPORT_COMMAND:
|
|
820
|
+
return facts
|
|
821
|
+
if launch_class is not LaunchClass.LOCAL_VALIDATION:
|
|
822
|
+
raise ValueError(f"no policy template for the {launch_class.value}")
|
|
823
|
+
entry = posixpath.join(workspace, ".git")
|
|
824
|
+
git_dir = role("git-dir") if separate_git_dir or linked else entry
|
|
825
|
+
return dataclasses.replace(
|
|
826
|
+
facts,
|
|
827
|
+
git_entry=entry,
|
|
828
|
+
git_dir=git_dir,
|
|
829
|
+
common_dir=role("common-dir") if linked else git_dir,
|
|
830
|
+
cache=role("cache"),
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def launch_template(facts: LaunchFacts) -> dict:
|
|
835
|
+
"""The path-independent policy template of a command launch (ADR 0006 D8.1).
|
|
836
|
+
|
|
837
|
+
§2.5's request for ``facts`` (:func:`sandbox_request`, pure for a
|
|
838
|
+
command class), as canonical data: the projections and their access,
|
|
839
|
+
the masks, the overlays, the denied roots, the network position and the
|
|
840
|
+
compiler's options. Labels are prose and are left out. Given
|
|
841
|
+
:func:`template_facts`, no concrete path is in it, so two exports of one
|
|
842
|
+
candidate in two temporary directories give one template.
|
|
843
|
+
"""
|
|
844
|
+
request = sandbox_request(facts)
|
|
845
|
+
return {
|
|
846
|
+
"version": TEMPLATE_VERSION,
|
|
847
|
+
"launch_class": facts.launch_class.name,
|
|
848
|
+
"projections": [
|
|
849
|
+
{
|
|
850
|
+
"source": p.source,
|
|
851
|
+
"access": p.access.value,
|
|
852
|
+
"origin": p.origin.value,
|
|
853
|
+
"target": p.target,
|
|
854
|
+
"carve_out": p.carve_out,
|
|
855
|
+
}
|
|
856
|
+
for p in request.projections
|
|
857
|
+
],
|
|
858
|
+
"masks": list(request.masks),
|
|
859
|
+
"overlays": [
|
|
860
|
+
{"lower": o.lower, "upper": o.upper, "work": o.work, "carve_out": o.carve_out}
|
|
861
|
+
for o in request.overlays
|
|
862
|
+
],
|
|
863
|
+
"denied_roots": list(request.denied_roots),
|
|
864
|
+
"operator_config_roots": list(request.operator_config_roots),
|
|
865
|
+
"system_runtime": request.system_runtime,
|
|
866
|
+
"home_size_bytes": request.home_size_bytes,
|
|
867
|
+
"tmp_size_bytes": request.tmp_size_bytes,
|
|
868
|
+
"network": request.network.value,
|
|
869
|
+
"sys": request.sys.value,
|
|
870
|
+
"env": [list(pair) for pair in request.env],
|
|
871
|
+
"env_unset": list(request.env_unset),
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _outermost(paths: list[str]) -> list[str]:
|
|
876
|
+
"""``paths`` resolved, without those inside another one (C6 would refuse them)."""
|
|
877
|
+
resolved = list(dict.fromkeys(os.path.realpath(p) for p in paths if p))
|
|
878
|
+
return [p for p in resolved if not any(q != p and _inside(p, q) for q in resolved)]
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def describe_launch(
|
|
882
|
+
facts: LaunchFacts, needs: ProviderRequirements | None, env: Mapping[str, str]
|
|
883
|
+
) -> LaunchDescription:
|
|
884
|
+
"""Compile one launch's description and rewrite its environment (D8.2).
|
|
885
|
+
|
|
886
|
+
``env`` is the environment the launch would get without a sandbox (the
|
|
887
|
+
allow-listed variables and the controller's overrides). A request the
|
|
888
|
+
compiler refuses is a :class:`SandboxLaunchRefusedError` naming the path
|
|
889
|
+
and the rule, raised before anything is spawned.
|
|
890
|
+
"""
|
|
891
|
+
try:
|
|
892
|
+
description = compile_launch(sandbox_request(facts, needs))
|
|
893
|
+
except SandboxPolicyError as exc:
|
|
894
|
+
raise policy_refusal(exc, facts) from exc
|
|
895
|
+
return rewrite_environment(description, env, needs)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def policy_refusal(exc: SandboxPolicyError, facts: LaunchFacts) -> SandboxLaunchRefusedError:
|
|
899
|
+
"""A compiler refusal as the launch refusal the engine records (D12.1)."""
|
|
900
|
+
reason = redact(str(exc))
|
|
901
|
+
return SandboxLaunchRefusedError(
|
|
902
|
+
f"the sandbox cannot be established for the {facts.launch_class.value}: {reason}",
|
|
903
|
+
SandboxFacts(enforced=False, backend="", network=facts.network.value, setup_failure=reason),
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def inside_spelling(description: LaunchDescription, value: str) -> str | None:
|
|
908
|
+
"""The absolute host path ``value`` as a launch must spell it inside (D8.2).
|
|
909
|
+
|
|
910
|
+
``None`` when nothing of the description shows what it names. Its own
|
|
911
|
+
spelling when that leads inside to where its host target is shown;
|
|
912
|
+
else that place: a link on its host path that the sandbox does not
|
|
913
|
+
recreate (a link in the private home, or anywhere the root is empty),
|
|
914
|
+
or a projection that appears elsewhere than at its host path (the cache).
|
|
915
|
+
"""
|
|
916
|
+
if not value.startswith("/"):
|
|
917
|
+
return None
|
|
918
|
+
inner = description.inside_path(os.path.realpath(value))
|
|
919
|
+
if inner is None or description.resolve_inside(value) == inner:
|
|
920
|
+
return None if inner is None else value
|
|
921
|
+
return inner
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _auth_variables(needs: ProviderRequirements | None) -> dict[str, str]:
|
|
925
|
+
"""The variables naming a projected auth item at its host path: name to host path."""
|
|
926
|
+
if needs is None:
|
|
927
|
+
return {}
|
|
928
|
+
return {
|
|
929
|
+
item.env_name: item.source
|
|
930
|
+
for item in needs.auth
|
|
931
|
+
if item.env_name
|
|
932
|
+
and item.target is None
|
|
933
|
+
and item.mode in (AuthMode.FILE_RO, AuthMode.DIR_RW)
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def rewrite_environment(
|
|
938
|
+
description: LaunchDescription,
|
|
939
|
+
env: Mapping[str, str],
|
|
940
|
+
needs: ProviderRequirements | None = None,
|
|
941
|
+
) -> LaunchDescription:
|
|
942
|
+
"""``description`` with the launch's path-valued names made true inside (D8.2).
|
|
943
|
+
|
|
944
|
+
A name AutoForge knows to be a path (the CA-bundle names, #165's
|
|
945
|
+
overrides, the provider's ``path_names``) whose absolute value names
|
|
946
|
+
nothing visible inside is unset, and one whose spelling leads elsewhere
|
|
947
|
+
inside is set to where what it names is (:func:`inside_spelling`); a
|
|
948
|
+
value that is not an absolute path (``OPENCODE_DB=:memory:``, a name
|
|
949
|
+
relative to the provider's own data directory) passes unchanged, as any
|
|
950
|
+
other variable does. ``PATH`` keeps its visible absolute entries, each so
|
|
951
|
+
spelled, after the provider's ``path_dirs``, and is unset when none is
|
|
952
|
+
left. The compiler's own names (``HOME``, ``XDG_*``, ``TMPDIR``) are
|
|
953
|
+
left alone; the variable an auth item names (``PI_CODING_AGENT_DIR``)
|
|
954
|
+
is set by the compiler to the item's host path and spelled here too.
|
|
955
|
+
"""
|
|
956
|
+
compiled = dict(description.env_set)
|
|
957
|
+
auth = {name: path for name, path in _auth_variables(needs).items() if name in compiled}
|
|
958
|
+
owned = (set(compiled) | set(description.env_unset)) - set(auth)
|
|
959
|
+
names = [*CA_BUNDLE_NAMES, *OVERRIDE_PATH_NAMES, *(needs.path_names if needs else ())]
|
|
960
|
+
values = {name: env.get(name, "") for name in dict.fromkeys(names)}
|
|
961
|
+
values.update(auth)
|
|
962
|
+
moved: dict[str, str] = {}
|
|
963
|
+
unset: set[str] = set()
|
|
964
|
+
for name, value in values.items():
|
|
965
|
+
if name in owned or not value.startswith("/"):
|
|
966
|
+
continue
|
|
967
|
+
spelled = inside_spelling(description, value)
|
|
968
|
+
if spelled is None:
|
|
969
|
+
unset.add(name)
|
|
970
|
+
elif spelled != value:
|
|
971
|
+
moved[name] = spelled
|
|
972
|
+
if "PATH" not in owned and ("PATH" in env or (needs and needs.path_dirs)):
|
|
973
|
+
entries = [*(needs.path_dirs if needs else ()), *env.get("PATH", "").split(":")]
|
|
974
|
+
spelled_entries = (inside_spelling(description, entry) for entry in entries)
|
|
975
|
+
kept = list(dict.fromkeys(entry for entry in spelled_entries if entry))
|
|
976
|
+
if kept:
|
|
977
|
+
if ":".join(kept) != env.get("PATH"):
|
|
978
|
+
moved["PATH"] = ":".join(kept)
|
|
979
|
+
elif "PATH" in env:
|
|
980
|
+
unset.add("PATH")
|
|
981
|
+
if not unset and not moved:
|
|
982
|
+
return description
|
|
983
|
+
env_set = {name: value for name, value in compiled.items() if name not in unset}
|
|
984
|
+
env_set.update(moved)
|
|
985
|
+
return dataclasses.replace(
|
|
986
|
+
description,
|
|
987
|
+
env_set=tuple(sorted(env_set.items())),
|
|
988
|
+
env_unset=tuple(sorted(set(description.env_unset) | unset)),
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
# ------------------------------------------------- executables and install roots
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def resolve_command(name: str, search_path: str | None) -> str:
|
|
996
|
+
"""The resolved host path ``name`` runs as, or "" when it cannot be told.
|
|
997
|
+
|
|
998
|
+
A bare name is looked up on ``search_path`` (the launch's ``PATH``, as
|
|
999
|
+
the executor's spawn would); a relative path with a separator is left
|
|
1000
|
+
alone, since it would be resolved against the launch's directory.
|
|
1001
|
+
"""
|
|
1002
|
+
found = name if "/" in name else shutil.which(name, path=search_path)
|
|
1003
|
+
if not found or not found.startswith("/"):
|
|
1004
|
+
return ""
|
|
1005
|
+
real = os.path.realpath(found)
|
|
1006
|
+
return real if os.path.isfile(real) and os.access(real, os.X_OK) else ""
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def install_root(path: str, home: str) -> str:
|
|
1010
|
+
"""The read-only root a resolved executable is installed in (D13.1).
|
|
1011
|
+
|
|
1012
|
+
The package directory for a file inside ``node_modules`` (its modules
|
|
1013
|
+
sit below it). Else the prefix of ``<prefix>/bin/<file>`` when the
|
|
1014
|
+
prefix also has a ``lib`` directory (an interpreter's standard library
|
|
1015
|
+
sits there), unless the prefix is the home, one of its ancestors or
|
|
1016
|
+
``~/.local``. Else the file's directory, unless that directory is the
|
|
1017
|
+
home or one of its ancestors: then the file alone, never all of the home.
|
|
1018
|
+
"""
|
|
1019
|
+
parts = path.split("/")
|
|
1020
|
+
if "node_modules" in parts:
|
|
1021
|
+
index = parts.index("node_modules")
|
|
1022
|
+
package = (
|
|
1023
|
+
index + 2
|
|
1024
|
+
if index + 1 < len(parts) and parts[index + 1].startswith("@")
|
|
1025
|
+
else (index + 1)
|
|
1026
|
+
)
|
|
1027
|
+
if package < len(parts) - 1:
|
|
1028
|
+
return "/".join(parts[: package + 1])
|
|
1029
|
+
parent = posixpath.dirname(path)
|
|
1030
|
+
homes = (home, os.path.realpath(home))
|
|
1031
|
+
if posixpath.basename(parent) == "bin":
|
|
1032
|
+
prefix = posixpath.dirname(parent)
|
|
1033
|
+
broad = any(_inside(anchor, prefix) for anchor in homes) or prefix in (
|
|
1034
|
+
posixpath.join(anchor, ".local") for anchor in homes
|
|
1035
|
+
)
|
|
1036
|
+
if prefix != "/" and not broad and os.path.isdir(posixpath.join(prefix, "lib")):
|
|
1037
|
+
return prefix
|
|
1038
|
+
for anchor in homes:
|
|
1039
|
+
if _inside(anchor, parent):
|
|
1040
|
+
return path
|
|
1041
|
+
return path if parent == "/" else parent
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _shebang(executable: str) -> list[str]:
|
|
1045
|
+
"""The words of an executable's ``#!`` line, ``[]`` without one. Only
|
|
1046
|
+
the first line is read."""
|
|
1047
|
+
try:
|
|
1048
|
+
with open(executable, "rb") as handle:
|
|
1049
|
+
head = handle.read(_SHEBANG_BYTES)
|
|
1050
|
+
except OSError:
|
|
1051
|
+
return []
|
|
1052
|
+
if not head.startswith(b"#!"):
|
|
1053
|
+
return []
|
|
1054
|
+
return head[2:].split(b"\n", 1)[0].decode("utf-8", "replace").split()
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _interpreter(words: list[str], search_path: str | None) -> str:
|
|
1058
|
+
if not words:
|
|
1059
|
+
return ""
|
|
1060
|
+
program = words[0]
|
|
1061
|
+
if posixpath.basename(program) == "env":
|
|
1062
|
+
names = [word for word in words[1:] if not word.startswith("-") and "=" not in word]
|
|
1063
|
+
if not names:
|
|
1064
|
+
return ""
|
|
1065
|
+
return resolve_command(names[0], search_path)
|
|
1066
|
+
return resolve_command(program, search_path)
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def interpreter_of(executable: str, search_path: str | None) -> str:
|
|
1070
|
+
"""The resolved interpreter an executable script names (``#!``), or "".
|
|
1071
|
+
|
|
1072
|
+
``#!/usr/bin/env [-S] name`` is looked up on ``search_path``, as ``env``
|
|
1073
|
+
would do inside. Only the first line is read.
|
|
1074
|
+
"""
|
|
1075
|
+
return _interpreter(_shebang(executable), search_path)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def executable_requirements(
|
|
1079
|
+
command: str, *, home: str, search_path: str | None
|
|
1080
|
+
) -> tuple[str, tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
|
|
1081
|
+
"""``(executable, install roots, PATH dirs, spelled paths)`` for the command a launch names.
|
|
1082
|
+
|
|
1083
|
+
The executable resolved on the host; its install root; and, for a
|
|
1084
|
+
script, its interpreter's install root and directory, which goes first
|
|
1085
|
+
on ``PATH`` so that ``#!/usr/bin/env node`` finds it inside. An
|
|
1086
|
+
absolute ``#!`` path, ``env``'s too, is also a spelled path, as
|
|
1087
|
+
written, and its resolved program's install root is projected: the
|
|
1088
|
+
kernel looks it up so, and a link on its way, such as Homebrew's
|
|
1089
|
+
``opt/`` into ``Cellar/``, need not lie in any root projected (#225).
|
|
1090
|
+
"""
|
|
1091
|
+
executable = resolve_command(command, search_path)
|
|
1092
|
+
if not executable:
|
|
1093
|
+
return "", (), (), ()
|
|
1094
|
+
roots = [install_root(executable, home)]
|
|
1095
|
+
path_dirs: list[str] = []
|
|
1096
|
+
spelled: list[str] = []
|
|
1097
|
+
words = _shebang(executable)
|
|
1098
|
+
program = resolve_command(words[0], search_path) if words and words[0].startswith("/") else ""
|
|
1099
|
+
if program:
|
|
1100
|
+
# What the kernel runs, as written: ``env`` is no exception (R1-F1).
|
|
1101
|
+
roots.append(install_root(program, home))
|
|
1102
|
+
spelled.append(words[0])
|
|
1103
|
+
interpreter = _interpreter(words, search_path)
|
|
1104
|
+
if interpreter:
|
|
1105
|
+
roots.append(install_root(interpreter, home))
|
|
1106
|
+
path_dirs.append(posixpath.dirname(interpreter))
|
|
1107
|
+
return executable, tuple(dict.fromkeys(roots)), tuple(path_dirs), tuple(spelled)
|