6685c8e9a4
Two consecutive critique rounds (architect / principal dev / test
engineer) of the Phase 0 commit and its first follow-on. End-state
fixes ride together because intermediate Phase 0.1 staging was never
committed.
Architecture
- Generalise the four shared env knobs to canonical DISPATCHER_*
names with one-shot deprecation warnings on REVIEW_DISPATCHER_*
fallbacks (back-compat preserved).
- Add IMPLEMENTER_DISPATCHER_PRECLONE Phase-3 feature flag with
explicit kill-switch precedence.
- Consolidate _kind_cfg lookups in prepare_pr_worktree end-to-end:
every consumer (gate predicates + _worktree_base) takes a
pre-resolved cfg via *_with_cfg twins, so a typo'd kind logs the
fall-through error exactly once per call. The local kind is also
normalised to "review" so path filenames and WorktreeHandle.kind
reflect the effective fall-back (no partial internal state).
- Raise _kind_cfg fall-through log from WARNING to ERROR.
- Rename _review_clone_creds.py to _pr_clone_creds.py.
- Type _KIND_CONFIG as TypedDict so typo'd keys are caught
statically.
Code hygiene
- Wire WorktreeHandle.kind into cleanup logging.
- Per-error-path warnings in commit_from_worktree (timeout / OSError
/ non-zero exit / empty stdout / sentinel parse failure).
- Switch emit_error.stream from Any to IO[str] | None.
- wrap_untrusted_section now filters None values from attrs.
- Worktree paths grow a kind segment: pr-{n}-{kind}-{tag}.
Tests
- 40 new tests in test_shared_substrate.py (54 total, up from 14).
- Parametrised env precedence + one-shot deprecation over all four
shared knobs.
- Direct unit test for _worktree_base_with_cfg with a hand-built
_KindConfig literal so future field additions fail loudly.
- Surface check covers Phase 0.1/0.2 callable additions; module-
private data structures intentionally excluded.
- Restructured the unknown-kind test to take the full success path
with explicit assertions on handle.kind, handle.path, and the
exactly-once ERROR fall-through, defending the consolidation
invariant + the full-fallback contract.
- Autouse fixture isolates _LEGACY_DEPRECATION_LOGGED per-test.
- Documented the substrate load-order trick in conftest.
No reviewer behaviour changes. IMPLEMENTER_DISPATCHER_PRECLONE and
kind="implementer" are forward-looking scaffolding only --
dispatch_implementer.py does not call prepare_pr_worktree yet
(Phase 3 wiring lands separately).
All 677 auto-agents tests pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
193 lines
7.1 KiB
Python
193 lines
7.1 KiB
Python
"""Credential isolation for the auto-agents dispatchers' git subprocesses.
|
|
|
|
Extracted from the pre-clone module (:mod:`_pr_clone`) so the clone
|
|
module can stay under the project's 500-line per-file budget. The
|
|
helpers are kind-agnostic — both the reviewer and implementer
|
|
dispatchers use the same askpass shim and env-builder. This file
|
|
owns the two primitives that decide what makes it onto a ``git``
|
|
subprocess:
|
|
|
|
- :func:`_ensure_askpass_script` -- creates (once per process) a
|
|
0600 ``GIT_ASKPASS`` shim that reads the PAT from the live env at
|
|
call time. The script itself stores no secret, and is removed via
|
|
``atexit`` so a crashed dispatcher does not leave stale askpass
|
|
scripts in ``/tmp``.
|
|
- :func:`_git_env` -- builds the explicit env dict every git
|
|
invocation runs under. The dispatcher deliberately *constructs* a
|
|
fresh env rather than copying ``os.environ`` so an unrelated
|
|
secret in some other env var (a CI pipeline's
|
|
``DATABASE_PASSWORD`` etc.) cannot leak into hook scripts spawned
|
|
by git. Only the variables in :data:`_GIT_ENV_PASSTHROUGH` are
|
|
carried through, in addition to the always-required PATH/HOME +
|
|
GIT_ASKPASS handshake.
|
|
|
|
Why these are isolated together: the askpass script and the env
|
|
builder are tightly coupled (the script reads env vars the env
|
|
builder is responsible for setting), and they are the only entry
|
|
points in :mod:`_pr_clone` that touch credentials at all. Moving
|
|
them into a sibling keeps the credential surface small and audit-
|
|
friendly.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import os
|
|
import stat
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
_ASKPASS_SCRIPT_PATH: Path | None = None
|
|
|
|
|
|
def _ensure_askpass_script() -> Path:
|
|
"""Create (once per process) a 0600 asker-script that echoes the
|
|
Forgejo PAT to stdout when git invokes ``GIT_ASKPASS``.
|
|
|
|
The PAT is read from ``FORGEJO_REVIEWER_PAT`` at script-call
|
|
time (NOT script-creation time) so a credential rotation
|
|
mid-process picks up the new value on the next fetch. The
|
|
script file itself stores no secret.
|
|
"""
|
|
global _ASKPASS_SCRIPT_PATH
|
|
if _ASKPASS_SCRIPT_PATH is not None and _ASKPASS_SCRIPT_PATH.exists():
|
|
return _ASKPASS_SCRIPT_PATH
|
|
fd, path = tempfile.mkstemp(prefix="cleveragents-askpass-", suffix=".sh")
|
|
with os.fdopen(fd, "w") as f:
|
|
# Git invokes the askpass binary with a single argv prompt
|
|
# like "Username for 'https://forgejo.example.com':" or
|
|
# "Password for 'https://forgejo.example.com':". HTTPS basic
|
|
# auth on a Forgejo PAT wants username=<reviewer-account>
|
|
# and password=<the PAT>; many setups happen to work when
|
|
# both prompts return the PAT (Forgejo accepts the PAT as
|
|
# the username when paired with a non-empty password) but
|
|
# that is undocumented. Differentiating them keeps us on
|
|
# the documented path *and* makes the script future-proof
|
|
# if Forgejo tightens credential validation.
|
|
#
|
|
# We pull the username from FORGEJO_REVIEWER_USERNAME (or
|
|
# legacy GITEA_USERNAME) and fall back to the literal "x-token-auth"
|
|
# which Forgejo accepts as a sentinel for token-only auth,
|
|
# mirroring GitHub's documented pattern.
|
|
f.write(
|
|
"#!/bin/sh\n"
|
|
"# Generated by tools/_pr_clone_creds.py for the auto-agents dispatchers.\n"
|
|
"# Returns the right credential field depending on which prompt\n"
|
|
"# git issued. The PAT is read from the live env so credential\n"
|
|
"# rotation is picked up without recreating this script.\n"
|
|
'prompt="${1:-}"\n'
|
|
'lower=$(printf "%s" "$prompt" | tr "[:upper:]" "[:lower:]")\n'
|
|
'case "$lower" in\n'
|
|
" *username*)\n"
|
|
' printf "%s\\n" "${FORGEJO_REVIEWER_USERNAME:-${GITEA_USERNAME:-x-token-auth}}"\n'
|
|
" ;;\n"
|
|
" *password*|*token*|*passphrase*|*)\n"
|
|
' printf "%s\\n" "${FORGEJO_REVIEWER_PAT:-${GITEA_TOKEN:-}}"\n'
|
|
" ;;\n"
|
|
"esac\n"
|
|
)
|
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) # 0700
|
|
_ASKPASS_SCRIPT_PATH = Path(path)
|
|
|
|
def _cleanup() -> None:
|
|
try:
|
|
if _ASKPASS_SCRIPT_PATH and _ASKPASS_SCRIPT_PATH.exists():
|
|
_ASKPASS_SCRIPT_PATH.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
atexit.register(_cleanup)
|
|
return _ASKPASS_SCRIPT_PATH
|
|
|
|
|
|
# Env vars we explicitly preserve when building a git subprocess
|
|
# environment. Goals (in priority order):
|
|
#
|
|
# 1. Don't break corporate networks: ``SSL_CERT_FILE`` /
|
|
# ``SSL_CERT_DIR`` / ``GIT_SSL_CAINFO`` / ``GIT_SSL_CAPATH`` are
|
|
# how custom CA bundles get applied. ``*_proxy`` / ``no_proxy``
|
|
# are how outbound HTTPS reaches Forgejo through restricted
|
|
# networks.
|
|
# 2. Don't break locale-sensitive porcelain: ``LANG`` / ``LC_ALL`` /
|
|
# ``LC_*``. Some git tooling (``git log --grep`` regex, encoding
|
|
# of commit messages) silently misbehaves with the C locale.
|
|
# 3. Don't break ssh-based fallbacks if the operator points
|
|
# ``forgejo_url`` at an ssh remote: ``SSH_AUTH_SOCK`` /
|
|
# ``SSH_AGENT_PID``.
|
|
# 4. Don't break /tmp scratch usage: ``TMPDIR``.
|
|
#
|
|
# Everything else is intentionally dropped so a leaked secret in an
|
|
# unrelated env var (a CI pipeline that exports
|
|
# ``DATABASE_PASSWORD`` for example) cannot ride along into the
|
|
# git subprocess and end up in a hook script's environment.
|
|
_GIT_ENV_PASSTHROUGH = (
|
|
"SSL_CERT_FILE",
|
|
"SSL_CERT_DIR",
|
|
"GIT_SSL_CAINFO",
|
|
"GIT_SSL_CAPATH",
|
|
"GIT_SSL_NO_VERIFY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"no_proxy",
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"NO_PROXY",
|
|
"ALL_PROXY",
|
|
"all_proxy",
|
|
"LANG",
|
|
"LC_ALL",
|
|
"LC_CTYPE",
|
|
"LC_MESSAGES",
|
|
"LC_COLLATE",
|
|
"TZ",
|
|
"TMPDIR",
|
|
"SSH_AUTH_SOCK",
|
|
"SSH_AGENT_PID",
|
|
"GNUPGHOME",
|
|
)
|
|
|
|
|
|
def _git_env(cfg: Any) -> dict[str, str]:
|
|
"""Build a clean env for a ``git`` subprocess invocation.
|
|
|
|
Sets ``GIT_ASKPASS`` and re-exposes the PAT (so the asker script
|
|
can read it) while suppressing every other unrelated env var
|
|
via :data:`_GIT_ENV_PASSTHROUGH`. ``GIT_TERMINAL_PROMPT=0``
|
|
refuses interactive prompts so a missing credential fails fast
|
|
instead of hanging.
|
|
"""
|
|
pat = (
|
|
os.environ.get("FORGEJO_REVIEWER_PAT")
|
|
or os.environ.get("GITEA_TOKEN")
|
|
or getattr(cfg, "token", "")
|
|
)
|
|
username = (
|
|
os.environ.get("FORGEJO_REVIEWER_USERNAME")
|
|
or os.environ.get("GITEA_USERNAME")
|
|
or "x-token-auth"
|
|
)
|
|
askpass = _ensure_askpass_script()
|
|
env: dict[str, str] = {
|
|
# Always-present essentials git itself reads:
|
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
"HOME": os.environ.get("HOME", "/tmp"),
|
|
"GIT_ASKPASS": str(askpass),
|
|
"GIT_TERMINAL_PROMPT": "0",
|
|
"FORGEJO_REVIEWER_PAT": pat,
|
|
"GITEA_TOKEN": pat,
|
|
"FORGEJO_REVIEWER_USERNAME": username,
|
|
"GITEA_USERNAME": username,
|
|
}
|
|
for name in _GIT_ENV_PASSTHROUGH:
|
|
value = os.environ.get(name)
|
|
if value is not None:
|
|
env[name] = value
|
|
return env
|
|
|
|
|
|
__all__ = (
|
|
"_ensure_askpass_script",
|
|
"_git_env",
|
|
)
|