autoforge-cli 0.1.0a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/child_env.py ADDED
@@ -0,0 +1,871 @@
1
+ """What AutoForge hands a process it launches outside itself (#165).
2
+
3
+ Agents, LOCAL validation commands and pre-merge verification commands are
4
+ untrusted: an agent follows whatever a prompt injection in project data
5
+ tells it, and a repository-defined command is the PR's own code. Every
6
+ externally visible write of a run is the controller's (ADR 0004), so none
7
+ of these processes may hold a GitHub or git mutation credential through
8
+ anything AutoForge controls. This module is that boundary, provider-neutral
9
+ and with no workflow semantics; the engine applies it to all three kinds
10
+ of launch the same way.
11
+
12
+ - **Names that are never inherited** (:data:`FORBIDDEN_ENV_NAMES`,
13
+ :data:`FORBIDDEN_ENV_PREFIXES`). gh's tokens and configuration location,
14
+ the programs git and ssh run to obtain a credential, the SSH agent
15
+ socket, git configuration taken from the environment, the session bus a
16
+ desktop keyring answers on, where curl and wget find the
17
+ configuration file they read on every request, ``NETRC``, the
18
+ ``.netrc`` curl (8.16 and later), Python's ``requests`` and Go read
19
+ instead of ``~/.netrc``, and where a shell finds a startup file
20
+ (``BASH_ENV``, ``ENV``, ``ZDOTDIR``).
21
+ :func:`refuse_forbidden_env` refuses an allow-list entry that names one,
22
+ or a ``*`` prefix that covers one, at config load
23
+ (``execution.env_allowlist`` / ``env_allowlist_extra``) and for every
24
+ provider adapter's own names, naming the variable and the rule, never a
25
+ value.
26
+ - **What the controller sets instead** (:func:`isolated_overrides`), over
27
+ the allow-listed environment, for one launch:
28
+
29
+ - ``GH_CONFIG_DIR`` names an empty directory the controller created for
30
+ the launch, so the child's ``gh`` finds no ``hosts.yml``;
31
+ - ``GH_TOKEN`` and ``GH_ENTERPRISE_TOKEN`` carry
32
+ :data:`GH_PLACEHOLDER_TOKEN`, which is not a credential: with no token
33
+ in its environment or configuration, ``gh`` (2.40 and later) falls
34
+ back to the active login it keeps in the system keyring, which every
35
+ process of the user can reach on macOS. A token in the environment is
36
+ used first and the keyring is never asked, and GitHub refuses this one;
37
+ - ``GIT_CONFIG_GLOBAL`` names a configuration file the controller wrote
38
+ for the launch, holding only the author and committer identity copied
39
+ from the operator's global configuration, so the operator's
40
+ ``~/.gitconfig`` (credential helpers, ``url.*.insteadOf``, extra
41
+ headers) is not read, and ``git config --global`` writes land there;
42
+ - credential helpers, the askpass program and ``http.extraHeader`` are
43
+ reset and ``core.sshCommand`` is a hardened ``ssh`` at git's command
44
+ scope (``GIT_CONFIG_COUNT``), which no configuration file, the
45
+ repository's included, can override; ``GIT_TERMINAL_PROMPT=0``. The
46
+ ``ssh`` reads no configuration file and offers no identity file and no
47
+ agent, so git over SSH cannot pick up the operator's default keys;
48
+ - ``CURL_HOME`` names a directory holding an empty ``.curlrc``, and
49
+ ``WGETRC`` (wget 1.x) and ``WGET2RC`` (wget2) an empty file. curl reads
50
+ the first default configuration file it finds, ``$CURL_HOME/.curlrc``
51
+ before the one in ``$XDG_CONFIG_HOME`` (``curlrc`` in curl 8.18,
52
+ ``.curlrc`` in 8.5), ``~/.curlrc`` and the home of the password
53
+ database, and wget reads the file its variable names instead of
54
+ ``~/.wgetrc`` or ``~/.config/wget/wget2rc``; either file can add an
55
+ ``Authorization`` header to every request, so a plain ``curl`` or
56
+ ``wget`` of the child's would otherwise send the operator's token;
57
+ - ``ZDOTDIR`` names an empty directory, where every zsh of the launch
58
+ looks for ``.zshenv``, ``.zprofile``, ``.zshrc`` and ``.zlogin``
59
+ instead of ``HOME``: zsh reads ``.zshenv`` on every start, ``zsh -c``
60
+ included, and an ``export GH_TOKEN=...`` there would replace the
61
+ placeholder for everything the shell runs;
62
+ - ``SHELL`` names a shell that reads no startup file of the operator's to
63
+ run a command (:func:`child_shell`): the operator's own when it is one
64
+ of :data:`POSIX_SHELLS`, else ``bash`` (``/bin/sh`` without one). The
65
+ agent CLIs run their shell tool with it (OpenCode as ``$SHELL -c``,
66
+ Claude Code when it is bash or zsh), and so do the tools that start
67
+ ``$SHELL``; fish and the csh family read their configuration on every
68
+ start.
69
+
70
+ The directory is removed when the launch ends.
71
+ - **A preflight before every launch** (:func:`preflight`): the git
72
+ configuration the child would read (system, repository and worktree
73
+ scope) must not hold a credential helper in repository or worktree scope
74
+ (the shared common dir), a URL with an embedded credential (a remote, an
75
+ ``insteadOf`` base) or an ``http.extraHeader``, which the command-scope
76
+ reset cannot reach when it is set for a URL; ``~/.netrc`` must have no
77
+ word that a netrc reader may take for a GitHub machine or a ``default``
78
+ entry, because git's HTTPS transport reads it through ``HOME``; and the
79
+ startup files of a login shell of the launch's must leave its variables
80
+ alone (:func:`shell_startup_problems`). ``bash -l`` and ``sh -l`` read
81
+ ``~/.bash_profile``, ``~/.profile`` and ``/etc/profile`` through ``HOME``,
82
+ which no variable redirects, so each shell a launch may start (its
83
+ ``SHELL``, ``sh``, ``bash`` and ``zsh``) is run once as a login shell in
84
+ the launch's environment and reports, by name only, every variable of the
85
+ isolation its startup files changed. A refusal is a
86
+ :class:`ConfigurationError` that names the key and scope, the file or the
87
+ shell and variable, never a value; nothing is launched.
88
+
89
+ What this does not do (docs/agent-guides/github-safety.md, "Threat
90
+ model"): it hides nothing from a process that deliberately opens a
91
+ credential file, a keychain or a socket by absolute path, or runs ``ssh``
92
+ or a credential helper of its own choosing; that is the outer sandbox's
93
+ (ADR 0005). ``HOME`` is still passed: the agent CLIs keep their own sign-in
94
+ there. So an interactive ``bash`` still reads ``~/.bashrc`` and a fish or
95
+ csh-family shell started by name still reads its configuration; neither is
96
+ started by a launch through ``SHELL``. Claude Code sources ``~/.zshrc`` or
97
+ ``~/.bashrc`` itself for the functions and aliases its Bash tool runs
98
+ with; its adapter removes them before every command
99
+ (:class:`autoforge.providers.ClaudeCodeProvider`). An agent CLI that applies
100
+ variables of its own configuration over the launch's refuses the launch
101
+ when one of them would undo this isolation (:func:`undoes_isolation`).
102
+ """
103
+
104
+ from __future__ import annotations
105
+
106
+ import os
107
+ import re
108
+ import shlex
109
+ import shutil
110
+ import sys
111
+ import tempfile
112
+ from collections.abc import Callable, Iterable, Iterator, Mapping
113
+ from contextlib import contextmanager
114
+ from dataclasses import dataclass
115
+ from pathlib import Path
116
+ from urllib.parse import unquote, urlsplit
117
+
118
+ from .errors import ConfigurationError, ExecutionError
119
+ from .executor import ExecutionRequest, ExecutionResult, execute
120
+ from .git_transport import local_git_request
121
+ from .redaction import credential_classes, redact
122
+
123
+ Runner = Callable[[ExecutionRequest], ExecutionResult]
124
+
125
+ _GH = "a GitHub CLI token or configuration location"
126
+ _PROGRAM = "a program git or ssh runs to obtain a credential or to connect"
127
+ _SSH_AGENT = "the SSH agent socket"
128
+ _GIT_CONFIG = "git configuration taken from the environment, which can name a credential helper"
129
+ _SESSION_BUS = "the session bus a desktop keyring is reached through"
130
+ _HTTP_CONFIG = (
131
+ "where an HTTP client finds the configuration file it reads on every request, "
132
+ "which can add a credential header"
133
+ )
134
+ _NETRC = "where an HTTP client finds the .netrc file it takes a password from"
135
+ _SHELL_STARTUP = (
136
+ "where a shell finds a startup file it runs before a command, which can export a credential"
137
+ )
138
+
139
+ # Every variable an agent or a repository command must never inherit from
140
+ # the operator's environment, and the rule it falls under. Some of them are
141
+ # set by the controller itself (:func:`child_overrides`), to values that
142
+ # carry no credential; none is ever taken from the operator.
143
+ FORBIDDEN_ENV_NAMES: dict[str, str] = {
144
+ "GH_TOKEN": _GH,
145
+ "GITHUB_TOKEN": _GH,
146
+ "GH_ENTERPRISE_TOKEN": _GH,
147
+ "GITHUB_ENTERPRISE_TOKEN": _GH,
148
+ "GH_HOST": _GH,
149
+ "GH_CONFIG_DIR": _GH,
150
+ "GIT_ASKPASS": _PROGRAM,
151
+ "SSH_ASKPASS": _PROGRAM,
152
+ "GIT_SSH": _PROGRAM,
153
+ "GIT_SSH_COMMAND": _PROGRAM,
154
+ "SSH_AUTH_SOCK": _SSH_AGENT,
155
+ "GIT_CONFIG_GLOBAL": _GIT_CONFIG,
156
+ "GIT_CONFIG_SYSTEM": _GIT_CONFIG,
157
+ "GIT_CONFIG_COUNT": _GIT_CONFIG,
158
+ "GIT_CONFIG_PARAMETERS": _GIT_CONFIG,
159
+ "XDG_RUNTIME_DIR": _SESSION_BUS,
160
+ "DBUS_SESSION_BUS_ADDRESS": _SESSION_BUS,
161
+ "CURL_HOME": _HTTP_CONFIG,
162
+ "WGETRC": _HTTP_CONFIG,
163
+ "WGET2RC": _HTTP_CONFIG,
164
+ "NETRC": _NETRC,
165
+ "BASH_ENV": _SHELL_STARTUP,
166
+ "ENV": _SHELL_STARTUP,
167
+ "ZDOTDIR": _SHELL_STARTUP,
168
+ }
169
+ FORBIDDEN_ENV_PREFIXES: dict[str, str] = {
170
+ "GIT_CONFIG_KEY_": _GIT_CONFIG,
171
+ "GIT_CONFIG_VALUE_": _GIT_CONFIG,
172
+ }
173
+
174
+ # What the child's ``gh`` finds as its token. Not a credential, and not
175
+ # shaped like one (the redactor leaves it alone); GitHub answers 401.
176
+ GH_PLACEHOLDER_TOKEN = "autoforge-no-github-credential"
177
+
178
+ # The ``ssh`` git runs for a child: no configuration file (``-F /dev/null``
179
+ # also skips the system one), no identity file (``IdentityFile=none``; the
180
+ # defaults under ``~/.ssh`` are found through the password database, not
181
+ # ``HOME``, so only this keeps them out), no agent and no shared connection,
182
+ # and no prompt.
183
+ SSH_COMMAND = (
184
+ "ssh -F /dev/null -o IdentityFile=none -o IdentitiesOnly=yes -o IdentityAgent=none "
185
+ "-o ControlPath=none -o BatchMode=yes"
186
+ )
187
+
188
+ # Set at git's command scope (``GIT_CONFIG_COUNT``), which git reads after
189
+ # every configuration file. An empty ``credential.helper`` clears the helper
190
+ # list, URL-specific helpers included (git collects every matching helper in
191
+ # file order); an empty ``http.extraHeader`` clears the generic headers only,
192
+ # so a URL-specific one is refused by the preflight instead.
193
+ GIT_COMMAND_SCOPE: tuple[tuple[str, str], ...] = (
194
+ ("credential.helper", ""),
195
+ ("core.askPass", ""),
196
+ ("core.sshCommand", SSH_COMMAND),
197
+ ("http.extraHeader", ""),
198
+ )
199
+
200
+ # The identity keys copied from the operator's global configuration into the
201
+ # child's: git's own fallbacks for the author and the committer.
202
+ IDENTITY_KEYS = (
203
+ "user.name",
204
+ "user.email",
205
+ "author.name",
206
+ "author.email",
207
+ "committer.name",
208
+ "committer.email",
209
+ )
210
+
211
+ # The scopes of the configuration a child's git still reads: the operator's
212
+ # global configuration is replaced (``GIT_CONFIG_GLOBAL``) and the command
213
+ # scope is the controller's own.
214
+ CHILD_SCOPES = ("system", "local", "worktree")
215
+ # The scopes that live in the repository (the shared common dir and the
216
+ # worktree's own configuration), where a credential helper is refused.
217
+ REPOSITORY_SCOPES = ("local", "worktree")
218
+
219
+ # The machines of a ``.netrc`` entry git's HTTPS transport or ``curl -n``
220
+ # would use for GitHub (a trailing dot is the same host); ``default``
221
+ # matches every host.
222
+ _GITHUB_HOST_RE = re.compile(r"(?i)^(?:[a-z0-9-]+\.)*github\.com\.?$")
223
+ # What separates two words of a ``.netrc`` for some reader: whitespace, and
224
+ # the ``"`` that curl, wget and Python's ``netrc`` quote a word with.
225
+ _NETRC_WORD_BREAK = re.compile(r'[\s"]+')
226
+ NETRC_FILES = (".netrc", "_netrc")
227
+ MAX_NETRC_BYTES = 1024 * 1024
228
+
229
+ PREFLIGHT_TIMEOUT_SECONDS = 60
230
+
231
+ # The shells a launch keeps as its ``SHELL`` (by file name): POSIX shells
232
+ # that run a command (``-c``, a script) without reading a startup file,
233
+ # given that ``BASH_ENV`` and ``ENV`` never reach them and ``ZDOTDIR`` is the
234
+ # controller's. What their login startup files do is probed instead
235
+ # (:func:`shell_startup_problems`). fish, the csh family and the rest read
236
+ # their configuration on every start, through ``HOME`` or
237
+ # ``XDG_CONFIG_HOME``, which a launch keeps.
238
+ POSIX_SHELLS = ("sh", "bash", "zsh", "dash", "ksh", "mksh")
239
+ # The shells a launch may start by name besides its ``SHELL`` (a
240
+ # repository's script or recipe, ``bash -l`` included), each probed too.
241
+ PROBED_SHELLS = ("sh", "bash", "zsh")
242
+ # Printed by the probe before what it found, so whatever a startup file
243
+ # prints itself is never read as a finding.
244
+ SHELL_PROBE_MARKER = "<<<autoforge-shell-startup>>>"
245
+ # What the probe runs once the shell's startup files have: the controller's
246
+ # own interpreter, isolated (``-I``, no site), reading the environment the
247
+ # shell exports, which is what everything it starts inherits. It prints the
248
+ # marker, then the name of each variable of ``argv`` (a count, that many
249
+ # name and value pairs, then names) that differs or is set; never a value.
250
+ _PROBE_PROGRAM = (
251
+ "import os, sys\n"
252
+ "a = sys.argv[1:]\n"
253
+ "n = int(a[0])\n"
254
+ "pairs = a[1 : 1 + 2 * n]\n"
255
+ f"print('\\n' + {SHELL_PROBE_MARKER!r})\n"
256
+ "for name, value in zip(pairs[::2], pairs[1::2]):\n"
257
+ " if os.environ.get(name) != value:\n"
258
+ " print(name)\n"
259
+ "for name in a[1 + 2 * n :]:\n"
260
+ " if name in os.environ:\n"
261
+ " print(name)\n"
262
+ )
263
+ # Withheld from every launch as defence in depth rather than because a
264
+ # route depends on them, so a startup file that sets one again is not
265
+ # refused: git offers no agent (``IdentityAgent=none`` at command scope,
266
+ # which the probe checks), gh asks no keyring while the placeholder token
267
+ # stands (checked too), and ``GH_HOST`` picks a host, not a token.
268
+ STARTUP_TOLERATED = frozenset(
269
+ {"GH_HOST", "SSH_AUTH_SOCK", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"}
270
+ )
271
+
272
+
273
+ def forbidden_env_entry(entry: str) -> tuple[str, str] | None:
274
+ """The forbidden variable allow-list ``entry`` would pass, and its rule; None if none.
275
+
276
+ An exact name is forbidden when it is in :data:`FORBIDDEN_ENV_NAMES` or
277
+ starts with a forbidden prefix; a ``*`` prefix (``GH_*``, ``GIT_*``, a
278
+ bare ``*``) when it covers a forbidden name or overlaps a forbidden
279
+ prefix.
280
+ """
281
+ if entry.endswith("*"):
282
+ prefix = entry[:-1]
283
+ for name, rule in FORBIDDEN_ENV_NAMES.items():
284
+ if name.startswith(prefix):
285
+ return name, rule
286
+ for forbidden, rule in FORBIDDEN_ENV_PREFIXES.items():
287
+ if forbidden.startswith(prefix) or prefix.startswith(forbidden):
288
+ return f"{forbidden}*", rule
289
+ return None
290
+ if entry in FORBIDDEN_ENV_NAMES:
291
+ return entry, FORBIDDEN_ENV_NAMES[entry]
292
+ for forbidden, rule in FORBIDDEN_ENV_PREFIXES.items():
293
+ if entry.startswith(forbidden):
294
+ return entry, rule
295
+ return None
296
+
297
+
298
+ def refuse_forbidden_env(entries: Iterable[str], where: str) -> None:
299
+ """Raise :class:`ConfigurationError` for the first entry that would pass a
300
+ forbidden variable. The error names the entry, the variable and the
301
+ rule; it never reads or names a value."""
302
+ for entry in entries:
303
+ hit = forbidden_env_entry(entry)
304
+ if hit is None:
305
+ continue
306
+ variable, rule = hit
307
+ what = f"is {rule}" if variable == entry else f"covers {variable}, which is {rule}"
308
+ raise ConfigurationError(
309
+ f"{where}: environment entry {entry!r} {what}. No GitHub or git credential "
310
+ "route may reach an agent or a repository-defined command, so it cannot be "
311
+ "allow-listed; name the variables you need instead (docs/configuration.md, "
312
+ "'Agent environment')"
313
+ )
314
+
315
+
316
+ def child_shell(operator: str | None = None) -> str:
317
+ """The ``SHELL`` a launch gets: ``operator`` (default: the controller's
318
+ ``SHELL``) when it is an executable named in :data:`POSIX_SHELLS` by an
319
+ absolute path, else the ``bash`` on ``PATH``, else ``/bin/sh``."""
320
+ shell = os.environ.get("SHELL", "") if operator is None else operator
321
+ if (
322
+ os.path.isabs(shell)
323
+ and os.path.basename(shell) in POSIX_SHELLS
324
+ and os.path.isfile(shell)
325
+ and os.access(shell, os.X_OK)
326
+ ):
327
+ return shell
328
+ return shutil.which("bash") or "/bin/sh"
329
+
330
+
331
+ def child_overrides(
332
+ gh_config_dir: str,
333
+ git_config_global: str,
334
+ curl_home: str,
335
+ wgetrc: str,
336
+ zdotdir: str,
337
+ shell: str,
338
+ ) -> dict[str, str]:
339
+ """The variables the controller sets for a child, over the allow-listed ones."""
340
+ env = {
341
+ "GH_CONFIG_DIR": gh_config_dir,
342
+ "GH_TOKEN": GH_PLACEHOLDER_TOKEN,
343
+ "GH_ENTERPRISE_TOKEN": GH_PLACEHOLDER_TOKEN,
344
+ "GIT_CONFIG_GLOBAL": git_config_global,
345
+ "GIT_TERMINAL_PROMPT": "0",
346
+ "GIT_CONFIG_COUNT": str(len(GIT_COMMAND_SCOPE)),
347
+ "CURL_HOME": curl_home,
348
+ "WGETRC": wgetrc,
349
+ "WGET2RC": wgetrc,
350
+ "ZDOTDIR": zdotdir,
351
+ "SHELL": shell,
352
+ }
353
+ for index, (key, value) in enumerate(GIT_COMMAND_SCOPE):
354
+ env[f"GIT_CONFIG_KEY_{index}"] = key
355
+ env[f"GIT_CONFIG_VALUE_{index}"] = value
356
+ return env
357
+
358
+
359
+ def override_names() -> tuple[str, ...]:
360
+ """The names :func:`child_overrides` sets, sorted (values vary by launch)."""
361
+ return tuple(sorted(child_overrides("", "", "", "", "", "")))
362
+
363
+
364
+ # The names of :func:`child_overrides` whose value is a path: a sandboxed
365
+ # launch keeps one only when what it names is visible inside (ADR 0005 D8.2).
366
+ OVERRIDE_PATH_NAMES = (
367
+ "GH_CONFIG_DIR",
368
+ "GIT_CONFIG_GLOBAL",
369
+ "CURL_HOME",
370
+ "WGETRC",
371
+ "WGET2RC",
372
+ "ZDOTDIR",
373
+ "SHELL",
374
+ )
375
+
376
+
377
+ def overrides_directory(overrides: Mapping[str, str]) -> str:
378
+ """The directory :func:`isolated_overrides` made for ``overrides``, "" for none.
379
+
380
+ A sandboxed launch sees it read-only (ADR 0005 D8.3), so that the
381
+ configuration it names is the one the child reads.
382
+ """
383
+ config = overrides.get("GIT_CONFIG_GLOBAL", "")
384
+ return os.path.dirname(config) if config.startswith("/") else ""
385
+
386
+
387
+ def undoes_isolation(name: str) -> bool:
388
+ """Whether variable ``name``, set over a launch's environment after the
389
+ controller's (an agent CLI applying the variables of its own
390
+ configuration does that), could hand the launch a credential: a
391
+ forbidden name, a name the controller sets (:func:`override_names`),
392
+ ``HOME`` (the ``.netrc`` and login startup files :func:`preflight`
393
+ checked are the ones there), or an exported bash function
394
+ (``BASH_FUNC_*``), which every bash the launch starts defines. Compared
395
+ without regard to case."""
396
+ upper = name.upper()
397
+ return (
398
+ upper in FORBIDDEN_ENV_NAMES
399
+ or upper.startswith(tuple(FORBIDDEN_ENV_PREFIXES))
400
+ or upper in override_names()
401
+ or upper == "HOME"
402
+ or upper.startswith("BASH_FUNC_")
403
+ )
404
+
405
+
406
+ @dataclass(frozen=True)
407
+ class LaunchIdentity:
408
+ """The identity keys copied into a child's global git configuration.
409
+
410
+ ``entries`` are ``(key, value)`` pairs of :data:`IDENTITY_KEYS`, as the
411
+ operator's global configuration last sets them; empty when it sets none
412
+ (the child's git then uses the repository's or the system's, as before).
413
+ """
414
+
415
+ entries: tuple[tuple[str, str], ...] = ()
416
+
417
+ def describe(self) -> str:
418
+ """Which keys are supplied, never their values."""
419
+ if not self.entries:
420
+ return "none in the operator's global git configuration"
421
+ return ", ".join(key for key, _ in self.entries)
422
+
423
+
424
+ def _config_value(value: str) -> str | None:
425
+ """``value`` as a quoted git configuration value; None if it cannot be one."""
426
+ if any(ord(c) < 0x20 and c not in "\t\n" or c == "\x7f" for c in value):
427
+ return None
428
+ escaped = (
429
+ value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\t", "\\t")
430
+ )
431
+ return f'"{escaped}"'
432
+
433
+
434
+ def render_git_config(identity: LaunchIdentity) -> str:
435
+ """The child's global git configuration: the identity, nothing else."""
436
+ lines = ["# Written by AutoForge for one launch (#165): the operator's identity only."]
437
+ sections: dict[str, list[str]] = {}
438
+ for key, value in identity.entries:
439
+ section, _, name = key.partition(".")
440
+ quoted = _config_value(value)
441
+ if quoted is not None:
442
+ sections.setdefault(section, []).append(f"\t{name} = {quoted}")
443
+ for section, body in sections.items():
444
+ lines.append(f"[{section}]")
445
+ lines.extend(body)
446
+ return "\n".join(lines) + "\n"
447
+
448
+
449
+ # What the child's curl and wget find as their configuration file: options
450
+ # neither, so the operator's (``~/.curlrc``, ``~/.wgetrc``, their XDG
451
+ # locations) are never read. Both take ``#`` for a comment.
452
+ HTTP_CLIENT_CONFIG = "# Written by AutoForge for one launch (#165): no default options.\n"
453
+
454
+
455
+ def _write_new(path: str, text: str) -> None:
456
+ """Create ``path`` (mode 0600, never through a link) holding ``text``."""
457
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
458
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
459
+ handle.write(text)
460
+
461
+
462
+ @contextmanager
463
+ def isolated_overrides(identity: LaunchIdentity) -> Iterator[dict[str, str]]:
464
+ """The child's overrides, backed by a directory that exists for the launch only.
465
+
466
+ The directory (``mkdtemp``, mode 0700, outside every repository) holds
467
+ the empty gh configuration directory, the global git configuration,
468
+ curl's and wget's configuration files and zsh's empty startup
469
+ directory; it is removed when the ``with`` block ends, whatever the
470
+ child wrote into it, so nothing one launch plants reaches the next. A
471
+ directory that cannot be prepared is an :class:`ExecutionError`, before
472
+ any launch.
473
+ """
474
+ try:
475
+ root = tempfile.mkdtemp(prefix="autoforge-child-")
476
+ except OSError as exc:
477
+ raise ExecutionError(f"cannot prepare a launch's environment: {exc}") from exc
478
+ try:
479
+ try:
480
+ gh_dir = os.path.join(root, "gh")
481
+ os.mkdir(gh_dir, 0o700)
482
+ git_config = os.path.join(root, "gitconfig")
483
+ _write_new(git_config, render_git_config(identity))
484
+ curl_home = os.path.join(root, "curl")
485
+ os.mkdir(curl_home, 0o700)
486
+ _write_new(os.path.join(curl_home, ".curlrc"), HTTP_CLIENT_CONFIG)
487
+ wgetrc = os.path.join(root, "wgetrc")
488
+ _write_new(wgetrc, HTTP_CLIENT_CONFIG)
489
+ zdotdir = os.path.join(root, "zsh")
490
+ os.mkdir(zdotdir, 0o700)
491
+ except OSError as exc:
492
+ raise ExecutionError(f"cannot prepare a launch's environment: {exc}") from exc
493
+ yield child_overrides(gh_dir, git_config, curl_home, wgetrc, zdotdir, child_shell())
494
+ finally:
495
+ shutil.rmtree(root, ignore_errors=True)
496
+
497
+
498
+ # -- the preflight ------------------------------------------------------------
499
+
500
+
501
+ @dataclass(frozen=True)
502
+ class GitConfigEntry:
503
+ """One entry of ``git config --list --show-scope``; ``value`` None for a bare key."""
504
+
505
+ scope: str
506
+ key: str
507
+ value: str | None
508
+
509
+
510
+ def parse_git_config_listing(output: str) -> list[GitConfigEntry]:
511
+ """Parse ``git config --list --show-scope -z``: ``scope NUL key LF value NUL``.
512
+
513
+ Raises :class:`ExecutionError` on anything else, so an output the parser
514
+ does not understand is never read as "no entries".
515
+ """
516
+ if not output:
517
+ return []
518
+ if not output.endswith("\0"):
519
+ raise ExecutionError("git config --list printed an unterminated entry")
520
+ fields = output[:-1].split("\0")
521
+ if len(fields) % 2:
522
+ raise ExecutionError("git config --list printed a scope without an entry")
523
+ entries = []
524
+ for scope, item in zip(fields[0::2], fields[1::2], strict=True):
525
+ if scope not in ("system", "global", "local", "worktree", "command"):
526
+ raise ExecutionError(f"git config --list printed an unknown scope {scope[:40]!r}")
527
+ key, newline, value = item.partition("\n")
528
+ if not key:
529
+ raise ExecutionError("git config --list printed an entry without a key")
530
+ entries.append(GitConfigEntry(scope, key, value if newline else None))
531
+ return entries
532
+
533
+
534
+ def read_git_config(cwd: str, runner: Runner | None = None) -> list[GitConfigEntry]:
535
+ """Every configuration entry git reads in ``cwd``, includes followed.
536
+
537
+ Runs the hardened local request (no hooks, no ``GIT_*`` of the
538
+ operator's shell) with the operator's own ``GIT_CONFIG_GLOBAL`` when it
539
+ sets one, so the global scope is the one the operator's git reads, the
540
+ scope the child's identity is copied from. Raises
541
+ :class:`ConfigurationError` when the configuration cannot be read: a
542
+ child would meet the same configuration.
543
+ """
544
+ env = {}
545
+ operator_global = os.environ.get("GIT_CONFIG_GLOBAL")
546
+ if operator_global:
547
+ env["GIT_CONFIG_GLOBAL"] = operator_global
548
+ request = local_git_request(
549
+ ["config", "--list", "--show-scope", "--includes", "-z"],
550
+ cwd=cwd,
551
+ env=env,
552
+ timeout_seconds=PREFLIGHT_TIMEOUT_SECONDS,
553
+ )
554
+ try:
555
+ result = (runner or execute)(request)
556
+ if result.timed_out or result.exit_code != 0 or result.stdout_truncated:
557
+ detail = redact(result.stderr.strip())[:300] or f"exit {result.exit_code}"
558
+ raise ExecutionError(f"git config --list failed: {detail}")
559
+ return parse_git_config_listing(result.stdout)
560
+ except ExecutionError as exc:
561
+ raise ConfigurationError(
562
+ f"cannot read the git configuration a launch in {cwd} would use: {exc}"
563
+ ) from exc
564
+
565
+
566
+ def _split_key(key: str) -> tuple[str, str, str]:
567
+ """``section.subsection.name`` as its three parts (subsection may be empty)."""
568
+ section, _, rest = key.partition(".")
569
+ subsection, _, name = rest.rpartition(".")
570
+ return section.lower(), subsection, name.lower()
571
+
572
+
573
+ def url_has_credential(url: str) -> bool:
574
+ """Whether ``url`` embeds a credential: a password in its userinfo, or an
575
+ HTTP(S) username shaped like a token. ``git@host:path`` and plain paths
576
+ carry none; an unparseable URL with a userinfo is counted as carrying one."""
577
+ if "://" not in url:
578
+ return False
579
+ try:
580
+ parts = urlsplit(url)
581
+ netloc = parts.netloc
582
+ except ValueError:
583
+ return "@" in url.partition("://")[2].split("/", 1)[0]
584
+ if "@" not in netloc:
585
+ return False
586
+ user, _, password = netloc.rpartition("@")[0].partition(":")
587
+ if password:
588
+ return True
589
+ return parts.scheme.lower() in ("http", "https") and bool(credential_classes(unquote(user)))
590
+
591
+
592
+ def git_config_problems(entries: Iterable[GitConfigEntry]) -> list[str]:
593
+ """What in the configuration a child reads would hand it a credential.
594
+
595
+ Each problem names the scope and the key's shape, never a value and
596
+ never a URL (a URL in a key may be the credential).
597
+ """
598
+ problems: list[str] = []
599
+ for entry in entries:
600
+ if entry.scope not in CHILD_SCOPES:
601
+ continue
602
+ section, subsection, name = _split_key(entry.key)
603
+ where = f"{entry.scope}-scope git configuration"
604
+ value = entry.value or ""
605
+ if section == "credential" and name == "helper" and value:
606
+ if entry.scope in REPOSITORY_SCOPES:
607
+ shape = "credential.<url>.helper" if subsection else "credential.helper"
608
+ problems.append(f"a credential helper in {where} ({shape})")
609
+ elif section == "http" and name == "extraheader" and value:
610
+ shape = "http.<url>.extraHeader" if subsection else "http.extraHeader"
611
+ problems.append(f"an HTTP header for git in {where} ({shape})")
612
+ elif section == "remote" and name in ("url", "pushurl") and url_has_credential(value):
613
+ problems.append(
614
+ f"a remote URL with an embedded credential in {where} "
615
+ f"(remote.{redact(subsection)}.{name})"
616
+ )
617
+ elif (
618
+ section == "url"
619
+ and name in ("insteadof", "pushinsteadof")
620
+ and url_has_credential(subsection)
621
+ ):
622
+ problems.append(
623
+ f"a URL rewrite to a URL with an embedded credential in {where} (url.<url>.{name})"
624
+ )
625
+ return problems
626
+
627
+
628
+ def operator_identity(entries: Iterable[GitConfigEntry]) -> LaunchIdentity:
629
+ """The identity keys the operator's global configuration last sets."""
630
+ found: dict[str, str] = {}
631
+ for entry in entries:
632
+ key = entry.key.lower()
633
+ if entry.scope == "global" and key in IDENTITY_KEYS and entry.value:
634
+ found[key] = entry.value
635
+ return LaunchIdentity(tuple((key, found[key]) for key in IDENTITY_KEYS if key in found))
636
+
637
+
638
+ def operator_home() -> str | None:
639
+ """The ``HOME`` a child inherits (the controller's own)."""
640
+ return os.environ.get("HOME") or None
641
+
642
+
643
+ def netrc_problems(home: str | None) -> list[str]:
644
+ """A ``.netrc`` in ``home`` with an entry for GitHub, or one that cannot be read.
645
+
646
+ git's HTTPS transport (libcurl), ``curl -n`` and Python's ``requests``
647
+ read ``$HOME/.netrc``, and ``HOME`` reaches every child, so the file
648
+ cannot be hidden from it. Only words are compared
649
+ (:func:`_netrc_names_github`), and only the file name is reported.
650
+ """
651
+ if not home:
652
+ return []
653
+ problems = []
654
+ for name in NETRC_FILES:
655
+ path = Path(home) / name
656
+ try:
657
+ with path.open("rb") as handle:
658
+ data = handle.read(MAX_NETRC_BYTES + 1)
659
+ except FileNotFoundError:
660
+ continue
661
+ except OSError as exc:
662
+ problems.append(f"~/{name} could not be checked ({type(exc).__name__})")
663
+ continue
664
+ if len(data) > MAX_NETRC_BYTES:
665
+ problems.append(f"~/{name} is larger than {MAX_NETRC_BYTES} bytes and was not checked")
666
+ elif _netrc_names_github(data.decode("utf-8", "replace")):
667
+ problems.append(f"an entry for GitHub (or a default entry) in ~/{name}")
668
+ return problems
669
+
670
+
671
+ def _netrc_names_github(text: str) -> bool:
672
+ """Whether any word of a netrc text may be read as a GitHub machine or ``default``.
673
+
674
+ The readers disagree on the syntax. curl (7.84 and later) and wget take
675
+ a ``"``-quoted word with ``\\`` escapes; Python's ``netrc`` module, which
676
+ ``requests`` reads, drops a ``\\`` before any character, quoted or not;
677
+ curl matches ``machine`` and ``default`` in any case; and where a
678
+ ``macdef`` body or a ``#`` comment ends, and whether a keyword counts in
679
+ a value's position, differs between them (a ``macdef`` given as a login
680
+ starts a macro for curl and none for Python). So no position, quoting,
681
+ comment or macro is trusted: every word, split at whitespace and at
682
+ ``"``, with each ``\\`` dropped, is compared without regard to case, and
683
+ one that names a GitHub host or is ``default`` refuses the file, though
684
+ it stands in a password, a comment or a macro body.
685
+ """
686
+ for word in _NETRC_WORD_BREAK.split(text):
687
+ word = word.replace("\\", "").lower()
688
+ if word == "default" or _GITHUB_HOST_RE.match(word):
689
+ return True
690
+ return False
691
+
692
+
693
+ def shell_probe_script(overrides: Mapping[str, str]) -> str:
694
+ """A POSIX command that prints :data:`SHELL_PROBE_MARKER`, then the name
695
+ of every variable of a launch's isolation the shell running it no longer
696
+ exports as the controller left it.
697
+
698
+ Run after the shell's startup files, it names each variable of
699
+ ``overrides`` that is unset, holds another value or is no longer
700
+ exported, and each forbidden name the controller withholds (but
701
+ :data:`STARTUP_TOLERATED`) that is now exported. Values are compared in
702
+ a process the shell starts and never printed.
703
+ """
704
+ pairs = sorted(overrides.items())
705
+ withheld = sorted(set(FORBIDDEN_ENV_NAMES) - set(overrides) - STARTUP_TOLERATED)
706
+ argv = [
707
+ sys.executable or "python3",
708
+ "-I",
709
+ "-S",
710
+ "-c",
711
+ _PROBE_PROGRAM,
712
+ str(len(pairs)),
713
+ *(part for pair in pairs for part in pair),
714
+ *withheld,
715
+ ]
716
+ return "exec " + shlex.join(argv) + "\n"
717
+
718
+
719
+ def probe_findings(stdout: str, names: Iterable[str]) -> list[str] | None:
720
+ """The variables a probe's output names after its last marker, sorted;
721
+ None when the marker is missing. Only ``names`` are read."""
722
+ _, marker, findings = stdout.rpartition(f"\n{SHELL_PROBE_MARKER}\n")
723
+ if not marker:
724
+ return None
725
+ known = set(names)
726
+ return sorted({line for line in findings.splitlines() if line in known})
727
+
728
+
729
+ def launch_shells(shell: str) -> list[str]:
730
+ """``shell`` and each of :data:`PROBED_SHELLS` on ``PATH``, once per name
731
+ and file (``sh`` is probed apart from the ``bash`` it may be: bash run as
732
+ ``sh`` reads ``~/.profile``, not ``~/.bash_profile``)."""
733
+ found: list[str] = []
734
+ seen: set[tuple[str, str]] = set()
735
+ for path in [shell, *filter(None, (shutil.which(name) for name in PROBED_SHELLS))]:
736
+ key = (os.path.basename(path), os.path.realpath(path))
737
+ if key not in seen:
738
+ seen.add(key)
739
+ found.append(path)
740
+ return found
741
+
742
+
743
+ def shell_startup_problems(
744
+ cwd: str,
745
+ env_allowlist: Iterable[str],
746
+ runner: Runner | None = None,
747
+ *,
748
+ home: str | None = None,
749
+ ) -> list[str]:
750
+ """What the startup files of a launch's shells would change of its isolation.
751
+
752
+ Each shell of :func:`launch_shells` runs :func:`shell_probe_script` once
753
+ as a login shell (``-l -c``, which reads what ``-c`` reads and the login
754
+ files besides), in ``cwd``, in the environment a launch with
755
+ ``env_allowlist`` gets (``HOME`` is ``home``, default the operator's)
756
+ and with its processes contained. zsh reads nothing of the operator's
757
+ there (``ZDOTDIR``), so what it can find are the system-wide files;
758
+ ``bash`` and ``sh`` read ``/etc/profile`` and ``~/.bash_profile``,
759
+ ``~/.bash_login`` or ``~/.profile``. Each problem names the shell and
760
+ the variables, never a value; a shell whose probe fails, times out or
761
+ prints no marker could not be checked, which is a problem too.
762
+ """
763
+ problems: list[str] = []
764
+ home = home if home is not None else operator_home()
765
+ with isolated_overrides(LaunchIdentity()) as overrides:
766
+ env = dict(overrides)
767
+ if home:
768
+ env["HOME"] = home
769
+ script = shell_probe_script(overrides)
770
+ names = set(overrides) | set(FORBIDDEN_ENV_NAMES)
771
+ for shell in launch_shells(overrides["SHELL"]):
772
+ where = f"the startup files of a login {os.path.basename(shell)} ({shell})"
773
+ request = ExecutionRequest(
774
+ command=[shell, "-l", "-c", script],
775
+ cwd=cwd,
776
+ timeout_seconds=PREFLIGHT_TIMEOUT_SECONDS,
777
+ env_allowlist=tuple(env_allowlist),
778
+ env=env,
779
+ contain_orphans=True,
780
+ )
781
+ try:
782
+ result = (runner or execute)(request)
783
+ except (ExecutionError, OSError) as exc:
784
+ problems.append(f"{where}, which could not be checked ({type(exc).__name__})")
785
+ continue
786
+ findings = None
787
+ if not (result.timed_out or result.exit_code or result.stdout_truncated):
788
+ findings = probe_findings(result.stdout, names)
789
+ if findings is None:
790
+ reason = (
791
+ f"timed out after {PREFLIGHT_TIMEOUT_SECONDS}s"
792
+ if result.timed_out
793
+ else f"exit {result.exit_code}"
794
+ if result.exit_code
795
+ else "no result"
796
+ )
797
+ problems.append(f"{where}, which could not be checked ({reason})")
798
+ elif findings:
799
+ problems.append(f"{where}, which change {', '.join(findings)}")
800
+ return problems
801
+
802
+
803
+ def preflight(
804
+ cwd: str,
805
+ runner: Runner | None = None,
806
+ *,
807
+ home: str | None = None,
808
+ env_allowlist: Iterable[str] | None = None,
809
+ ) -> LaunchIdentity:
810
+ """Refuse a launch in ``cwd`` that would hand the child a credential; else
811
+ the identity to copy into its global git configuration.
812
+
813
+ Run before anything is spawned: one ``git config``, one file read and,
814
+ with the launch's ``env_allowlist`` (the engine and ``doctor`` always
815
+ pass it), one login shell per shell a launch may start
816
+ (:func:`shell_startup_problems`). A refusal is a
817
+ :class:`ConfigurationError`: the operator fixes the configuration, and
818
+ nothing needs recovering.
819
+ """
820
+ entries = read_git_config(cwd, runner)
821
+ problems = git_config_problems(entries)
822
+ home = home if home is not None else operator_home()
823
+ problems += netrc_problems(home)
824
+ if env_allowlist is not None:
825
+ problems += shell_startup_problems(cwd, env_allowlist, runner, home=home)
826
+ if problems:
827
+ raise ConfigurationError(
828
+ "refusing to launch: a process AutoForge launches would be handed a GitHub or "
829
+ f"git credential through {'; '.join(problems)}. Remove it (doctor names it too; "
830
+ "docs/configuration.md, 'Agent environment'), then run again"
831
+ )
832
+ return operator_identity(entries)
833
+
834
+
835
+ def describe_overrides() -> str:
836
+ """The controller-set names, for ``doctor`` and the dry-run plan."""
837
+ return ", ".join(override_names())
838
+
839
+
840
+ __all__ = [
841
+ "FORBIDDEN_ENV_NAMES",
842
+ "FORBIDDEN_ENV_PREFIXES",
843
+ "GH_PLACEHOLDER_TOKEN",
844
+ "GIT_COMMAND_SCOPE",
845
+ "HTTP_CLIENT_CONFIG",
846
+ "POSIX_SHELLS",
847
+ "PROBED_SHELLS",
848
+ "SHELL_PROBE_MARKER",
849
+ "SSH_COMMAND",
850
+ "STARTUP_TOLERATED",
851
+ "GitConfigEntry",
852
+ "LaunchIdentity",
853
+ "child_overrides",
854
+ "child_shell",
855
+ "forbidden_env_entry",
856
+ "git_config_problems",
857
+ "isolated_overrides",
858
+ "launch_shells",
859
+ "netrc_problems",
860
+ "operator_identity",
861
+ "override_names",
862
+ "parse_git_config_listing",
863
+ "preflight",
864
+ "probe_findings",
865
+ "read_git_config",
866
+ "refuse_forbidden_env",
867
+ "shell_probe_script",
868
+ "shell_startup_problems",
869
+ "undoes_isolation",
870
+ "url_has_credential",
871
+ ]