Skip to content

fix(agent): scope run_task git-identity + token env mutation to the call lifetime #635

Description

@scottschreckengaust

Summary

Scope the process-global environment mutation in run_task (git identity + gh tokens) to the
lifetime of the call, so it cannot leak into a long-lived in-process caller.

Background

#623 fixed the
destructive git config --global clobber (#622) by switching to GIT_AUTHOR_* / GIT_COMMITTER_*
environment variables. That is the correct mechanism and is strictly better than the
predecessor — the old code persisted identity to ~/.gitconfig and survived reboots; the new code
only persists for the lifetime of the Python process.

The variables are set via direct os.environ[...] writes in run_task:

# Configure git identity and gh auth before setup_repo() uses them.
# Use GIT_AUTHOR_*/GIT_COMMITTER_* env vars rather than
# `git config --global`: git honors these for every commit (inherited
# by Claude Code and the safety-net commit in post_hooks) WITHOUT
# writing to any on-disk config. `--global` would clobber the real
# ~/.gitconfig — harmless in the ephemeral container, but destructive
# when this pipeline runs on a developer workstation (#622).
os.environ["GIT_AUTHOR_NAME"] = "bgagent"
os.environ["GIT_AUTHOR_EMAIL"] = "bgagent@noreply.github.com"
os.environ["GIT_COMMITTER_NAME"] = "bgagent"
os.environ["GIT_COMMITTER_EMAIL"] = "bgagent@noreply.github.com"
os.environ["GITHUB_TOKEN"] = config.github_token
os.environ["GH_TOKEN"] = config.github_token
# Set env vars for the prepare-commit-msg hook BEFORE setup_repo()
# so the hook has access to TASK_ID/PROMPT_VERSION from the start.
os.environ["TASK_ID"] = config.task_id
if prompt_version:
os.environ["PROMPT_VERSION"] = prompt_version

Because these are process-global, they persist after run_task returns for as long as the calling
process lives. This was raised as a non-blocking known-property note in the #623 review:
#623 (comment)

The residual edge (not a bug today)

No current caller triggers this — the AgentCore container runs run_task then exits, and the
dev-workstation path (#622's motivation) is a short-lived script that also exits. The latent edge is
a long-lived in-process caller that runs its own git commit after run_task in the same
process without setting identity: that commit would be silently attributed to bgagent.

The same block also writes GITHUB_TOKEN / GH_TOKEN to os.environ (pipeline.py:835-836), so
this is token hygiene as well as commit-attribution correctness — a lingering token in a
long-lived process is the more security-relevant leak of the two.

Proposed fix (option B — scope, don't eliminate)

Keep the current mechanism (subprocesses — the Claude Code CLI spawn and the post_hooks.py
safety-net commit — inherit identity via os.environ, which is exactly why the env-var approach
works), but bound its lifetime with a try/finally restore. Because every commit happens within
run_task's scope
, restoring the prior environment at exit is safe — all commits are already done
by then.

A small context manager keeps it clean:

from contextlib import contextmanager

@contextmanager
def _scoped_environ(**overrides: str):
    """Set env vars for the duration of the block, restoring prior values (or
    deleting keys that were previously unset) on exit — even on exception."""
    prior = {k: os.environ.get(k) for k in overrides}
    os.environ.update(overrides)
    try:
        yield
    finally:
        for k, old in prior.items():
            if old is None:
                os.environ.pop(k, None)
            else:
                os.environ[k] = old

Then wrap the identity/token writes (and the downstream repo setup + agent run that depend on them)
in with _scoped_environ(GIT_AUTHOR_NAME="bgagent", ...): ... inside run_task.

Why B and not the alternatives

  • Document-only: acceptable but leaves the token-lingering property in place.
  • Eliminate via explicit env=: threading an explicit env dict into every subprocess
    (setup_repo, the CLI spawn, post_hooks) is fully correct but a cross-call-site refactor that
    is easy to get wrong (the CLI spawn inherits os.environ today; shell.py._clean_env() strips
    only OTEL_* / PYTHONPATH). Not worth it for a currently-unreachable edge.
  • B neutralizes both the misattribution and the token-lingering edges in one localized change
    with no call-site threading, and it is the production mirror of what the test already does — the
    new regression test uses monkeypatch.delenv(..., raising=False) to restore env on teardown
    (agent/tests/test_pipeline.py:166). B gives production the same lifecycle the test assumes.

Acceptance criteria

  • run_task restores (or deletes, if previously unset) GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL,
    GIT_COMMITTER_NAME, GIT_COMMITTER_EMAIL, GITHUB_TOKEN, GH_TOKEN, TASK_ID,
    PROMPT_VERSION on exit — on both the return path and the exception path.
  • Subprocess commit paths (Claude Code CLI spawn + post_hooks.py safety-net commit) still
    resolve identity to bgagent <bgagent@noreply.github.com> (no regression to fix(agent): stop clobbering developer ~/.gitconfig — use GIT_AUTHOR/COMMITTER env vars instead of git config --global #622's fix).
  • A regression test asserts that, after run_task returns, the six vars hold their
    pre-call values (unset stays unset) in the calling process.
  • Ruff clean; existing test_pipeline.py identity assertions still pass.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2lowest priorityagent-runtimePython agent container: pipeline, runner, hooks, prompts, tools, DockerfileenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions