[verified] Define non-file Git credential recipe
This commit is contained in:
@@ -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.<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한다.
|
||||
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을 별도 사실로 기록한다.
|
||||
|
||||
@@ -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를 사용하거나 자동 복구하지 않는다.
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user