diff --git a/.github/prompts/dogfood-github-integration.md b/.github/prompts/dogfood-github-integration.md new file mode 100644 index 0000000..4794510 --- /dev/null +++ b/.github/prompts/dogfood-github-integration.md @@ -0,0 +1,18 @@ +# Dogfood: GitHub integration boundary + +Validate the repository publication path without moving credentials into worker state. + +## Acceptance criteria + +1. The worker and arbiter remain provider-neutral. +2. GitHub authentication is supplied only by the trusted parent environment or `gh` credential store. +3. `GitHubClient` does not store, serialize, or write credentials to task state, worker results, prompts, or repository files. +4. CI proves `GH_TOKEN`/`GITHUB_TOKEN` remains available to the `gh` subprocess. +5. Pull-request creation, inspection, and squash merge remain explicit operations. +6. Merge authorization remains outside the worker/arbiter decision itself. + +## Dogfood sequence + +`Task -> Worker -> hard gates -> Arbiter -> accepted diff -> trusted GitHub publication` + +A live publication run must use a disposable branch and an explicitly authorized GitHub identity. Tests must use mocked `gh` subprocesses and must never require a real token. diff --git a/personal_agent/github.py b/personal_agent/github.py index bf05440..8518efa 100644 --- a/personal_agent/github.py +++ b/personal_agent/github.py @@ -1,9 +1,9 @@ """GitHub CLI integration for guarded repository publication. The adapter deliberately uses the authenticated ``gh`` CLI instead of storing -GitHub credentials in the agent process. It is an integration boundary, not a -second Git implementation. All mutating operations are explicit methods so -callers can map them to the autonomous-engineering authority levels. +GitHub credentials in agent state. It is an integration boundary, not a second +Git implementation. All mutating operations are explicit methods so callers +can map them to the autonomous-engineering authority levels. """ from __future__ import annotations @@ -29,7 +29,12 @@ class PullRequest: class GitHubClient: - """Small, credential-free GitHub adapter backed by ``gh``.""" + """Small GitHub adapter backed by ``gh``. + + Authentication remains owned by the trusted parent environment or gh's + credential store. The client does not persist, serialize, or place tokens + in worker results, task state, prompts, or repository files. + """ def __init__(self, repository: str | None = None, *, timeout: int = 30) -> None: self.repository = repository @@ -39,11 +44,10 @@ def _run(self, args: Sequence[str], *, check: bool = True) -> str: command = ["gh", *args] if self.repository and "--repo" not in command: command.extend(["--repo", self.repository]) + # Preserve gh's authentication environment. These credentials are + # consumed only by the trusted GitHub adapter and are never copied into + # worker state. In CI, GH_TOKEN/GITHUB_TOKEN is the normal gh contract. env = dict(os.environ) - env.pop("GITHUB_TOKEN", None) - env.pop("GH_TOKEN", None) - # gh authentication is intentionally delegated to gh's configured - # credential store / host auth. Do not copy tokens into worker state. try: result = subprocess.run( command, diff --git a/tests/test_github.py b/tests/test_github.py index 1b03631..35ffbea 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import subprocess import pytest @@ -13,7 +14,8 @@ def test_create_pull_request_parses_machine_output(monkeypatch: pytest.MonkeyPat def fake_run(command, **kwargs): calls.append(command) - assert "GH_TOKEN" not in kwargs["env"] + assert kwargs["env"]["GH_TOKEN"] == "test-token" + assert kwargs["env"]["GITHUB_TOKEN"] == "test-token-2" return subprocess.CompletedProcess( command, 0, @@ -21,6 +23,8 @@ def fake_run(command, **kwargs): stderr="", ) + monkeypatch.setenv("GH_TOKEN", "test-token") + monkeypatch.setenv("GITHUB_TOKEN", "test-token-2") monkeypatch.setattr(subprocess, "run", fake_run) pr = GitHubClient("FlossWare/agent").create_pull_request( title="Fix", @@ -34,6 +38,15 @@ def fake_run(command, **kwargs): assert "--repo" in calls[0] +def test_github_client_does_not_persist_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GH_TOKEN", "secret") + client = GitHubClient("FlossWare/agent") + assert client.repository == "FlossWare/agent" + assert client.timeout == 30 + assert not hasattr(client, "token") + assert not hasattr(client, "credential") + + def test_missing_gh_is_reported(monkeypatch: pytest.MonkeyPatch) -> None: def fake_run(*args, **kwargs): raise FileNotFoundError