[verified] Define non-file Git credential recipe

This commit is contained in:
2026-10-12 07:27:04 +00:00
parent d4db3f6a8e
commit 1e842a6e43
3 changed files with 129 additions and 2 deletions
@@ -273,7 +273,7 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
### 7. 기존 PR branch 전달 ### 7. 기존 PR branch 전달
1. Write 직전에 remote branch와 PR head가 snapshot의 reviewed head와 여전히 exact-match하는지 확인한다. 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.<name>.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.<name>.mirror=false`를 적용한다.
3. Push 결과만 신뢰하지 않고 remote branch SHA와 PR head SHA가 final local HEAD와 exact-match하는지 readback한다. 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 #<issue-number>`와 기존 구현 내용을 보존하고 재작업 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로 중단한다. 4. PR body 갱신이 필요한 경우 write 직전에 PR 전체 필드, review·comment·commit collection cardinality와 authenticated impl identity를 다시 읽는다. Current body의 `Closes #<issue-number>`와 기존 구현 내용을 보존하고 재작업 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을 별도 사실로 기록한다. 5. PR changed files, commits, state, merge 상태, statuses와 workflow runs를 다시 읽는다. Local test, commit status와 Actions run을 별도 사실로 기록한다.
+20 -1
View File
@@ -279,11 +279,30 @@ Runtime 실행에서 만든 server·합성 DB·임시 credential은 해당 workf
- credential 값은 role profile의 `.env`에만 저장하고 mode `0600`을 요구한다. - credential 값은 role profile의 `.env`에만 저장하고 mode `0600`을 요구한다.
- worker에게 credential 값 또는 credential-bearing URL을 전달하지 않는다. - 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하지 않는다. - inherited credential, 다른 role의 credential 또는 interactive prompt로 fallback하지 않는다.
- 인증에 실패하면 public clone이나 unauthenticated API·content read를 initialized mirror의 증거로 대체하지 않고 중단한다. - 인증에 실패하면 public clone이나 unauthenticated API·content read를 initialized mirror의 증거로 대체하지 않고 중단한다.
- Worktree source, state, Git config, reflog message, commit, patch, test artifact 또는 push refspec에 credential을 기록하지 않는다. - 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 조건 ## Fail-closed 조건
다음 중 하나라도 발생하면 cache를 사용하거나 자동 복구하지 않는다. 다음 중 하나라도 발생하면 cache를 사용하거나 자동 복구하지 않는다.
@@ -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)