Files
cleveragents-core/tools/_pr_clone_creds.py
T
drew 6685c8e9a4 refactor(auto-agents): Phase 0.1 + 0.2 critique fold-in
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>
2026-05-08 20:25:07 -04:00

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",
)