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
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
|
+
]
|