From 1e842a6e43dec278a128120594ef2f4fac39b1e2 Mon Sep 17 00:00:00 2001 From: gitea-spec-agent Date: Mon, 12 Oct 2026 07:27:04 +0000 Subject: [PATCH] [verified] Define non-file Git credential recipe --- .../impl/skills/gitea-issue-rework/SKILL.md | 2 +- harness/operations/repository-cache.md | 21 +++- .../common/test_git_credential_recipe.py | 108 ++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 harness/tests/common/test_git_credential_recipe.py diff --git a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md index 3725e77..931ab89 100644 --- a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md +++ b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md @@ -273,7 +273,7 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com ### 7. 기존 PR branch 전달 1. Write 직전에 remote branch와 PR head가 snapshot의 reviewed head와 여전히 exact-match하는지 확인한다. -2. 첫 delivery push 전에 승인된 noninteractive credential capability를 preflight하고 필요한 temporary askpass helper를 제한된 권한으로 준비한다. Credential은 출력하지 않고 주입하며 interactive prompt를 비활성화하고 push 시도 뒤 성공·실패와 무관하게 helper를 확정적으로 제거한다. 그 후 canonical mirror remote를 변경하지 않고 explicit repository URL과 refspec으로 기존 Issue 전용 branch에 fast-forward non-force push한다. Named mirror remote를 사용해야 하면 `operations/repository-cache.md`에 따라 해당 push 호출에서만 `remote..mirror=false`를 적용한다. +2. 첫 delivery push 전에 승인된 noninteractive credential capability를 preflight하고 공통 [non-file Git credential helper](../../../../operations/repository-cache.md#non-file-git-credential-helper)를 role-owned short-lived child process에서 사용한다. Credential은 Git의 private protocol pipe로만 공급하고 출력·파일·config에 저장하지 않으며 interactive/다른 helper fallback을 비활성화한다. 성공·실패와 무관하게 child process를 wait/reap하여 credential 환경의 종료를 확인한다. 그 후 canonical mirror remote를 변경하지 않고 explicit repository URL과 refspec으로 기존 Issue 전용 branch에 fast-forward non-force push한다. Named mirror remote를 사용해야 하면 `operations/repository-cache.md`에 따라 해당 push 호출에서만 `remote..mirror=false`를 적용한다. 3. Push 결과만 신뢰하지 않고 remote branch SHA와 PR head SHA가 final local HEAD와 exact-match하는지 readback한다. 4. PR body 갱신이 필요한 경우 write 직전에 PR 전체 필드, review·comment·commit collection cardinality와 authenticated impl identity를 다시 읽는다. Current body의 `Closes #`와 기존 구현 내용을 보존하고 재작업 review ID·reviewed head·finding별 조치·최종 검증과 unresolved limitation만 반영한 exact body payload를 명시해 한 번만 호출한다. 성공 응답만 신뢰하지 않고 fresh PR과 관련 collection을 읽어 PR author stable account ID, exact body payload, `updated_at`, base·head·commit/ref, state·draft·merge 상태, labels·assignees·reviewer와 collection cardinality를 baseline과 비교한다. Exact body와 provider가 해당 body update로 파생한 `updated_at` 외의 변경은 승인되지 않았으며 인과를 증명할 수 없으면 partial로 중단한다. 5. PR changed files, commits, state, merge 상태, statuses와 workflow runs를 다시 읽는다. Local test, commit status와 Actions run을 별도 사실로 기록한다. diff --git a/harness/operations/repository-cache.md b/harness/operations/repository-cache.md index 0da1666..b0f2899 100644 --- a/harness/operations/repository-cache.md +++ b/harness/operations/repository-cache.md @@ -279,11 +279,30 @@ Runtime 실행에서 만든 server·합성 DB·임시 credential은 해당 workf - credential 값은 role profile의 `.env`에만 저장하고 mode `0600`을 요구한다. - worker에게 credential 값 또는 credential-bearing URL을 전달하지 않는다. -- Git 인증이 필요하면 short-lived askpass 또는 동등한 child-process mechanism을 사용하고 종료 시 제거한다. +- Git 인증은 아래 canonical non-file child-process mechanism을 우선 사용한다. 직접 실행하는 askpass 파일을 mode `0600`으로 만드는 모순을 피하며, 실행 권한을 추가하거나 credential 파일 권한을 완화하지 않는다. - inherited credential, 다른 role의 credential 또는 interactive prompt로 fallback하지 않는다. - 인증에 실패하면 public clone이나 unauthenticated API·content read를 initialized mirror의 증거로 대체하지 않고 중단한다. - Worktree source, state, Git config, reflog message, commit, patch, test artifact 또는 push refspec에 credential을 기록하지 않는다. +### Non-file Git credential helper + +이 패턴은 기존 Git credential protocol을 사용하는 POSIX child-process recipe이며 새 push adapter나 credential 저장소가 아니다. Lock 및 secret-bearing 파일의 기존 mode `0600` 계약은 유지한다. mode `0600` askpass 파일을 직접 실행하거나 chmod로 우회하지 않으며 이 패턴은 helper 파일을 만들지 않는다. + +Main caller는 자신의 승인된 role profile `.env`에서 지정된 token 변수만 private child 안에서 읽고 role account identity와 exact HTTPS repository URL·branch/refspec·권한을 독립 검증한다. 다른 profile·ambient credential을 선택하지 않는다. 인증된 username과 token을 각각 child-only `GIT_ROLE_USERNAME`/`GIT_ROLE_TOKEN`에 넣으며 빈 값, CR/LF/NUL이 포함된 값은 주입 전에 거절한다. 이 변수는 새 영구 설정이나 `.env` key가 아니다. Parent/shared shell에 export하거나 값·request header·raw exception을 기록하지 않는다. + +Child는 `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=/bin/false`, `SSH_ASKPASS=/bin/false`를 명시하고 승인된 최소 환경만 전달한다. 아래 첫 empty `credential.helper`는 inherited helper 목록을 지우고 다음 helper는 `get`에서만 값을 Git private protocol pipe로 공급한다. `store`/`erase`는 아무 값도 출력하거나 저장하지 않는다. Token은 argv·URL·Git config에 들어가지 않으며 모든 `-c`는 해당 호출에만 적용된다. Missing 변수는 실패하며 interactive나 다른 helper로 fallback하지 않는다. + +```sh +git -c credential.helper= \ + -c 'credential.helper=!f() { case "$1" in get) test -n "${GIT_ROLE_USERNAME:-}" && test -n "${GIT_ROLE_TOKEN:-}" && printf "%s\n" "username=$GIT_ROLE_USERNAME" "password=$GIT_ROLE_TOKEN";; store|erase) :;; *) return 1;; esac; }; f' \ + -c credential.interactive=false -c remote.origin.mirror=false \ + push --dry-run "$VERIFIED_REPOSITORY_URL" "$VERIFIED_REFSPEC" +``` + +`VERIFIED_REPOSITORY_URL`은 credential 없는 exact 승인 URL이고 `VERIFIED_REFSPEC`은 승인된 branch의 explicit non-force refspec이다. Canonical mirror remote/config를 변경하지 않는다. 먼저 위 exact destination dry-run으로 capability를 확인한 뒤 별도로 승인된 실제 push에서만 같은 credential option/env와 refspec을 사용한다. Dry-run은 실제 write 성공의 증명이 아니며 push 뒤 authoritative branch/PR readback과 fetch/local SHA 비교가 필요하다. 오류·timeout은 기존 reconcile 규칙을 따르고 automatic push retry하지 않는다. + +Helper stdout은 Git 전용 pipe다. `git credential fill`의 credential-bearing stdout을 terminal, tool output, evidence 또는 log로 내보내지 않는다. Local synthetic protocol 검증은 이를 private child memory로만 capture하고 결과 boolean/exit code만 보고한다. Parent는 child의 전체 수명에 finite deadline을 적용하고 성공·실패·cancel 모두 wait/reap하여 credential 환경의 종료를 확인한다. 값이 담긴 파일/config를 만들지 않으므로 helper-file 삭제 단계도 없다. same-UID 환경은 cooperative trust이며 같은 UID의 악성 프로세스로부터 환경변수를 격리한다는 보장은 아니다. Network 인증·TLS·permission·lease/adoption 검증은 기존 caller 책임이며 이 recipe 자체가 증명하지 않는다. + ## Fail-closed 조건 다음 중 하나라도 발생하면 cache를 사용하거나 자동 복구하지 않는다. diff --git a/harness/tests/common/test_git_credential_recipe.py b/harness/tests/common/test_git_credential_recipe.py new file mode 100644 index 0000000..cf02654 --- /dev/null +++ b/harness/tests/common/test_git_credential_recipe.py @@ -0,0 +1,108 @@ +"""Local credential protocol checks only; no real credentials or network.""" +import os +import secrets +import shlex +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +POLICY = ROOT / "harness/operations/repository-cache.md" +REWORK = ROOT / "harness/agents/impl/skills/gitea-issue-rework/SKILL.md" +HEADING = "### Non-file Git credential helper" + + +class GitCredentialRecipeTests(unittest.TestCase): + def recipe(self): + policy = POLICY.read_text(encoding="utf-8") + self.assertTrue(HEADING in policy, "canonical non-file credential recipe is missing") + section = policy.split(HEADING, 1)[1].split("\n## ", 1)[0] + block = section.split("```sh\n", 1)[1].split("```", 1)[0] + return section, shlex.split(block.replace("\\\n", "")) + + def test_canonical_nonfile_recipe_preserves_permission_and_delivery_contract(self): + section, command = self.recipe() + self.assertEqual(command[0], "git") + self.assertEqual(command[1:3], ["-c", "credential.helper="]) + helper = command[4] + self.assertTrue(helper.startswith("credential.helper=!")) + self.assertIn("GIT_ROLE_USERNAME", helper) + self.assertIn("GIT_ROLE_TOKEN", helper) + self.assertIn("credential.interactive=false", command) + self.assertIn("remote.origin.mirror=false", command) + self.assertEqual(command[-4:], ["push", "--dry-run", "$VERIFIED_REPOSITORY_URL", "$VERIFIED_REFSPEC"]) + for required in ("0600", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false", "SSH_ASKPASS=/bin/false", + "same-UID", "store", "erase", "stdout", "readback"): + self.assertIn(required, section) + policy = POLICY.read_text(encoding="utf-8") + self.assertIn("Lock 파일과 임시 credential helper는 실행 role 소유와 mode `0600`을 요구한다", policy) + delivery = REWORK.read_text(encoding="utf-8").split("### 7. 기존 PR branch 전달", 1)[1].split("###", 1)[0] + self.assertIn("#non-file-git-credential-helper", delivery) + self.assertNotIn("필요한 temporary askpass helper", delivery) + + def test_nonfile_get_missing_variables_and_store_erase_do_not_persist_credentials(self): + _, documented = self.recipe() + options = documented[:documented.index("push")] + with tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR", "/opt/data/cache/scratch")) as d: + root = Path(d) + token = secrets.token_hex(24) + env = {"PATH": os.environ["PATH"], "HOME": str(root), "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, "GIT_TERMINAL_PROMPT": "0", + "GIT_ASKPASS": "/bin/false", "SSH_ASKPASS": "/bin/false", + "GIT_ROLE_USERNAME": "gitea-impl-agent", "GIT_ROLE_TOKEN": token} + initialized = subprocess.run(["git", "init", "-q"], cwd=root, env=env, + capture_output=True, timeout=10) + self.assertEqual(initialized.returncode, 0) + config = root / ".git/config" + before, mode = config.read_bytes(), stat.S_IMODE(config.stat().st_mode) + request = "protocol=https\nhost=example.invalid\n\n" + + def invoke(action, packet=request, child_env=env): + # Values travel only in the private child env/protocol, never argv. + self.assertFalse(any(token in arg for arg in options)) + result = subprocess.run([*options, "credential", action], cwd=root, env=child_env, + input=packet, text=True, capture_output=True, timeout=10) + self.assertFalse(token in result.stderr, "credential exposed outside private protocol") + return result + + valid = invoke("fill") + self.assertEqual(valid.returncode, 0) + fields = dict(line.split("=", 1) for line in valid.stdout.splitlines() if "=" in line) + self.assertTrue(fields.get("username") == env["GIT_ROLE_USERNAME"], "username binding mismatch") + self.assertTrue(fields.get("password") == token, "synthetic credential binding mismatch") + for missing in ("GIT_ROLE_TOKEN", "GIT_ROLE_USERNAME"): + with self.subTest(missing=missing): + incomplete = dict(env) + incomplete.pop(missing) + rejected = invoke("fill", child_env=incomplete) + self.assertNotEqual(rejected.returncode, 0) + self.assertTrue(rejected.stdout == "", "failed credential lookup emitted data") + packet = ("protocol=https\nhost=example.invalid\nusername=" + env["GIT_ROLE_USERNAME"] + + "\npassword=" + token + "\n\n") + for action in ("approve", "reject"): + with self.subTest(action=action): + result = invoke(action, packet=packet) + self.assertEqual(result.returncode, 0) + self.assertTrue(result.stdout == "", "store/erase emitted credential data") + self.assertTrue(config.read_bytes() == before, "Git config changed") + self.assertEqual(stat.S_IMODE(config.stat().st_mode), mode) + self.assertFalse(token.encode() in config.read_bytes(), "credential persisted") + self.assertEqual(sorted(path.name for path in root.iterdir()), [".git"]) + + def test_mode0600_direct_askpass_fails_without_permission_relaxation(self): + with tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR", "/opt/data/cache/scratch")) as d: + root = Path(d) + askpass = root / "askpass.sh" + askpass.write_text("#!/bin/sh\nexit 0\n") + askpass.chmod(0o600) + env = {"PATH": os.environ["PATH"], "HOME": str(root), "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": str(askpass)} + result = subprocess.run(["git", "-c", "credential.helper=", "credential", "fill"], + cwd=root, env=env, input="protocol=https\nhost=example.invalid\n\n", + text=True, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 128) + self.assertIn("Permission denied", result.stderr) + self.assertTrue(result.stdout == "", "failed credential lookup emitted data") + self.assertEqual(stat.S_IMODE(askpass.stat().st_mode), 0o600)