3"""Process choke point, token redaction, and the git adapter behind ``work``.
5Every subprocess this tool starts goes through :func:`run_process`, and every
6byte handed back from one has already passed :func:`redact`. That is
7deliberate. A GitHub token reaches a repository tool through a remote URL, an
8askpass helper, or a ``gh`` diagnostic far more often than through anything the
9tool itself holds, and a single choke point is the only arrangement in which
10"is this output redacted" has one answer rather than one answer per call site.
12The git surface is split in two on purpose. :func:`run_git_readonly` refuses
13any argv whose subcommand is outside :data:`READ_ONLY_SUBCOMMANDS`, so a path
14documented as read-only -- ``work landed`` is the main reason the guard exists
15-- cannot quietly acquire a ``push`` or a ``branch -D`` in a later edit.
16The unguarded process adapter is private to that wrapper; workspace creation
17belongs exclusively to ``scripts/dev/agent_workspace.sh``.
19Nothing in this module fetches, pushes, or otherwise reaches the network, and
20nothing here deletes a file, a branch, or a worktree.
22Failure is signalled by raising :class:`WorkError` or one of its subclasses;
23the exit-code mapping belongs to ``work.py``.
26from __future__
import annotations
31from collections.abc
import Mapping, Sequence
32from dataclasses
import dataclass
33from pathlib
import Path
35sys.path.insert(0, str(Path(__file__).resolve().parents[4] /
"scripts/dev"))
37from git_environment
import (
39 reject_untrusted_executable_attributes,
40 sanitized_git_environment,
41 trusted_git_executable,
48REDACTED =
"[REDACTED-TOKEN]"
58 re.compile(
r"gh[pousr]_[A-Za-z0-9]{16,}"),
59 re.compile(
r"github_pat_[A-Za-z0-9_]{20,}"),
68READ_ONLY_OPTIONS: dict[str, frozenset[str]] = {
69 "rev-parse": frozenset(
70 {
"--show-toplevel",
"--git-common-dir",
"--git-dir",
"--verify",
"--quiet"}
72 "status": frozenset({
"--porcelain=v1",
"--untracked-files=all",
"--ignore-submodules=none"}),
73 "diff": frozenset({
"--stat",
"--no-ext-diff",
"--no-textconv"}),
75 "show-ref": frozenset({
"--verify",
"--quiet"}),
76 "worktree": frozenset({
"--porcelain"}),
77 "branch": frozenset({
"--list"}),
82READ_ONLY_SUBCOMMANDS = frozenset(READ_ONLY_OPTIONS)
87READ_ONLY_MODES: dict[str, str] = {
"worktree":
"list"}
90REQUIRED_OPTIONS: dict[str, str] = {
"branch":
"--list"}
99 "--no-optional-locks",
101 "core.fsmonitor=false",
103 "core.untrackedCache=false",
107 "pager.status=false",
115class WorkError(Exception):
116 """Any condition that stops ``work`` from producing a trustworthy answer."""
119class ToolMissingError(WorkError):
120 """A required external executable is absent from ``PATH``."""
123class GitCommandError(WorkError):
124 """A git command ran and reported a non-zero status."""
127class GitWriteAttemptError(WorkError):
128 """An argv that could change repository state reached a read-only runner."""
131def redact(text: str) -> str:
132 """Replace every token-shaped substring in ``text`` with a fixed placeholder.
135 text: Arbitrary captured output or exception text.
138 The same text with each GitHub-token-shaped run replaced by
142 for pattern
in TOKEN_PATTERNS:
143 out = pattern.sub(REDACTED, out)
147def printable(text: str) -> str:
148 """Reduce ``text`` to printable ASCII, so nothing it contains can rewrite a report.
150 A manifest is a file on disk that anyone able to write the state directory
151 can author, and several of its fields are pure display data that no
152 validation constrains -- ``base_ref`` and ``creator`` most obviously. An
153 escape sequence in one of those repositions the cursor and repaints the
154 line, so a planted record could make ``work landed`` report a base, a
155 branch or an author that is not what it actually found.
157 Report callers pass one logical line at a time. Newlines and every other
158 character outside space..tilde therefore become :data:`UNPRINTABLE`; a
159 planted metadata field cannot inject a second, trusted-looking row.
162 text: Any string on its way to stdout or stderr.
165 The same text with every non-printable character replaced.
168 char
if PRINTABLE_LOW <= ord(char) <= PRINTABLE_HIGH
else UNPRINTABLE
for char
in text
172@dataclass(frozen=True)
174 """One captured subprocess result, already redacted."""
176 argv: tuple[str, ...]
182 def ok(self) -> bool:
183 """Whether the process exited zero."""
184 return self.returncode == 0
190 cwd: Path |
None =
None,
191 timeout: int = DEFAULT_TIMEOUT_S,
192 env: Mapping[str, str] |
None =
None,
194 """Run ``argv`` with output captured, and return the redacted result.
197 argv: Full argument vector, executable first. Never a shell string.
198 cwd: Directory to run from, or None to inherit the caller's.
199 timeout: Seconds before the child is killed and the call fails.
200 env: Explicit child environment, or None to inherit the caller's.
203 A :class:`Completed` whose streams have passed :func:`redact`.
206 ToolMissingError: The executable named by ``argv[0]`` does not exist.
207 WorkError: The command exceeded ``timeout``.
211 proc = subprocess.run(
213 cwd=
None if cwd
is None else str(cwd),
217 env=
None if env
is None else dict(env),
220 except FileNotFoundError
as exc:
221 msg = f
"executable not found: {redact(str(exc))}"
222 raise ToolMissingError(msg)
from exc
223 except subprocess.TimeoutExpired
as exc:
224 msg = f
"command timed out after {timeout}s: {redact(' '.join(listed))}"
225 raise WorkError(msg)
from exc
228 returncode=proc.returncode,
229 stdout=redact(proc.stdout
or ""),
230 stderr=redact(proc.stderr
or ""),
234def git_executable() -> str:
235 """Return the repository's one absolute control-plane Git authority."""
237 return trusted_git_executable()
238 except GitEnvironmentError
as exc:
239 raise ToolMissingError(str(exc))
from exc
242def git_child_environment() -> dict[str, str]:
243 """Return the shared hardened environment used by every child Git.
246 The environment produced by the repository-wide nested-Git authority.
248 return sanitized_git_environment()
251def _subcommand_index(argv: Sequence[str]) -> int:
252 """Return the index of the git subcommand in ``argv``, or -1 if there is none.
254 The subcommand must be argv[0]. No global option, including ``-C``, is
255 skipped: a leading option remains the token :func:`assert_read_only` judges
256 and is refused rather than silently reinterpreted.
259 argv: A git argument vector with the executable already removed.
262 Index of the subcommand token, or -1 when the vector has none.
264 return 0
if argv
else -1
267def git_subcommand(argv: Sequence[str]) -> str |
None:
268 """Return argv[0] as the Git subcommand; never skip global options.
271 argv: A git argument vector with the executable already removed.
274 The subcommand token, or None when the vector names none.
276 index = _subcommand_index(argv)
277 return None if index < 0
else argv[index]
280def _assert_subcommand(argv: Sequence[str]) -> int:
281 """Check everything before the subcommand, and return where it starts.
284 argv: A git argument vector with the executable already removed.
287 The index of the subcommand token.
290 GitWriteAttemptError: The vector carries a global option other than the
291 ``-C <path>`` pair, names no subcommand, or names one outside
292 :data:`READ_ONLY_SUBCOMMANDS`.
294 index = _subcommand_index(argv)
296 msg =
"read-only git runner received an argv with no subcommand"
297 raise GitWriteAttemptError(msg)
299 if sub.startswith(
"-"):
300 msg = f
"read-only git runner refused the global option: {sub}"
301 raise GitWriteAttemptError(msg)
302 if sub
not in READ_ONLY_SUBCOMMANDS:
303 msg = f
"read-only git runner refused subcommand: {sub}"
304 raise GitWriteAttemptError(msg)
308def _assert_tail(sub: str, tail: Sequence[str]) ->
None:
309 """Check every argument after the subcommand against that subcommand's allowlist.
312 sub: The subcommand, already known to be permitted.
313 tail: Everything after it.
316 GitWriteAttemptError: An option is not allowlisted for ``sub``, a
317 required option is absent, or a mode-selecting positional is either
320 allowed = READ_ONLY_OPTIONS[sub]
322 if token.startswith(
"-")
and token
not in allowed:
323 msg = f
"read-only git runner refused option {token!r} for subcommand {sub!r}"
324 raise GitWriteAttemptError(msg)
325 required = REQUIRED_OPTIONS.get(sub)
326 if required
is not None and required
not in tail:
327 msg = f
"read-only git runner requires {required} for subcommand {sub!r}"
328 raise GitWriteAttemptError(msg)
329 mode = READ_ONLY_MODES.get(sub)
332 positionals = [token
for token
in tail
if not token.startswith(
"-")]
333 if positionals != [mode]:
334 msg = f
"read-only git runner allows only: git {sub} {mode}"
335 raise GitWriteAttemptError(msg)
338def assert_read_only(argv: Sequence[str]) ->
None:
339 """Raise unless ``argv`` is a git invocation that cannot change any state.
341 The check is a whitelist at three levels -- the global prefix, the
342 subcommand, and every single option after it -- because none of the three
343 is safe on its own. ``git diff --output=FILE`` truncates a file while being
344 a "read-only subcommand", and ``git worktree list add -b x /tmp/z HEAD``
345 is a write wearing a listing subcommand as a hat.
348 argv: A git argument vector with the executable already removed.
351 GitWriteAttemptError: The vector is not one of the exact forms this
354 index = _assert_subcommand(argv)
355 _assert_tail(argv[index], list(argv[index + 1 :]))
358def _run_git(argv: Sequence[str], *, cwd: Path, timeout: int = DEFAULT_TIMEOUT_S) -> Completed:
359 """Run git with ``argv`` from ``cwd`` with no read-only guard applied.
362 argv: Git arguments with the executable omitted.
363 cwd: Directory to run from.
364 timeout: Seconds before the child is killed.
367 The captured, redacted result.
370 [git_executable(), *GIT_READ_PREFIX, *argv],
373 env=git_child_environment(),
378 argv: Sequence[str], *, cwd: Path, timeout: int = DEFAULT_TIMEOUT_S
380 """Run a git command that has been proved incapable of changing state.
383 argv: Git arguments with the executable omitted.
384 cwd: Directory to run from.
385 timeout: Seconds before the child is killed.
388 The captured, redacted result.
391 GitWriteAttemptError: ``argv`` did not pass :func:`assert_read_only`.
393 assert_read_only(argv)
394 return _run_git(argv, cwd=cwd, timeout=timeout)
397def git_text(argv: Sequence[str], *, cwd: Path) -> str:
398 """Run a read-only git command and return its stdout, failing loudly.
401 argv: Git arguments with the executable omitted.
402 cwd: Directory to run from.
408 GitCommandError: The command exited non-zero.
410 done = run_git_readonly(argv, cwd=cwd)
412 joined =
" ".join(argv)
413 msg = f
"git {joined} failed (exit {done.returncode}): {done.stderr.strip()}"
414 raise GitCommandError(msg)
418@dataclass(frozen=True)
420 """Where the repository under the caller's feet actually lives."""
427 def is_linked_worktree(self) -> bool:
428 """Whether this checkout is a linked worktree rather than the main one."""
429 return self.git_dir != self.common_dir
432def discover_repo(cwd: Path) -> RepoPaths:
433 """Locate the repository containing ``cwd``.
435 ``--git-common-dir`` is asked for rather than assuming ``.git`` is a
436 directory: in a linked worktree ``.git`` is a file, and the shared state
437 this tool writes must land beside the main repository rather than once per
441 cwd: Any directory inside the repository.
444 The resolved toplevel, common git directory, and per-worktree git
448 GitCommandError: ``cwd`` is not inside a git repository.
450 toplevel = Path(git_text([
"rev-parse",
"--show-toplevel"], cwd=cwd).strip()).resolve()
451 common = _resolve_git_path(git_text([
"rev-parse",
"--git-common-dir"], cwd=cwd).strip(), cwd)
452 git_dir = _resolve_git_path(git_text([
"rev-parse",
"--git-dir"], cwd=cwd).strip(), cwd)
453 return RepoPaths(toplevel=toplevel, common_dir=common, git_dir=git_dir)
456def reject_executable_attributes(cwd: Path, commit: str |
None =
None) ->
None:
457 """Apply the repository-wide trusted attribute policy as a workflow error."""
459 reject_untrusted_executable_attributes(cwd, commit)
460 except GitEnvironmentError
as exc:
461 raise WorkError(str(exc))
from exc
464def _resolve_git_path(raw: str, cwd: Path) -> Path:
465 """Turn a possibly relative ``git rev-parse`` path answer into an absolute one.
468 raw: The path git printed.
469 cwd: The directory the command ran from, which relative answers are
473 An absolute, symlink-resolved path.
476 if not path.is_absolute():
478 return path.resolve()
481def worktree_paths(cwd: Path) -> list[Path]:
482 """Return the resolved path of every worktree registered in this repository.
485 cwd: Any directory inside the repository.
488 Resolved worktree paths, in the order git reported them.
490 out = git_text([
"worktree",
"list",
"--porcelain"], cwd=cwd)
493 Path(line[len(marker) :]).resolve()
for line
in out.splitlines()
if line.startswith(marker)
497def branch_exists(name: str, *, cwd: Path) -> bool:
498 """Whether a local branch of exactly ``name`` exists.
501 name: Branch name without the ``refs/heads/`` prefix.
502 cwd: Any directory inside the repository.
505 True when the ref resolves.
507 done = run_git_readonly([
"show-ref",
"--verify",
"--quiet", f
"refs/heads/{name}"], cwd=cwd)
511def resolve_commit(ref: str, *, cwd: Path) -> str |
None:
512 """Resolve ``ref`` to a commit id without touching the network.
515 ref: Any revision expression.
516 cwd: Any directory inside the repository.
519 The full commit id, or None when the reference does not resolve.
521 done = run_git_readonly([
"rev-parse",
"--verify",
"--quiet", f
"{ref}^{{commit}}"], cwd=cwd)
522 text = done.stdout.strip()
523 return text
if done.ok
and text
else None
526def resolve_tree(ref: str, *, cwd: Path) -> str |
None:
527 """Resolve the tree object belonging to ``ref`` without touching the network.
530 ref: Commit-ish whose content tree is required.
531 cwd: Any directory inside the repository.
534 The full tree object id, or None when the reference does not resolve.
536 done = run_git_readonly([
"rev-parse",
"--verify",
"--quiet", f
"{ref}^{{tree}}"], cwd=cwd)
537 text = done.stdout.strip()
538 return text
if done.ok
and text
else None
541def porcelain_status(cwd: Path) -> list[str]:
542 """Return the ``git status --porcelain`` lines for the tree at ``cwd``.
548 One entry per reported path, untracked files included.
550 reject_executable_attributes(cwd)
552 [
"status",
"--porcelain=v1",
"--untracked-files=all",
"--ignore-submodules=none"],
555 return [line
for line
in out.splitlines()
if line.strip()]
558def diff_stat(cwd: Path, base: str) -> str:
559 """Return ``git diff --stat <base>...HEAD`` for the tree at ``cwd``.
563 base: The base revision to compare against.
566 The diffstat text, or an explanatory line when the base does not
567 resolve in that tree.
569 reject_executable_attributes(cwd)
570 done = run_git_readonly(
571 [
"diff",
"--no-ext-diff",
"--no-textconv",
"--stat", f
"{base}...HEAD"],
575 return f
"(no diffstat: base {base} did not resolve here)"
576 return done.stdout.rstrip()