diff --git a/.github/runner-images/law-ai-agent/Dockerfile b/.github/runner-images/law-ai-agent/Dockerfile new file mode 100644 index 0000000000..5c73db15e5 --- /dev/null +++ b/.github/runner-images/law-ai-agent/Dockerfile @@ -0,0 +1,8 @@ +FROM ghcr.io/actions/actions-runner@sha256:e5496277be5d09bc968b3d64911b74e219ac4a3f2edce956a3ecf9271bea1ef4 +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-16 postgresql-client-16 libpq-dev python3-venv ca-certificates git curl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* +USER runner +WORKDIR /home/runner diff --git a/.github/workflows/law-ai-agent-ci.yml b/.github/workflows/law-ai-agent-ci.yml new file mode 100644 index 0000000000..d0c19baf53 --- /dev/null +++ b/.github/workflows/law-ai-agent-ci.yml @@ -0,0 +1,117 @@ +name: Law AI Agent central CI + +on: + workflow_call: + +permissions: + contents: read + +concurrency: + group: law-ci-${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || format('{0}-{1}', github.event_name, github.ref) }} + cancel-in-progress: true + +jobs: + quality: + runs-on: + group: CWL law CI + labels: [self-hosted, Linux, X64, law-ai-agent-ci] + timeout-minutes: 20 + strategy: + fail-fast: false + max-parallel: 1 + matrix: + python-version: ["3.12", "3.14"] + steps: + - name: Admit exact owned caller before checkout + id: admission + shell: bash + env: + CALLER_REPOSITORY: ${{ github.repository }} + CALLER_EVENT: ${{ github.event_name }} + CALLER_REF: ${{ github.ref }} + SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + CENTRAL_REPOSITORY: ${{ job.workflow_repository }} + CENTRAL_REF: ${{ job.workflow_ref }} + CENTRAL_SHA: ${{ job.workflow_sha }} + run: | + set -euo pipefail + python3 - <<'PY' + """Fail closed before checking out consumer or central source.""" + import json + import os + import re + import sys + from pathlib import Path + e = os.environ + owned = "ContextualWisdomLab/law-ai-agent" + valid = e["CALLER_REPOSITORY"] == owned + valid &= e["CENTRAL_REPOSITORY"] == "ContextualWisdomLab/.github" + valid &= bool(re.fullmatch(r"[0-9a-f]{40}", e["SOURCE_SHA"])) + valid &= bool(re.fullmatch(r"[0-9a-f]{40}", e["CENTRAL_SHA"])) + valid &= e["CENTRAL_REF"] == ( + "ContextualWisdomLab/.github/.github/workflows/law-ai-agent-ci.yml@" + + e["CENTRAL_SHA"] + ) + payload = json.loads(Path(e["GITHUB_EVENT_PATH"]).read_text()) + event = e["CALLER_EVENT"] + if event == "pull_request": + pr = payload.get("pull_request", {}) + head, base = pr.get("head", {}), pr.get("base", {}) + valid &= head.get("repo", {}).get("full_name") == owned + valid &= base.get("repo", {}).get("full_name") == owned + valid &= head.get("sha") == e["SOURCE_SHA"] + valid &= base.get("ref") == "main" + elif event == "push": + valid &= e["CALLER_REF"] == "refs/heads/main" + valid &= payload.get("ref") == "refs/heads/main" + valid &= payload.get("after") == e["SOURCE_SHA"] + valid &= payload.get("deleted") is False + elif event == "workflow_dispatch": + valid &= e["CALLER_REF"] == "refs/heads/main" + else: + valid = False + if not valid: + sys.exit("law-ci: admission refused") + with open(e["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write("source_sha=" + e["SOURCE_SHA"] + "\n") + PY + - name: Checkout admitted consumer head + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 verified commit + with: + repository: ContextualWisdomLab/law-ai-agent + ref: ${{ steps.admission.outputs.source_sha }} + path: source + persist-credentials: false + - name: Checkout this reusable workflow source + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 verified commit + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: central + persist-credentials: false + - name: Verify both checkout identities + shell: bash + env: + SOURCE_SHA: ${{ steps.admission.outputs.source_sha }} + CENTRAL_SHA: ${{ job.workflow_sha }} + run: | + set -euo pipefail + test "$(git -C "$GITHUB_WORKSPACE/source" rev-parse HEAD)" = "$SOURCE_SHA" + test "$(git -C "$GITHUB_WORKSPACE/central" rev-parse HEAD)" = "$CENTRAL_SHA" + - name: Provision pinned uv and matrix Python + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 dereferenced commit + with: + version: "0.12.5" + python-version: ${{ matrix.python-version }} + enable-cache: false + - name: Run isolated native database and artifact gates + shell: bash + env: + LAW_CI_SOURCE: ${{ github.workspace }}/source + LAW_CI_SOURCE_SHA: ${{ steps.admission.outputs.source_sha }} + LAW_CI_PYTHON_VERSION: ${{ matrix.python-version }} + run: bash "$GITHUB_WORKSPACE/central/scripts/ci/law_ai_agent_ci.sh" run + - name: Cleanup owned native cluster on cancellation + if: always() + shell: bash + run: bash "$GITHUB_WORKSPACE/central/scripts/ci/law_ai_agent_ci.sh" cleanup diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2417a9e7..ff4e5a0699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +### Law CI cleanup rejects symlinked PostgreSQL descendants + +- The central law-agent cleanup now refuses symbolic links at every component + from the owned scratch root through the PostgreSQL cluster and + `postmaster.pid`. Cancellation and normal-exit cleanup therefore retain + evidence instead of invoking `pg_ctl` through an untrusted descendant path. + Contracts cover all five descendant boundaries in normal and Actions-mode + execution. Hosted exact-head Checks and independent review remain required. ### Security scanner setup no longer inherits runner-local failures - Disable `trivy-action`'s binary cache on the heterogeneous `CWL CI isolated` diff --git a/docs/doctoring/law-ai-agent-central-ci.md b/docs/doctoring/law-ai-agent-central-ci.md new file mode 100644 index 0000000000..4bb159440f --- /dev/null +++ b/docs/doctoring/law-ai-agent-central-ci.md @@ -0,0 +1,145 @@ +# Law AI Agent 중앙 CI 이관 기록 + +## 범위와 상태 + +- 2026-10-05 14:02 KST 사용자 승인. 중앙 소유자는 `ContextualWisdomLab/.github`다. +- 중앙 기준본: `main@37b10243cec3d160ecc9c1be75c71428b160a703`. +- 검증한 소비 기준본: `ContextualWisdomLab/law-ai-agent@301800bd9e2f1c0876ff3466b0de8013474c7d26`. +- 이 변경은 새 workflow, shell helper, 중앙 계약 테스트, 이 기록의 네 파일만 소유한다. + 기존 중앙 control/security/scanner와 소비 source는 수정하지 않는다. +- AGENTS, CLAUDE, master context, product directive, Project protocol, gap baseline, + `ContextualWisdomLab/naruon#974` spec을 읽었다. Project #1은 조회 당시 503개 항목이었다. + 조회한 앞 100개는 전체 인벤토리가 아니다. 이 작업의 통합 담당자는 Coordinator다. +- 소스 이관과 runner 운영은 다른 증거다. 보호된 병합, GitHub job 성공, 출시를 주장하지 않는다. + +## 신뢰 경계 + +`workflow_call`만 허용한다. 중앙 public PR 이벤트는 private 소비 code를 checkout하지 않는다. +첫 inline admission은 checkout 전에 exact caller repository, 이벤트와 SHA를 검사한다. +허용 이벤트는 같은 저장소의 main 대상 PR, main push, main 수동 실행이다. +Fork, 다른 저장소, `pull_request_target`, mutable 중앙 ref, 잘못된 SHA는 실패한다. +Fork를 성공한 skip으로 처리하지 않는다. + +소비 checkout은 이벤트의 PR head 또는 github.sha다. 중앙 helper checkout은 +`job.workflow_repository`와 `job.workflow_sha`다. `github.workflow_sha`를 중앙 SHA로 쓰지 않는다. +공식 문서는 job 필드가 “workflow file that defines the current job”을 가리킨다고 설명한다 +(GitHub, n.d.-a). 두 checkout의 실제 HEAD를 검사한다. 경로는 `source`와 `central`로 분리한다. +`persist-credentials: false`, `contents: read`, cache 비활성화를 적용한다. 별도 secret과 +`secrets: inherit`은 없다. 소비 concurrency prefix와 중앙 `law-ci` prefix는 달라야 한다. + +검증한 action 객체: + +- checkout commit: `fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09`. +- 소비 setup-uv의 `94527f2e458b27549849d47d273a16bec83a01e9`는 annotated tag 객체다. + REST git tag 응답에서 commit `37802adc94f370d6bfd71619e3f0bf239e1f3b78`을 확인했다. + 중앙은 이 commit을 pin한다. uv 버전은 0.12.5다. + +Runner selector는 group `CWL law CI`, labels `self-hosted/Linux/X64/law-ai-agent-ci`다. +Python 3.12/3.14 matrix는 `max-parallel: 1`, 각 job은 20분이다. +Reusable workflow의 runner 접근과 hosted billing은 caller context를 따른다 +(GitHub, n.d.-b). 중앙 소스 이관으로 private caller의 전역 admission 문제가 해결됐다는 주장은 하지 않는다. +격리 runner의 운영 권한·자원·network 검증은 Coordinator의 별도 영수증이 필요하다. + +기존 중앙 workflow에는 Harden-Runner가 있다. 이 lane에는 효과가 없는 action을 복사하지 않았다. +공급자 문서는 self-hosted runner에 Enterprise 구독과 runner infrastructure agent가 필요하며, +구독 없이 action만 추가하면 no-op이라고 설명한다 (StepSecurity, n.d.). Egress 강제와 +host 격리는 runner provisioning 경계다. 이 기록은 해당 제어의 실제 활성화를 증명하지 않는다. + +## 실행 계약 + +Helper는 BASH_SOURCE로 자기 경로를 찾는다. CWD에 의존하지 않는다. +정확한 consumer commit을 `git archive`로 새 scratch source에 복원한다. +이전 checkout의 dist, venv, cache를 사용하지 않는다. + +- native PostgreSQL은 provisioned `pg_config --bindir`에서 찾는다. 런타임 apt/sudo 설치는 없다. +- RUNNER_TEMP의 짧은 `mktemp` immediate-child root, uid 소유권, mode 0700, + socket 0700, TCP off, test database만 사용한다. 모든 경로 component의 symlink를 거부한다. +- `LAW_AGENT_ROOT`와 소비 안전 코드가 실제 읽는 `LAW_AGENT_POSTGRES_ROOT`는 같은 scratch다. +- 서버와 연결의 statement 10초, lock 2초 제한을 유지한다. 실제 consumer 승인 함수 + `connect(..., test_only=True)`를 사용하여 migration 001/002를 commit한다. + committed baseline fixture가 suite 앞부분에서 필요하므로 전체 pytest 전에 초기화한다. +- uv locked sync, ruff, format, mypy, 전체 pytest/coverage90/subprocess coverage를 유지한다. + JUnit의 모든 suite에서 skip/failure/error 0과 test count 양수를 확인한다. +- 새 outdir에서 정확히 wheel 1개와 sdist 1개를 요구한다. uv의 자동 `.gitignore`는 + `--no-create-gitignore`로 막는다 (Astral, n.d.). +- 새 lane-local cache를 online prime한 뒤 다른 새 venv에 실제 offline wheel 설치를 한다. + source 밖에서 `python -I`로 installed prefix 안의 module, 두 migration resource와 CLI help를 확인한다. +- 생성 직후 trap을 설치한다. 기존 state marker는 덮어쓰거나 삭제하지 않는다. + child 실패 상태를 보존하고 cleanup 실패를 성공으로 바꾸지 않는다. + 항상 실행하는 workflow cleanup은 private uid/mode와 canonical immediate-child 범위를 다시 검사한다. + +Shell은 Python interrogate 대상이 아니다. 모든 shell function 앞의 역할·실패 경계 주석과 +모든 embedded Python block의 module docstring을 이 파일의 문서화 계약으로 사용한다. +이는 전체 조직의 100% coverage/docstring gate가 실행됐다는 주장이 아니다. + +## 실제 검증 + +2026-10-05 KST, macOS 로컬의 실제 native PostgreSQL 18.6와 uv 0.12.5에서 실행했다. +소비 source는 위의 정확한 committed SHA다. 소비 작업 중 추가된 미커밋 테스트는 포함하지 않는다. +요청 당시 343개와 달리 현재 기준본의 실제 collection은 **345개**다. + +| 실행 | 실제 결과 | +|---|---| +| Python 3.12.14 전체 소비 suite | 345 passed, skipped 0, coverage 98.95% | +| Python 3.14.7 전체 소비 suite | 345 passed, skipped 0, coverage 98.87% | +| 두 Python의 ruff / format / mypy | 통과 | +| 두 Python의 fresh wheel/sdist + offline 새 venv 설치 | 통과 | +| 두 Python의 source 밖 isolated resource / CLI help | 통과 | +| 두 Python의 native cluster stop와 scratch 제거 | 통과 | +| 중앙 admission TDD | 15 RED → 15 GREEN | +| 중앙 helper evidence TDD | 7 RED → 23 GREEN | +| 실행 계약 TDD | 2 RED → 25 GREEN | +| 실제 빌드 `.gitignore` 회귀 | RED 확인 후 수정·두 lane 재실행 통과 | +| 독립 리뷰 cleanup/trap 회귀 | nested/public root와 trap 순서 3 RED → 31 GREEN | + +Synthetic tool fixture는 child exit 37의 전파, stop 실패, 재cleanup만 검사한다. +실제 DB 검증으로 계산하지 않는다. 실제 DB 결과는 위의 두 native 실행뿐이다. +독립 리뷰 뒤 강화한 최종 helper bytes는 아래 최종 checkpoint에서 다시 검증한다. + +`actionlint 1.7.12`는 공식 `job.workflow_*` 필드를 아직 모른다. 처음 실행은 이 필드와 +custom label에서 실패했다. 임시 custom-label config와 이 세 필드의 exact error regex만 +ignore한 실행은 통과했다. Workflow expression 검사가 완전히 지원된다는 주장은 하지 않는다. +공식 context 문서와 admission runtime 테스트를 별도 근거로 남긴다. +Shellcheck와 `bash -n`은 통과했다. 중앙 full-suite/100% coverage는 별도 실행 상태를 기록한다. + +## 최종 로컬 checkpoint — 14:42 KST + +독립 리뷰 수정 뒤 동일 helper bytes를 두 Python에서 다시 실행했다. +3.12.14는 345 passed / skip 0 / 98.95%, 3.14.7은 345 passed / skip 0 / 98.87%다. +양쪽 offline 실설치, prefix/resource/CLI help와 cleanup을 포함하여 exit 0이다. +중앙 subset은 일반 환경과 `GITHUB_ACTIONS=true`에서 각각 **33 passed**다. +Shellcheck, bash 구문, 6개 embedded Python block compile, 제한적 actionlint와 diff check는 통과했다. +중앙 전체 suite와 100% coverage/interrogate는 이 worker에서 실행하지 않았다. + +최종 core 파일 SHA-256: + +| 파일 | SHA-256 | +|---|---| +| `.github/workflows/law-ai-agent-ci.yml` | `3aac714edfb5438c1f7c0eb6420fcff2f43b39692324cd6d7e86c4c103cfc164` | +| `scripts/ci/law_ai_agent_ci.sh` | `7ee3d7d4f7083d87b921530f4ee3c7ce7dd8041e9611f4429390aa58f9e4e935` | +| `tests/test_law_ai_agent_ci.py` | `8afd78b82f0c2a270375192eb35454735b1bb192b573bc89027d951b50900b35` | + +이 문서 자체의 hash는 Coordinator handoff에서 별도로 전달한다. +다른 agent의 runner image/provisioning/review 파일은 shared worktree에 있지만 이 worker는 수정하지 않았다. + +## 남은 통합 확인 + +1. 독립 reviewer에게 final 네 파일 SHA-256을 전달한다. +2. Coordinator가 격리 Linux runner에서 final helper exact bytes를 실행한다. +3. 소비 thin SHA caller의 exact commit과 중앙 protected commit을 연결한다. +4. GitHub current-head required checks, 독립 승인, protected merge를 따로 검증한다. +5. 활성 기존 writer/cron의 재개는 Coordinator가 소유한다. 이 worker는 스케줄을 변경하지 않는다. + +## References (APA 7) + +Astral. (n.d.). *Commands: uv build*. Retrieved October 5, 2026, from +https://docs.astral.sh/uv/reference/cli/ + +GitHub. (n.d.-a). *Contexts reference*. Retrieved October 5, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/contexts + +GitHub. (n.d.-b). *Reusing workflow configurations*. Retrieved October 5, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations + +StepSecurity. (n.d.). *Harden-Runner*. Retrieved October 5, 2026, from +https://github.com/step-security/harden-runner diff --git a/docs/doctoring/law-central-ci-independent-review.md b/docs/doctoring/law-central-ci-independent-review.md new file mode 100644 index 0000000000..becd4bc6cc --- /dev/null +++ b/docs/doctoring/law-central-ci-independent-review.md @@ -0,0 +1,89 @@ +# Law central CI — independent bounded review + +검토일: 2026-10-05 KST. 담당 dispatch: `ctx_03ac2f3ed053`. + +## 판정 + +**BOUNDED PASS — writer 완료 뒤 frozen 6파일 bytes와 전달 SHA256이 일치했다. 원격 gate, 소비 caller pin과 출시의 PASS가 아니다.** + +Coordinator는 writer_done `msg_87a489827bae`, outcome succeeded와 14:44 KST byte freeze를 확인했다. 검토자는 freeze SHA256 6개를 현재 파일에서 직접 다시 계산하고 일치시켰다. 조기 발견한 root 경계와 trap-before-marker 결함은 final helper에서 수정됐다. 현재 승인된 source/design 검토 범위에서 미해결 blocking 결함은 발견하지 않았다. + +검토 대상은 중앙 신규 reusable workflow, shell helper, 계약 테스트와 운영 설명이다. 제품 원문을 중앙에 복사하지 않는다. 검토자는 제품 소스, 자격 증명, 다른 private 자료를 읽지 않았다. 소스 수정, Git 변경, 빌드, 전체 테스트, 컨테이너 실행과 등록을 하지 않았다. 이 문서만 검토자가 소유한다. + +## 조기 발견 — 수정 중인 bytes 기준 + +1. **Cleanup 경로 경계** — 최초 helper의 `cleanup_root`는 최종 root의 디렉터리/비-symlink 여부와 basename만 검사했다. `cleanup_state`는 marker의 regular/non-symlink 여부와 lexical prefix만 검사했다. marker/root owner, mode, 정규화된 즉시 자식과 parent symlink 검증이 없었다. 삭제를 실행하지 않고 Bash 조건만 평가했다. `/scope/lawci.parent/lawci.child`와 `/scope/lawci.parent/../lawci.child` 모두 lexical prefix 조건에 통과했다. 기존 정리 경계 안에서 owner/mode와 canonical direct-child 경계를 확인하는 수정을 coordinator에 전달했다. +2. **Marker 생성 이전 실패** — 최초 helper는 `mktemp` 이후 state 부재 검사와 state 쓰기를 완료한 다음 EXIT trap을 설치했다. state 충돌/쓰기 실패 시 새 scratch 정리가 실행되지 않는다. 기존 marker와 이번 실행 marker의 소유 여부를 구분하면서 trap을 일찍 설치하는 좁은 수정이 필요하다. +3. **실행 증거의 강도** — 최초 helper는 PostgreSQL 도구 존재를 검사하나 16.15 runtime version을 검사하지 않았다. matrix Python은 uv 선택값이며 실행 interpreter version 검사가 없었다. 설치 wheel의 import는 source 밖임만 확인하고 이번 installed prefix 안임은 확인하지 않았다. image/runtime 영수증과 최종 수정 확인이 필요하다. 재현 없이 실제 image가 잘못됐다고 주장하지 않는다. + +## 현재 직접 관측 + +- 14:31 KST GitHub API: runner group `11`, `CWL law CI`, visibility `selected`, `allows_public_repositories=false`, 선택 repository ID는 `[1404539192]` 한 개다. workflow restriction은 설정되지 않았다. 이것은 선택된 private 저장소의 접근 범위이며 특정 중앙 workflow만 허용하는 ACL은 아니다. +- 같은 조회: runner `1134186`, Linux, `online`, `busy=false`; labels는 `self-hosted`, `Linux`, `X64`, `law-ai-agent-ci`다. 재등록, 영구 재사용 또는 컨테이너 폐기의 증거는 아니다. +- 최초 workflow는 `workflow_call`만 받는다. contents 권한은 read다. 두 checkout은 persist-credentials=false이며 consumer head와 called workflow SHA를 따로 검증한다. admission은 owned same-repository PR, main push, main dispatch만 인정하고 fork와 pull_request_target을 거부한다. +- `bash -n scripts/ci/law_ai_agent_ci.sh`는 exit 0이었다. 검토 Python은 `/Users/seonghobae/.pyenv/versions/3.14.5/bin/python3`, version `3.14.5`였다. 이것은 runner Python 3.12/3.14 실행 증거가 아니다. +- 중앙 기존 tracked 파일 수정은 최초 `git status --short`에 없었다. 신규 workflow/helper/test 3개가 untracked였다. 다른 writer가 작업 중이므로 이 관측을 최종 상태로 재사용하지 않는다. + +## 공식 설계 근거 + +- GitHub 2026-09-03 Changelog, “GitHub Actions: Early September 2026 updates”: `job.workflow_ref`, `job.workflow_sha`, `job.workflow_repository`, `job.workflow_file_path`가 해당 job을 정의한 workflow의 identity를 제공한다. github.workflow_*와 달리 reusable called workflow를 가리킨다. GitHub Enterprise Server에는 제공되지 않는다. +- GitHub Docs, “Contexts reference”, job context: 위 called-workflow identity 의미가 현재 reference에도 있다. +- GitHub Docs, “Reusing workflow configurations”, access/runner behavior: private caller는 public reusable workflow를 사용할 수 있다. Actions 허용 설정은 별도 전제다. 같은 조직 called workflow는 caller에게 제공된 조직 self-hosted runner를 사용할 수 있다. GitHub-hosted runner 배정과 billing은 caller context다. 중앙 이전을 billing 또는 현재 StartupFailure 해소로 판정하지 않는다. + +공식 페이지는 검토 세션에서 직접 검색했다. 운영 API 관측은 문서 일반론과 구분한다. + +## 중간 수정 확인 — 14:37 KST, 아직 freeze 전 + +- `validate_owned_root`가 marker/root uid와 0600/0700 mode, immediate-child, parent symlink 검증을 추가했다. 기존 lexical-only 문제는 읽은 revised bytes에서 해소됐다. +- trap이 marker 생성보다 먼저 설치되고 `state_owned`가 이전 marker 삭제를 막는다. 조기 lifecycle 문제는 읽은 revised bytes에서 해소됐다. +- source Python major/minor runtime 검사와 wheel import의 `sys.prefix` 내부 검사가 추가됐다. PostgreSQL 16.15는 여전히 Dockerfile의 major-only 패키지 지정과 실제 image receipt를 구분해야 한다. +- 4번째 파일은 `.github/runner-images/law-ai-agent/Dockerfile`이다. 공식 base digest는 dispatch와 일치한다. build 중 root는 apt 설치에만 사용하고 최종 USER는 runner다. 실제 배포 image와 final Dockerfile bytes 동일성은 미확인이다. + +## 소비 계약 테스트 — 명시적으로 승인된 한 파일 + +Coordinator가 `/Users/seonghobae/orca/projects/law-ai-agent-pg-moa/tests/test_central_ci_caller.py`만 읽도록 추가 승인했다. 42줄의 해당 파일만 읽었다. private product source와 실제 caller workflow는 읽지 않았다. + +- immutable 40-hex 중앙 참조 1개, 중앙 실행 중복 금지, contents read, events와 concurrency literal을 검사한다. +- 테스트 보강 권고: YAML 구조로 `uses`가 job-level인지, push와 pull_request 양쪽 branch 범위가 main인지 확인한다. 현재 branch literal 한 번만 있으면 통과한다. 이는 실제 caller 결함 판정이 아니라 테스트의 증명 범위다. +- consumer test의 현재 RED3/pass1은 coordinator 관측이며 검토자가 실행하지 않았다. 중앙 SHA pin을 쓰기 전 결과를 최종 gate로 주장하지 않는다. + +## 최종 frozen bytes와 직접 bounded probe + +14:44–14:46 KST, 다음 SHA256을 full bytes에서 계산했다. 모두 Coordinator freeze receipt와 일치했다. + +| 파일 | SHA256 | +|---|---| +| `.github/workflows/law-ai-agent-ci.yml` | `3aac714edfb5438c1f7c0eb6420fcff2f43b39692324cd6d7e86c4c103cfc164` | +| `scripts/ci/law_ai_agent_ci.sh` | `7ee3d7d4f7083d87b921530f4ee3c7ce7dd8041e9611f4429390aa58f9e4e935` | +| `tests/test_law_ai_agent_ci.py` | `8afd78b82f0c2a270375192eb35454735b1bb192b573bc89027d951b50900b35` | +| `docs/doctoring/law-ai-agent-central-ci.md` | `08f02306e9aee3d9ca2a4d5ada6b0cc50a492831de08e4712479eec13772e6e5` | +| `.github/runner-images/law-ai-agent/Dockerfile` | `17596881141c761c5e1a55a81d1b2b76559f6fac3aa8b66e1b705f4990808694` | +| `docs/doctoring/law-ci-runner-provisioning.md` | `c798d85cc1db6b92113b2463dbbdb9b12b7115c23dbdf02f7fc0937f041af281` | + +직접 실행 결과: + +- 실제 workflow admission parsed Python body: 정상 main push 허용, mutable 중앙 ref/target event/중앙 caller/uppercase SHA 거부, **5개 PASS**. +- 실제 helper의 root validator parsed Python body: owned direct-child 허용, nested root/symlink root/0644 marker 거부, **4개 PASS**. validator만 실행했고 stop/remove는 실행하지 않았다. +- embedded Python **6개 compile PASS**, `bash -n` exit **0**. +- 승인된 소비 테스트 44줄을 다시 읽었다. Coordinator가 event별 main branch와 job-level uses regex를 추가했음을 확인했다. 실제 caller는 중앙 commit 전이므로 별도 후속 검토다. + +## 실행 증거의 경계와 다음 단계 + +- writer 문서의 macOS native 두 lane/33 central tests 성공은 writer 관측이다. 검토자가 재실행하지 않았다. +- Coordinator는 final helper `7ee3d7d…`와 consumer baseline `301800bd…`를 사용한 Linux 격리 image에서 3.12.3: 345 pass, skip 0, coverage 98.95%; 3.14.7: 345 pass, skip 0, coverage 98.87%, full quality/offline wheel/cleanup exit 0을 직접 확인했다고 전달했다. 이는 producer 관측이며 검토자 자신의 실행과 구분한다. +- image SHA, container limits/mounts와 sudo 거부는 provisioning 문서와 Coordinator producer 영수증이다. 문서는 egress 방화벽 미검증, VM 수준 격리 아님, apt 재빌드 결과 가변, ephemeral 이후 새 등록 필요를 명확히 기록했다. 현재 source 검토가 이를 보안 인증으로 확대하지 않는다. +- inherited PGOPTIONS는 helper에서 제거된다. native server timeout 10s/2s를 유지한다. 단일 job 20분은 품질 lane이며 중앙 모델 검토 timeout 변경이 아니다. +- 실제 소비 thin SHA caller, 원격 current-head required checks, ordinary protected merge/publish가 남았다. 중앙 기존 review/security gate는 변경하지 않았다. 중앙 이관은 billing/permission gate 우회가 아니다. + + +## 후속 공백 경로 수리 — scratch 검증, 미게시 + +위 14:44–14:46의 helper `7ee3d7d…` 및 test `8afd78b8…` 값은 당시 frozen source의 역사적 receipt다. 이후 a55 source의 migration inventory·ancestor admission 수리 bytes와 같다는 주장이 아니다. 당시 producer의 DB/wheel 관측을 이 후속 후보의 실행으로 전이하지 않는다. + +현재 scratch는 a55 head의 source에 공백 경로용 두 줄 수정과 별도 회귀 테스트를 추가했다. helper SHA-256은 `67b7b11968eed60745537d9e000a5c834f1cfbc22409d8321f16191ac25355f6`이며, 기존 test SHA-256은 `cbc5fa07cb6f004ff32f4f8a89f91d16dbcb4e944f9850af14d64c10e7311a25`, 추가 test SHA-256은 `736eb442735e1d40744edb554a65c087f825079eb91f5dc7650a679d6d3b8800`다. + +- RED: 실제 helper에서 추출한 pg_ctl 호출과 conninfo 할당을 실행했다. 공백이 든 path를 shlex token seam으로 확인한 2개 assertion이 실패했다. +- 최소 GREEN: pg_ctl server option의 socket 값을 double quote하고 conninfo host 값을 single quote했다. 같은 두 assertion은 2 passed, Bash syntax exit 0이다. +- pg_ctl executable은 합성 capture stub이다. shlex token seam은 실제 libpq parser 또는 native PostgreSQL startup이 아니다. 현재 host PATH에는 PostgreSQL 실행 파일이 없어 실제 native startup은 NOT_RUN이다. +- 이 후보는 공백 경로만 검증했다. quote/backslash 등 별도 경로 문자에 대한 전체 escaping acceptance를 주장하지 않는다. 실제 native startup과 독립 검토 전 canonical adoption·게시·App 승인으로 확대하지 않는다. +- 새 base362fed와의 조합은 아직 실행하지 않았다. a55의 이전 full gate를 이 수정이나 새 base의 결과로 전이하지 않는다. diff --git a/docs/doctoring/law-ci-runner-provisioning.md b/docs/doctoring/law-ci-runner-provisioning.md new file mode 100644 index 0000000000..1fa421ab41 --- /dev/null +++ b/docs/doctoring/law-ci-runner-provisioning.md @@ -0,0 +1,57 @@ +# Law CI 전용 runner 제공 기록 + +## 목표와 책임 + +ContextualWisdomLab/law-ai-agent#1의 품질 실행을 중앙 reusable workflow로 옮긴다. +중앙 issue는 ContextualWisdomLab/.github#2580, GitHub Project #1은 In Progress다. +사용자는 2026-10-05 KST에 중앙 .github 이관과 self-hosted 전환을 명시했다. +제품 source와 source 기록은 private consumer에 유지한다. 중앙 public repo에 원문을 복사하지 않는다. + +## 2026-10-05 14:25 KST 실측 + +- 새 group 11, `CWL law CI`: selected repository는 `ContextualWisdomLab/law-ai-agent` (1404539192) 하나. +- public repository 허용 false, 기존 control/security/signing/GPU/health group 변경 0. +- 새 runner 1134186 `law-ai-agent-ci-isolated-20261005`: Linux X64, label `law-ai-agent-ci`, online, busy false. +- 별도 컨테이너: non-root UID1001, mounts [], cap-drop ALL, no-new-privileges, memory2147483648, nanoCPUs2000000000, pids512. +- host Docker socket, host home/credential, production database volume, published port, host network 사용 0. +- 컨테이너에서 sudo는 no-new-privileges 때문에 거부된다. 이는 helper에 sudo install이 없다는 전제와 일치한다. +- immutable base: `ghcr.io/actions/actions-runner@sha256:e5496277be5d09bc968b3d64911b74e219ac4a3f2edce956a3ecf9271bea1ef4`. +- 로컬 제공 image SHA256 `7eef6dcf639e556ab28c8970978f30b668dc9d21099b8c8d206d3c8d863f7068`. +- `.github/runner-images/law-ai-agent/Dockerfile`의 SHA256 `17596881141c761c5e1a55a81d1b2b76559f6fac3aa8b66e1b705f4990808694`. +- native PostgreSQL 16.15와 pg_config bindir `/usr/lib/postgresql/16/bin`를 직접 확인했다. +- runner 2.337.0과 `Listening for Jobs`를 확인했다. GitHub job 성공 증거는 아니다. +- 이미지 apt 해석 결과는 위 image에 고정돼 있다. Dockerfile의 apt package는 재빌드 시 바뀔 수 있다. registry 배포/이미지 서명은 수행하지 않았다. + +## 재현 운영 계약 + +이미지 build는 provisioning 때만 수행한다. CI job에서 sudo/apt 설치하지 않는다. +전용 image는 DB 바이너리를 제공하고 workflow가 uv0.12.5와 Python matrix를 제공한다. +각 runner는 `--ephemeral --disableupdate --runnergroup 'CWL law CI' --labels law-ai-agent-ci`로 등록한다. +단일 job 종료 후 등록이 해제된다. 다음 matrix job은 새 컨테이너와 새 등록이 필요하다. +등록 token은 승인된 GitHub API로 구하고 stdin으로 config.sh에 전달한다. +`ACTIONS_RUNNER_INPUT_TOKEN`은 등록 child에만 적용하며 stdout·로그·파일·명령 인자에 직접 넣지 않는다. +서버 .credentials 등의 generated credential 파일은 컨테이너 내부에만 존재한다. 컨테이너 제거 시 함께 회수한다. + +컨테이너 생성 옵션은 `--restart=no --cap-drop ALL --security-opt no-new-privileges --memory=2g --cpus=2 --pids-limit=512`다. +이미지 writable layer는 job 전용이다. host mount를 추가하거나 host 네트워크로 전환하지 않는다. +같은 명칭의 busy runner는 제거하지 않는다. API의 id/name/group/busy와 컨테이너 owner label을 대조한 뒤 owned idle resource만 회수한다. +초기 detached stdin 전달은 대기 상태로 남았다. 원인은 detached container의 stdin attachment였다. +owned unregistered container를 제거하고 `docker exec -i` 등록 → 별도 `docker exec -d` 실행으로 수리했다. +등록 성공, online, workflow 해석, job 시작, source checkout, 품질 verdict를 별도 확인한다. + +## 신뢰 한계와 실제 다음 gate + +이 개발용 컨테이너를 VM 수준 격리 또는 hostile workload 보안 인증으로 표현하지 않는다. +network는 GitHub/toolchain 접근을 위해 존재한다. host private network에 대한 별도 egress 방화벽은 이 기록에서 검증하지 않았다. +일반 CI가 signing/security runner나 운영 자격을 공유하지 않도록 repo별 group을 사용한다. +reusable workflow의 runner access와 billing은 caller context다. 중앙 파일 이관은 caller의 계정 admission을 자동 해결하지 않는다. +issue ContextualWisdomLab/law-ai-agent#2의 정책 정상화 증거와 실제 runner-bound job가 있어야 ordinary merge를 승인한다. +공급된 runner만으로 성과를 만들지 않는다. 실제 Linux helper/full-suite 기록과 exact-head GitHub job receipt를 별도로 남긴다. + +## 공식 근거 (APA 7) + +GitHub. (2026, September 3). *GitHub Actions: Early September 2026 updates*. GitHub Changelog. https://github.blog/changelog/2026-09-03-github-actions-early-september-2026-updates/ + +GitHub. (n.d.). *Reusing workflow configurations*. GitHub Docs. Retrieved October 5, 2026, from https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations + +GitHub. (n.d.). *Reuse workflows*. GitHub Docs. Retrieved October 5, 2026, from https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows diff --git a/docs/doctoring/law-ci-test-portability-fix.md b/docs/doctoring/law-ci-test-portability-fix.md new file mode 100644 index 0000000000..bb15ee74be --- /dev/null +++ b/docs/doctoring/law-ci-test-portability-fix.md @@ -0,0 +1,60 @@ +# Law CI test portability fix + +## 범위 + +2026-10-05 KST. Dispatch `ctx_df60f6521590`. +기준 HEAD는 `9e482e14c3d31a09e9167af2fafb2edab26fbf12`다. +수정 파일은 `tests/test_law_ai_agent_ci.py`와 이 새 보고서뿐이다. +Git commit, push, 원격 게시, runner 설정, 제품 helper 변경은 하지 않았다. + +GitHub에서 PR #2581의 head/base와 CodeRabbit comment `4181096101`을 직접 읽었다. +Finding은 유효했다. 기존 fixture는 `~/.hermes/cache/scratch`가 이미 있어야 했다. +해당 부모 디렉터리가 없으면 두 parametrize case 모두 child 실패 주입 이전에 실패했다. +CodeRabbit의 `/tmp` 고정 제안은 적용하지 않았다. 현재 TMPDIR와 canonical 경로 검증을 유지했다. + +## RED → GREEN + +실행 interpreter는 `/Users/seonghobae/.pyenv/versions/3.14.5/bin/python3`, Python **3.14.5**다. +환경 snapshot의 3.14.7과 구분한다. 실제 TMPDIR는 +`/var/folders/vq/nxtc622x2lg1xq3jpdktymdw0000gn/T/`였다. + +1. 새 subprocess 회귀 테스트를 먼저 추가했다. 빈 HOME, 긴 RUNNER_TEMP, 짧은 TMPDIR에서 + 기존 실제 `test_synthetic_child_failure_and_cleanup_are_not_success[False/True]`를 각각 실행했다. +2. `python3 -m pytest tests/test_law_ai_agent_ci.py -q -k without_hermes_home`는 + **2 failed, 33 deselected**였다. 두 child 모두 `.hermes/cache/scratch/lf.*`에서 + **FileNotFoundError**였다. 이는 추정이나 별도 mock 재현이 아니다. +3. 테스트 전용 `short_temp_root()`를 추가했다. `tempfile.gettempdir()`의 resolved 경로를 우선한다. + `l.` + 8자리 임시 이름과 `/lawci.XXXXXX`를 포함한 24자 예산을 적용한다. + 생성될 helper scratch는 기존 계약인 **65자 미만**을 유지한다. +4. 기본 temp 경로가 길면 HOME 아래 `.cache/law-ci-tests`를 canonical 경로로 선택한다. + 길이를 먼저 검사하고 mode 0700으로 생성한다. 기존 경로의 uid와 private mode도 검사한다. + 긴 HOME까지 허용하려고 `/tmp` alias나 제품 validator를 우회하지 않는다. +5. GREEN 회귀 child는 `--basetemp`를 지정해 pytest 자체 임시 파일과 helper scratch를 분리한다. + 두 case는 child exit **37**, stop 호출, stop 실패 시 보존, 재cleanup을 실제 helper로 확인한다. + 새 `keep` sentinel은 cleanup 이후에도 내용 `unrelated`를 유지한다. + 테스트 종료 시 TemporaryDirectory가 이번 실행 디렉터리만 제거한다. + +## 최종 검증 + +| 명령/계약 | 실제 결과 | +|---|---| +| 원래 collection | 33 tests | +| 두 no-Hermes-HOME subprocess + 기존 두 failure case | 4 passed, 31 deselected | +| `python3 -m pytest tests/test_law_ai_agent_ci.py -q` | **39 passed, 10.18s** | +| `GITHUB_ACTIONS=true python3 -m pytest tests/test_law_ai_agent_ci.py -q` | **39 passed, 10.88s** | +| `git diff --check` | exit 0 | +| 추가 portability 계약 | short temp, symlink alias canonicalization, long temp fallback, TMPDIR 없는 default | + +기존 33개와 새 6개가 모두 통과했다. Synthetic PostgreSQL 도구 결과를 실제 DB 실행으로 계산하지 않는다. +전체 중앙 suite/coverage는 중복 실행하지 않았다. Coordinator의 진행 중 baseline 결과와 별도다. +중앙 provider/model review timeout은 변경하지 않았다. 독립 리뷰와 보호된 원격 gate는 Coordinator가 소유한다. + +## Exact bytes + +| 파일 | SHA-256 | +|---|---| +| `tests/test_law_ai_agent_ci.py` | `e36b38d67befd4f591c2362fe2306430af8843d8028d7d13e3deb5429bfece79` | +| `scripts/ci/law_ai_agent_ci.sh` (변경 없음) | `7ee3d7d4f7083d87b921530f4ee3c7ce7dd8041e9611f4429390aa58f9e4e935` | +| `.github/workflows/law-ai-agent-ci.yml` (변경 없음) | `3aac714edfb5438c1f7c0eb6420fcff2f43b39692324cd6d7e86c4c103cfc164` | + +보고서 자체의 SHA-256은 handoff에서 따로 전달한다. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 25d6c727fd..88bdaf0b30 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -114,6 +114,7 @@ | Gap ID | 상태 | exact-head evidence | causal owner / next gate | |---|---|---|---| +| CONTROL-LAW-CI-CLUSTER-SYMLINK-01 | **Proposed — `.github#2581` owner repair; local GREEN, hosted acceptance pending** | Exact parent `7558db6749e6f73a83cefd5f67dfaca380c8ef7d` allowed symlinks at `data`, `private`, `postgres`, `cluster`, and `postmaster.pid` to reach the synthetic `pg_ctl` boundary. Five RED cases now exercise the real helper; the minimal shared `cleanup_root` guard makes all five GREEN and preserves the root/marker/foreign PID evidence. | Canonical owner is `scripts/ci/law_ai_agent_ci.sh`. Keep `.github#2581` Draft until the final exact head passes the complete local suite, source-executing hosted Checks, and qualifying independent review; never substitute a skipped/zero-step run or bypass. | | CONTROL-OPENCODE-COVERAGE-LOCK-CONTEXT-01 | **Proposed — PR-bound incident register; GitHub Project #1 roadmap item이 아님; `.github#2385@950ab885…` source convergence, hosted acceptance pending** | Required OpenCode run `35370902053`의 `coverage-evidence` job `105778600365`은 PR source 실행 전에 `COPY requirements-opencode-review-ci-hashes.txt requirements-noema-document-ci-hashes.txt /tmp/`에서 두 번째 파일을 찾지 못해 종료했다. RED `9b9f5edcd`는 Dockerfile의 모든 lock input이 trusted build context에 존재해야 한다는 계약을 고정했다. 이 행은 live Project 상태를 주장하지 않고 exact-head PR evidence만 추적하며, protected integration 뒤 제거 여부를 재평가한다. | Canonical owner는 중앙 `.github/.github/workflows/opencode-review-dispatch.yml`이고 complete successor는 `.github#2385`이다. 두 lockfile을 각각 regular non-symlink로 검증하고 build context로 복사한 뒤 exact-head focused/full suite와 새 hosted `coverage-evidence`를 통과해야 한다. PR 제품 source나 coverage 비율의 결함으로 오인하지 않으며 synthetic status·manual rerun·bypass를 사용하지 않는다. | ### 2026-09-13 current-head incident delta diff --git a/scripts/ci/law_ai_agent_ci.sh b/scripts/ci/law_ai_agent_ci.sh new file mode 100755 index 0000000000..542359159c --- /dev/null +++ b/scripts/ci/law_ai_agent_ci.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# Law CI: isolate the consumer, native test database, tool cache and built artifacts. +# Shell function comments are the docstring contract; Python blocks use docstrings. +set -euo pipefail +SELF="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/$(basename -- "${BASH_SOURCE[0]}")" + +# Verify pytest's JUnit evidence, including every nested suite and executed count. +verify_junit() { + python3 - "$1" <<'PY' +"""Reject skips, failures, errors and empty JUnit evidence.""" +import sys +import xml.etree.ElementTree as ET +suites = list(ET.parse(sys.argv[1]).getroot().iter("testsuite")) +valid = bool(suites) and sum(int(s.get("tests", "0")) for s in suites) > 0 +valid &= all(int(s.get(k, "0")) == 0 for s in suites for k in ("skipped", "failures", "errors")) +valid &= not any(ET.parse(sys.argv[1]).getroot().iter("skipped")) +sys.exit(0 if valid else 1) +PY +} + +# Refuse ambiguous or stale artifact directories rather than selecting a glob match. +verify_dist() { + python3 - "$1" <<'PY' +"""Require exactly one regular non-symlink wheel and source archive.""" +import sys +from pathlib import Path +entries = list(Path(sys.argv[1]).iterdir()) +valid = len(entries) == 2 and all(p.is_file() and not p.is_symlink() for p in entries) +valid &= sum(p.name.endswith(".whl") for p in entries) == 1 +valid &= sum(p.name.endswith(".tar.gz") for p in entries) == 1 +sys.exit(0 if valid else 1) +PY +} + +# Validate owned canonical immediate-child paths and refuse every symlink component. +validate_owned_root() { + python3 - "$RUNNER_TEMP" "$1" "${2:-}" <<'PY' +"""Authorize only private current-user scratch roots and optional state markers.""" +import os +import stat +import sys +from pathlib import Path +base, root = map(Path, sys.argv[1:3]) +valid = base.is_absolute() and root.is_absolute() +for path in (base, root): + for component in (path, *path.parents): + valid &= not component.is_symlink() +valid &= root.parent == base and root.name.startswith("lawci.") +valid &= root.is_dir() and root.resolve().parent == base.resolve() +if root.exists(): + info = root.stat() + valid &= info.st_uid == os.getuid() and stat.S_IMODE(info.st_mode) == 0o700 +if sys.argv[3]: + marker = Path(sys.argv[3]) + valid &= marker.parent == base and not marker.is_symlink() and marker.is_file() + if marker.is_file(): + info = marker.stat() + valid &= info.st_uid == os.getuid() and stat.S_IMODE(info.st_mode) == 0o600 +sys.exit(0 if valid else 1) +PY +} + +# Remove only this lane's private scratch cluster; failed stop leaves evidence for retry. +cleanup_root() { + local root="$1" pg_bin="$2" cluster="$1/data/private/postgres/cluster" boundary_path + validate_owned_root "$root" || return 1 + for boundary_path in \ + "$root/data" "$root/data/private" "$root/data/private/postgres" \ + "$cluster" "$cluster/postmaster.pid"; do + if [[ -L "$boundary_path" ]]; then + printf '%s\n' 'law-ci: PostgreSQL cluster boundary is symlinked; scratch retained' >&2 + return 1 + fi + done + if [[ -e "$cluster" && ! -d "$cluster" ]]; then + printf '%s\n' 'law-ci: PostgreSQL cluster path is not a directory; scratch retained' >&2 + return 1 + fi + if [[ -d "$cluster" ]]; then + if [[ ! -f "$cluster/postmaster.pid" ]]; then + printf '%s\n' 'law-ci: PostgreSQL cluster PID metadata is missing; shutdown is inconclusive; scratch retained' >&2 + return 1 + fi + "$pg_bin/pg_ctl" -D "$cluster" -m fast -w stop || return 1 + if [[ -e "$cluster/postmaster.pid" ]]; then + printf '%s\n' 'law-ci: PostgreSQL stop returned success but PID metadata remains; scratch retained' >&2 + return 1 + fi + fi + rm -rf -- "$root" +} + +# Preserve the failing child status and turn a failed database cleanup into failure. +on_exit() { + local status=$? + trap - EXIT INT TERM + if ! cleanup_root "$scratch" "$pg_bin"; then + printf '%s\n' 'law-ci: cleanup failed; owned scratch retained' >&2 + [[ "$status" != 0 ]] || status=1 + else + if [[ "$state_owned" == 1 ]]; then rm -f -- "$state"; fi + fi + exit "$status" +} + +# Execute committed source in a fresh short scratch root, never mutate the checkout. +run_ci() { + [[ "${LAW_CI_SOURCE_SHA:-}" =~ ^[0-9a-f]{40}$ ]] && + [[ "$(git -C "${LAW_CI_SOURCE:-}" rev-parse HEAD)" == "$LAW_CI_SOURCE_SHA" ]] || { + printf '%s\n' 'law-ci: source identity refused' >&2; return 1; + } + [[ "${LAW_CI_PYTHON_VERSION:-}" == 3.12 || "${LAW_CI_PYTHON_VERSION:-}" == 3.14 ]] + [[ "$(uv --version)" == 'uv 0.12.5 '* ]] + pg_bin="$(pg_config --bindir)" + for tool in initdb pg_ctl createdb psql; do test -x "$pg_bin/$tool"; done + test -d "${RUNNER_TEMP:?}" + umask 077 + scratch="$(mktemp -d "$RUNNER_TEMP/lawci.XXXXXX")" + state_owned=0 + state="$RUNNER_TEMP/law-ci-state-${LAW_CI_PYTHON_VERSION}" + trap on_exit EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + test ! -e "$state" && test ! -L "$state" + (set -o noclobber; printf '%s\n' "$scratch" > "$state") + state_owned=1 + # Native Unix socket paths must remain shorter than the platform socket limit. + test "${#scratch}" -lt 65 + export LAW_AGENT_ROOT="$scratch" + export LAW_AGENT_POSTGRES_ROOT="$LAW_AGENT_ROOT" + export LAW_AGENT_POSTGRES_USER + LAW_AGENT_POSTGRES_USER="$(id -un)" + export LAW_AGENT_POSTGRES_PORT=55439 + local private="$scratch/data/private/postgres" + mkdir -p "$private/socket" "$scratch/source" "$scratch/out" "$scratch/outside" + chmod 700 "$scratch" "$private" "$private/socket" + # Consumer safety code requires this exact cluster/socket identity and test database. + export LAW_AGENT_TEST_DATABASE_URL="dbname=law_agent_test user=$LAW_AGENT_POSTGRES_USER host='$private/socket' port=$LAW_AGENT_POSTGRES_PORT" + unset LAW_AGENT_DATABASE_URL PGHOSTADDR PGSERVICE PGSERVICEFILE PGSYSCONFDIR PGOPTIONS PYTHONPATH PYTHONHOME VIRTUAL_ENV + export UV_CACHE_DIR="$scratch/uv-cache" UV_PROJECT_ENVIRONMENT="$scratch/venv" + export UV_PYTHON="$LAW_CI_PYTHON_VERSION" UV_NO_CACHE=1 + printf '%s\n' 'law-ci: fresh native test cluster' + "$pg_bin/initdb" -D "$private/cluster" -A trust -U "$LAW_AGENT_POSTGRES_USER" --encoding=UTF8 --no-locale > "$scratch/initdb.log" + "$pg_bin/pg_ctl" -D "$private/cluster" -l "$scratch/server.log" \ + -o "-k \"$private/socket\" -p $LAW_AGENT_POSTGRES_PORT -c listen_addresses='' -c shared_buffers=32MB -c max_connections=15 -c statement_timeout=10s -c lock_timeout=2s" -w start + "$pg_bin/createdb" -h "$private/socket" -p "$LAW_AGENT_POSTGRES_PORT" law_agent_test + git -C "$LAW_CI_SOURCE" archive "$LAW_CI_SOURCE_SHA" | tar -x -C "$scratch/source" + cd "$scratch/source" + printf '%s\n' 'law-ci: locked source quality gates' + uv sync --locked + uv run --locked ruff check . + uv run --locked ruff format --check . + uv run --locked mypy + # Fixtures need the complete archived migration inventory before suite collection. + # Retain source evidence outside the checkout for the independent installed smoke. + # Use the consumer's real test_only approval, not the broader development CLI path. + uv run --locked python - "$scratch/source-migrations.json" <<'PY' +"""Commit the verified migrations to the fresh, approved test-only database.""" +from hashlib import sha256 +import json +import os +from pathlib import Path +import re +import sys +if sys.version_info[:2] != tuple(map(int, os.environ["LAW_CI_PYTHON_VERSION"].split("."))): + raise SystemExit("law-ci: Python version mismatch") +# Check lexical ancestors before traversal or importing the archived product package. +resources = Path("src/law_ai_agent/migrations").absolute() +for component in reversed((resources, *resources.parents)): + if component.is_symlink() or not component.is_dir(): + raise SystemExit("law-ci: source migration inventory invalid") +inventory = [] +versions = set() +for resource in sorted(resources.iterdir(), key=lambda path: path.name): + match = re.fullmatch(r"([0-9]{3})_[a-z][a-z0-9]*(?:_[a-z0-9]+)*\.sql", resource.name) + if not match or not resource.is_file() or resource.is_symlink(): + raise SystemExit("law-ci: source migration inventory invalid") + version = int(match[1]) + script = resource.read_text() + if version == 0 or version in versions or not script.strip(): + raise SystemExit("law-ci: source migration inventory invalid") + versions.add(version) + # Preserve PostgresStore.migrate's read_text()/script.encode() checksum semantics. + inventory.append({"name": resource.name, "version": version, + "checksum": sha256(script.encode()).hexdigest()}) +if not inventory: + raise SystemExit("law-ci: source migration inventory invalid") +from law_ai_agent.postgres import PostgresStore, connect +with connect(os.environ["LAW_AGENT_TEST_DATABASE_URL"], test_only=True) as connection: + PostgresStore(connection).migrate() + connection.commit() + actual = [{"version": row["version"], "checksum": row["checksum"]} + for row in connection.execute( + "SELECT version, checksum FROM law_agent.migration ORDER BY version")] + expected = [{"version": row["version"], "checksum": row["checksum"]} for row in inventory] + if actual != expected: + raise SystemExit("law-ci: database migration inventory mismatch") +Path(sys.argv[1]).write_text(json.dumps(inventory)) +PY + uv run --locked pytest --cov --cov-fail-under=90 --cov-report=term-missing --junitxml="$scratch/junit.xml" + bash "$SELF" verify-junit "$scratch/junit.xml" + printf '%s\n' 'law-ci: fresh build and installed artifact smoke' + uv build --out-dir "$scratch/out" --no-create-gitignore + bash "$SELF" verify-dist "$scratch/out" + # Prime dependency wheels into a new lane-local cache, then prove real offline install. + uv venv --python "$LAW_CI_PYTHON_VERSION" "$scratch/prime" + UV_NO_CACHE=0 uv pip install --python "$scratch/prime/bin/python" "$scratch/out/"*.whl + uv venv --python "$LAW_CI_PYTHON_VERSION" "$scratch/installed" + UV_NO_CACHE=0 uv pip install --offline --python "$scratch/installed/bin/python" "$scratch/out/"*.whl + cd "$scratch/outside" + "$scratch/installed/bin/python" -I - "$scratch/source-migrations.json" <<'PY' +"""Verify packaged migration resources outside the source checkout.""" +from hashlib import sha256 +from importlib.resources import files +import json +from pathlib import Path +import sys +import law_ai_agent +if not Path(law_ai_agent.__file__).resolve().is_relative_to(Path(sys.prefix).resolve()): + raise SystemExit("law-ci: installed package prefix mismatch") +expected = json.loads(Path(sys.argv[1]).read_text()) +resources = sorted(files("law_ai_agent").joinpath("migrations").iterdir(), key=lambda path: path.name) +if not expected or [resource.name for resource in resources] != [row["name"] for row in expected]: + raise SystemExit("law-ci: installed migration inventory mismatch") +for resource, row in zip(resources, expected): + if not resource.is_file(): + raise SystemExit("law-ci: installed migration inventory mismatch") + script = resource.read_text() + if not script.strip() or sha256(script.encode()).hexdigest() != row["checksum"]: + raise SystemExit("law-ci: installed migration inventory mismatch") +PY + "$scratch/installed/bin/python" -I -m law_ai_agent --help > "$scratch/installed-help.log" + printf '%s\n' 'law-ci: quality, zero-skip database and installed artifact gates passed' +} + +# Recover a lane-local marker on GitHub cancellation; never source marker content. +cleanup_state() { + local version state root pg_bin + for version in 3.12 3.14; do + state="${RUNNER_TEMP:?}/law-ci-state-$version" + test -e "$state" || continue + test ! -L "$state" && test -f "$state" + IFS= read -r root < "$state" + validate_owned_root "$root" "$state" + pg_bin="$(pg_config --bindir)" + cleanup_root "$root" "$pg_bin" + rm -f -- "$state" + done +} + +case "${1:-}" in + verify-junit) verify_junit "$2" ;; + verify-dist) verify_dist "$2" ;; + run) run_ci ;; + cleanup) cleanup_state ;; + *) printf '%s\n' 'law-ci: unknown operation' >&2; exit 1 ;; +esac diff --git a/tests/test_law_ai_agent_ci.py b/tests/test_law_ai_agent_ci.py new file mode 100644 index 0000000000..076788ac68 --- /dev/null +++ b/tests/test_law_ai_agent_ci.py @@ -0,0 +1,579 @@ +"""Central law CI admission contracts; no consumer product source is copied here.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import textwrap +from pathlib import Path +import subprocess +import sys +import tempfile + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/law-ai-agent-ci.yml" +HELPER = ROOT / "scripts/ci/law_ai_agent_ci.sh" +SHA = "a" * 40 + + +def workflow(): + """Read YAML with BaseLoader so the GitHub `on` key remains a string.""" + return yaml.load(WORKFLOW.read_text(), Loader=yaml.BaseLoader) + + +def admission(tmp_path, *, event="push", repository="ContextualWisdomLab/law-ai-agent", **overrides): + """Execute the actual pre-checkout admission with synthetic GitHub metadata.""" + payload = {"after": SHA, "ref": "refs/heads/main", "deleted": False} + if event == "pull_request": + payload = {"pull_request": {"head": {"sha": SHA, "repo": {"full_name": repository}}, + "base": {"ref": "main", "repo": {"full_name": repository}}}} + payload.update(overrides.pop("payload", {})) + path = tmp_path / "event.json" + path.write_text(json.dumps(payload)) + env = {**os.environ, "CALLER_REPOSITORY": repository, "CALLER_EVENT": event, + "CALLER_REF": "refs/heads/main", "SOURCE_SHA": SHA, + "CENTRAL_REPOSITORY": "ContextualWisdomLab/.github", "CENTRAL_SHA": SHA, + "CENTRAL_REF": f"ContextualWisdomLab/.github/.github/workflows/law-ai-agent-ci.yml@{SHA}", + "GITHUB_EVENT_PATH": str(path), "GITHUB_OUTPUT": str(tmp_path / "output")} + env.update(overrides) + step = workflow()["jobs"]["quality"]["steps"][0] + return subprocess.run(["bash", "-euo", "pipefail", "-c", step["run"]], + env=env, capture_output=True, text=True) + + +def test_workflow_is_reusable_only_and_read_only(): + """Prevent public central PRs from executing private consumer code.""" + data = workflow() + assert data["on"] == {"workflow_call": ""} + assert data["permissions"] == {"contents": "read"} + job = data["jobs"]["quality"] + assert "if" not in job + assert job["runs-on"] == {"group": "CWL law CI", "labels": ["self-hosted", "Linux", "X64", "law-ai-agent-ci"]} + assert job["strategy"]["max-parallel"] == "1" + assert job["strategy"]["matrix"]["python-version"] == ["3.12", "3.14"] + assert job["timeout-minutes"] == "20" + steps = job["steps"] + assert "uses" not in steps[0] + checkouts = [s for s in steps if s.get("uses", "").startswith("actions/checkout@")] + assert len(checkouts) == 2 + assert {s["with"]["path"] for s in checkouts} == {"source", "central"} + assert all(s["with"]["persist-credentials"] == "false" for s in checkouts) + assert checkouts[1]["with"]["ref"] == "${{ job.workflow_sha }}" + assert checkouts[1]["with"]["repository"] == "${{ job.workflow_repository }}" + assert "secrets" not in WORKFLOW.read_text() + + +@pytest.mark.parametrize("event", ["push", "pull_request", "workflow_dispatch"]) +def test_admission_accepts_only_owned_events(tmp_path, event): + """Admit exact owned heads before either checkout.""" + assert admission(tmp_path, event=event).returncode == 0 + + +@pytest.mark.parametrize("changes", [ + {"repository": "ContextualWisdomLab/.github"}, + {"event": "pull_request_target"}, + {"SOURCE_SHA": "a" * 39}, + {"SOURCE_SHA": "A" * 40}, + {"CALLER_REF": "refs/heads/develop"}, + {"CENTRAL_REPOSITORY": "attacker/.github"}, + {"CENTRAL_SHA": "a" * 39}, + {"CENTRAL_REF": "ContextualWisdomLab/.github/.github/workflows/law-ai-agent-ci.yml@main"}, + {"payload": {"after": "b" * 40}}, + {"payload": {"deleted": True}}, + {"event": "pull_request", "payload": {"pull_request": {"head": {"sha": SHA, "repo": {"full_name": "fork/law-ai-agent"}}, "base": {"ref": "main", "repo": {"full_name": "ContextualWisdomLab/law-ai-agent"}}}}}, +]) +def test_admission_rejects_untrusted_metadata(tmp_path, changes): + """Forks, other repos, mutable refs, and malformed heads fail rather than skip.""" + assert admission(tmp_path, **changes).returncode != 0 + + +def helper(*args, env=None): + """Run the owned helper as a child process; fixture tools are synthetic only.""" + return subprocess.run(["bash", str(HELPER), *map(str, args)], + env=env, capture_output=True, text=True, cwd=ROOT.parent) + + +@pytest.mark.parametrize("skipped,expected", [(0, 0), (1, 1)]) +def test_junit_requires_executed_tests_without_skips(tmp_path, skipped, expected): + """A passing pytest exit cannot hide skipped database tests.""" + report = tmp_path / "junit.xml" + report.write_text(f'') + assert helper("verify-junit", report).returncode == expected + + +def test_empty_junit_is_not_execution_evidence(tmp_path): + """Empty test collections fail closed.""" + report = tmp_path / "junit.xml" + report.write_text('') + assert helper("verify-junit", report).returncode != 0 + + +@pytest.mark.parametrize("names,expected", [ + (["law.whl", "law.tar.gz"], 0), + (["law.whl", "old.whl", "law.tar.gz"], 1), + (["law.whl"], 1), + (["law.whl", "law.tar.gz", "old.txt"], 1), +]) +def test_dist_cardinality_rejects_previous_builds(tmp_path, names, expected): + """Only one fresh wheel and one fresh source archive are accepted.""" + for name in names: + (tmp_path / name).touch() + assert helper("verify-dist", tmp_path).returncode == expected + + +def test_run_rejects_mismatched_checkout_before_creating_cluster(tmp_path): + """Bad source identity must not start PostgreSQL or uv.""" + env = {**os.environ, "LAW_CI_SOURCE": str(ROOT), "LAW_CI_SOURCE_SHA": SHA, + "LAW_CI_PYTHON_VERSION": "3.14", "RUNNER_TEMP": str(tmp_path)} + result = helper("run", env=env) + assert result.returncode != 0 + assert "source identity refused" in result.stderr + assert not list(tmp_path.iterdir()) + + +def embedded_migration_block(installed=False): + """Extract the real heredoc, not a reimplementation of its acceptance logic.""" + blocks = re.findall(r"<<'PY'\n(.*?)\nPY", HELPER.read_text(), re.S) + marker = 'outside the source checkout' if installed else 'fresh, approved test-only database' + return next(block for block in blocks if marker in block) + + +def migration_fixture(tmp_path, count=5): + """Make archived SQL resources and independent product-semantics ledger rows.""" + source = tmp_path / 'source' + package = source / 'src/law_ai_agent' + resources = package / 'migrations' + resources.mkdir(parents=True) + (package / '__init__.py').write_text('') + for version in reversed(range(1, count + 1)): + name = {1: '001_initial.sql', 2: '002_revision_membership.sql'}.get( + version, f'{version:03}_step.sql') + # CRLF and non-ASCII catch raw-byte hashing in place of read_text().encode(). + (resources / name).write_bytes( + f'-- 단계 {version}\r\nSELECT {version};\r\n'.encode()) + rows = [{'version': int(path.name.split('_')[0]), + 'checksum': hashlib.sha256(path.read_text().encode()).hexdigest()} + for path in sorted(resources.iterdir())] + manifest = tmp_path / 'source-migrations.json' + return source, resources, rows, manifest + + +def run_migration_block(source, rows, manifest, *, optimized=False): + """Run production preflight with only its package/connection boundary synthetic.""" + package = source / 'src/law_ai_agent' + trace = source / 'trace.json' + (package / 'postgres.py').write_text(textwrap.dedent('''\ + import json + from pathlib import Path + class Connection: + def __enter__(self): + return self + def __exit__(self, *args): + pass + def commit(self): + record('commit') + def execute(self, query): + record(query) + rows = json.loads(Path('rows.json').read_text()) + if 'checksum' not in query: + return [{'version': row['version']} for row in rows] + return rows + def record(event): + path = Path('trace.json') + events = json.loads(path.read_text()) if path.exists() else [] + path.write_text(json.dumps(events + [event])) + def connect(url, *, test_only): + if test_only is not True: + raise ValueError('test_only boundary lost') + record('test_only') + return Connection() + class PostgresStore: + def __init__(self, connection): + self.connection = connection + def migrate(self): + record('migrate') + ''')) + (source / 'rows.json').write_text(json.dumps(rows)) + wrapper = ('import sys; sys.path.insert(0, "src"); ' + + f'exec(compile({embedded_migration_block()!r}, "", "exec"))') + env = {**os.environ, 'LAW_CI_PYTHON_VERSION': f'{sys.version_info.major}.{sys.version_info.minor}', + 'LAW_AGENT_TEST_DATABASE_URL': 'synthetic-only'} + command = [sys.executable, *(['-O'] if optimized else []), '-I', '-c', wrapper, str(manifest)] + result = subprocess.run(command, cwd=source, env=env, capture_output=True, text=True) + return result, trace + + +@pytest.mark.parametrize('count', [2, 5]) +def test_preflight_accepts_complete_source_inventory(tmp_path, count): + """Accept evolving inventories and retain sorted source names/checksums.""" + source, resources, rows, manifest = migration_fixture(tmp_path, count) + result, trace = run_migration_block(source, rows, manifest) + assert result.returncode == 0, result.stderr + assert manifest.is_file(), 'source migration manifest was not retained' + assert json.loads(manifest.read_text()) == [ + {'name': path.name, **row} for path, row in zip(sorted(resources.iterdir()), rows)] + assert json.loads(trace.read_text())[:3] == ['test_only', 'migrate', 'commit'] + + +@pytest.mark.parametrize('mutation', ['missing', 'extra', 'checksum']) +@pytest.mark.parametrize('optimized', [False, True]) +def test_preflight_rejects_nonexact_database_inventory(tmp_path, mutation, optimized): + """Every database version and content digest must match, even under -O.""" + source, _, rows, manifest = migration_fixture(tmp_path, 2) + if mutation == 'missing': + rows.pop() + elif mutation == 'extra': + rows.append({'version': 3, 'checksum': 'a' * 64}) + else: + rows[0]['checksum'] = 'a' * 64 + result, _ = run_migration_block(source, rows, manifest, optimized=optimized) + assert result.returncode != 0, 'nonexact database inventory accepted' + assert 'database migration inventory mismatch' in result.stderr + + +def run_installed_block(tmp_path, count=5, mutation=None, optimized=False): + """Execute installed smoke with source manifest and separate installed resources.""" + source, resources, rows, manifest = migration_fixture(tmp_path, count) + manifest.write_text(json.dumps([{'name': path.name, **row} + for path, row in zip(sorted(resources.iterdir()), rows)])) + site = tmp_path / 'installed' / 'lib' / 'site-packages' + package = site / 'law_ai_agent' + installed = package / 'migrations' + installed.mkdir(parents=True) + (package / '__init__.py').write_text('') + for path in resources.iterdir(): + (installed / path.name).write_bytes(path.read_bytes()) + if mutation == 'missing': + (installed / '001_initial.sql').unlink() + elif mutation == 'extra': + (installed / '999_extra.sql').write_text('SELECT 999;') + elif mutation == 'checksum': + (installed / '001_initial.sql').write_text('SELECT -1;') + # -I excludes cwd, and this shim redirects imports only to an installed-like prefix. + wrapper = ('import sys; sys.prefix = ' + repr(str(site.parent.parent)) + + '; sys.path.insert(0, ' + repr(str(site)) + '); ' + + f'exec(compile({embedded_migration_block(installed=True)!r}, "", "exec"))') + return subprocess.run([sys.executable, *(['-O'] if optimized else []), '-I', '-c', + wrapper, str(manifest)], cwd=tmp_path, capture_output=True, text=True) + + +@pytest.mark.parametrize('count', [2, 5]) +def test_installed_smoke_matches_retained_source_manifest(tmp_path, count): + """Two and five independently installed resources match archived checksums.""" + result = run_installed_block(tmp_path, count) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize('mutation', ['missing', 'extra', 'checksum']) +@pytest.mark.parametrize('optimized', [False, True]) +def test_installed_smoke_rejects_divergence_from_source(tmp_path, mutation, optimized): + """The installed resource set cannot self-attest or bypass checks under -O.""" + result = run_installed_block(tmp_path, mutation=mutation, optimized=optimized) + assert result.returncode != 0, 'nonexact installed inventory accepted' + assert 'installed migration inventory mismatch' in result.stderr + + +@pytest.mark.parametrize('optimized', [False, True]) +def test_preflight_imports_product_only_after_valid_source_admission(tmp_path, optimized): + """A valid source still imports the product and performs the test-only migration.""" + source, _, rows, manifest = migration_fixture(tmp_path, 5) + imported = source / 'product-imported' + (source / 'src/law_ai_agent/__init__.py').write_text( + 'from pathlib import Path\nPath("product-imported").write_text("imported")\n') + result, trace = run_migration_block(source, rows, manifest, optimized=optimized) + assert result.returncode == 0, result.stderr + assert imported.read_text() == 'imported' + assert manifest.is_file() + assert json.loads(trace.read_text())[:3] == ['test_only', 'migrate', 'commit'] + + +@pytest.mark.parametrize('component', ['src', 'src/law_ai_agent', 'src/law_ai_agent/migrations']) +@pytest.mark.parametrize('optimized', [False, True]) +def test_preflight_rejects_linked_source_migration_ancestors(tmp_path, component, optimized): + """An archived directory link cannot supply migration evidence from outside source.""" + source, _, rows, manifest = migration_fixture(tmp_path, 5) + imported = source / 'product-imported' + (source / 'src/law_ai_agent/__init__.py').write_text( + 'from pathlib import Path\nPath("product-imported").write_text("imported")\n') + path = source / component + outside = tmp_path / ('outside-' + path.name) + path.rename(outside) + path.symlink_to(outside, target_is_directory=True) + result, trace = run_migration_block(source, rows, manifest, optimized=optimized) + assert result.returncode != 0, 'linked migration ancestor accepted' + assert 'source migration inventory invalid' in result.stderr + assert not manifest.exists() + assert not imported.exists(), 'product imported before source admission' + assert not trace.exists(), 'database access started before source admission' + + +@pytest.mark.parametrize('name', ['bad.sql', '001_a.txt', '000_zero.sql', '001_duplicate.sql']) +def test_preflight_rejects_invalid_source_migration_names(tmp_path, name): + """Only uniquely numbered nonempty SQL migration resources are admissible.""" + source, resources, rows, manifest = migration_fixture(tmp_path, 2) + (resources / name).write_text('SELECT 1;') + result, _ = run_migration_block(source, rows, manifest) + assert result.returncode != 0 + assert 'source migration inventory invalid' in result.stderr + + +def test_preflight_rejects_empty_source_migration(tmp_path): + """An empty SQL file cannot be certified by a ledger row.""" + source, resources, rows, manifest = migration_fixture(tmp_path, 2) + (resources / '001_initial.sql').write_text(' \n') + result, _ = run_migration_block(source, rows, manifest) + assert result.returncode != 0 + assert 'source migration inventory invalid' in result.stderr + + +def test_run_preserves_native_db_quality_and_artifact_contracts(): + """Bind the real execution path to locked isolated tooling and mandatory gates.""" + text = HELPER.read_text() + for contract in ( + 'pg_config --bindir', 'mktemp -d', 'chmod 700', 'listen_addresses=', + 'statement_timeout=10s', 'lock_timeout=2s', 'trap', 'LAW_AGENT_ROOT=', + 'LAW_AGENT_TEST_DATABASE_URL=', 'uv sync --locked', 'ruff check .', + 'ruff format --check .', 'mypy', 'pytest --cov', '--cov-fail-under=90', + '--junitxml=', 'uv build --out-dir', '--no-create-gitignore', 'uv pip install --offline', + '-I -', 'source-migrations.json', 'SELECT version, checksum', + 'test_only=True', 'git -C', 'archive', 'BASH_SOURCE', + ): + assert contract in text + assert 'sudo' not in text and 'apt-get' not in text + + +def test_cleanup_absent_cluster_is_idempotent(tmp_path): + """Admission failures leave no cluster, and always-cleanup remains safe.""" + assert helper('cleanup', env={**os.environ, 'RUNNER_TEMP': str(tmp_path)}).returncode == 0 + + +def test_cleanup_preserves_cluster_when_pid_metadata_is_missing(tmp_path): + """Missing PostgreSQL PID metadata is inconclusive, not a clean stop.""" + runner = tmp_path / 'runner' + runner.mkdir(mode=0o700) + root = runner / 'lawci.keep' + cluster = root / 'data/private/postgres/cluster' + cluster.mkdir(mode=0o700, parents=True) + root.chmod(0o700) + marker = runner / 'law-ci-state-3.14' + marker.write_text(str(root) + '\n') + marker.chmod(0o600) + tools = tmp_path / 'tools' + tools.mkdir() + pg = tools / 'pg_config' + pg.write_text('#!/bin/bash\nprintf "%s\n" "$SYNTHETIC_TOOLS"\n') + pg.chmod(0o700) + env = {**os.environ, 'RUNNER_TEMP': str(runner), + 'SYNTHETIC_TOOLS': str(tools), + 'PATH': str(tools) + os.pathsep + os.environ['PATH']} + result = helper('cleanup', env=env) + assert result.returncode != 0 + assert 'PID metadata is missing; shutdown is inconclusive' in result.stderr + assert cluster.is_dir() + assert root.is_dir() + assert marker.is_file() + + +@pytest.mark.parametrize('unsafe_child', [ + 'data', 'private', 'postgres', 'cluster', 'postmaster-pid', +]) +def test_cleanup_refuses_symlinked_cluster_boundaries(tmp_path, unsafe_child): + """Never invoke PostgreSQL cleanup through a symlinked cluster boundary.""" + runner = tmp_path / 'runner' + runner.mkdir(mode=0o700) + root = runner / 'lawci.keep' + root.mkdir(mode=0o700) + cluster = root / 'data/private/postgres/cluster' + foreign_root = tmp_path / 'foreign-root' + foreign_cluster = foreign_root / 'data/private/postgres/cluster' + foreign_cluster.mkdir(mode=0o700, parents=True) + foreign_pid = foreign_cluster / 'postmaster.pid' + foreign_pid.write_text('foreign\n') + child_paths = { + 'data': Path('data'), + 'private': Path('data/private'), + 'postgres': Path('data/private/postgres'), + 'cluster': Path('data/private/postgres/cluster'), + } + if unsafe_child == 'postmaster-pid': + cluster.mkdir(mode=0o700, parents=True) + (cluster / 'postmaster.pid').symlink_to(foreign_pid) + else: + child_path = child_paths[unsafe_child] + (root / child_path).parent.mkdir(mode=0o700, parents=True, exist_ok=True) + (root / child_path).symlink_to(foreign_root / child_path, target_is_directory=True) + marker = runner / 'law-ci-state-3.14' + marker.write_text(str(root) + '\n') + marker.chmod(0o600) + tools = tmp_path / 'tools' + tools.mkdir() + pg_config = tools / 'pg_config' + pg_config.write_text('#!/bin/bash\nprintf "%s\\n" "$SYNTHETIC_TOOLS"\n') + pg_config.chmod(0o700) + pg_ctl = tools / 'pg_ctl' + pg_ctl.write_text('#!/bin/bash\nprintf "called\\n" >> "$SYNTHETIC_TRACE"\nexit 9\n') + pg_ctl.chmod(0o700) + trace = tmp_path / 'trace' + env = {**os.environ, 'RUNNER_TEMP': str(runner), + 'SYNTHETIC_TOOLS': str(tools), 'SYNTHETIC_TRACE': str(trace), + 'PATH': str(tools) + os.pathsep + os.environ['PATH']} + result = helper('cleanup', env=env) + assert result.returncode != 0 + assert not trace.exists() + assert foreign_pid.read_text() == 'foreign\n' + assert root.is_dir() + assert marker.is_file() + + +def short_temp_root(): + """Select a canonical base leaving room for both private temporary roots.""" + base = Path(tempfile.gettempdir()).resolve() + if len(str(base)) + len('/l.XXXXXXXX/lawci.XXXXXX') < 65: + return base + base = (Path.home() / '.cache/law-ci-tests').resolve() + if len(str(base)) + len('/l.XXXXXXXX/lawci.XXXXXX') >= 65: + raise ValueError('no short temporary root for law CI tests') + base.mkdir(mode=0o700, parents=True, exist_ok=True) + info = base.stat() + if info.st_uid != os.getuid() or info.st_mode & 0o777 != 0o700: + raise ValueError('law CI test cache must be owned and private') + return base + + +@pytest.mark.parametrize('layout', ['short', 'alias', 'long', 'default']) +def test_short_temp_root_is_canonical_and_socket_bounded(tmp_path, monkeypatch, layout): + """Select usable defaults or home cache without forwarding temp aliases.""" + expected = short_temp_root() + candidate = expected + if layout == 'alias': + candidate = tmp_path / 'alias' + candidate.symlink_to(expected, target_is_directory=True) + elif layout == 'long': + candidate = tmp_path / ('temp-' + 'x' * 80) + candidate.mkdir() + expected = (Path.home() / '.cache/law-ci-tests').resolve() + elif layout == 'default': + monkeypatch.delenv('TMPDIR', raising=False) + monkeypatch.setattr(tempfile, 'tempdir', None) + candidate = Path(tempfile.gettempdir()).resolve() + expected = candidate if len(str(candidate)) + len('/l.XXXXXXXX/lawci.XXXXXX') < 65 else ( + Path.home() / '.cache/law-ci-tests').resolve() + if layout != 'default': + monkeypatch.setattr(tempfile, 'gettempdir', lambda: str(candidate)) + selected = short_temp_root() + assert selected == expected + assert selected == selected.resolve() + assert all(not component.is_symlink() for component in (selected, *selected.parents)) + with tempfile.TemporaryDirectory(prefix='l.', dir=selected) as temp: + assert len(str(Path(temp) / 'lawci.XXXXXX')) < 65 + assert Path(temp).stat().st_uid == os.getuid() + assert Path(temp).stat().st_mode & 0o777 == 0o700 + + +@pytest.mark.parametrize('stop_failure', [False, True]) +def test_synthetic_child_failure_without_hermes_home(tmp_path, stop_failure): + """Run the actual two cleanup cases with an empty HOME and long runner temp.""" + home = tmp_path / 'home' + home.mkdir() + runner = tmp_path / ('runner-' + 'x' * 80) + runner.mkdir() + assert not (home / '.hermes').exists() + env = {**os.environ, 'HOME': str(home), 'TMPDIR': str(short_temp_root()), + 'RUNNER_TEMP': str(runner), 'PYTEST_DISABLE_PLUGIN_AUTOLOAD': '1'} + node = f'{__file__}::test_synthetic_child_failure_and_cleanup_are_not_success[{stop_failure}]' + result = subprocess.run([sys.executable, '-m', 'pytest', '-q', + '--basetemp', str(tmp_path / 'child'), node], + cwd=ROOT, env=env, capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + assert '1 passed' in result.stdout + assert not (home / '.hermes').exists() + assert not list(runner.iterdir()) + + +@pytest.mark.parametrize('stop_failure', [False, True]) +def test_synthetic_child_failure_and_cleanup_are_not_success(tmp_path, stop_failure): + """Synthetic tools prove exit propagation and cleanup; not real DB evidence.""" + tools = tmp_path / 'tools' + tools.mkdir() + scripts = { + 'git': '#!/bin/bash\nif [[ "$*" == *rev-parse* ]]; then printf "%s\\n" "$LAW_CI_SOURCE_SHA"; else tar -cf - --files-from /dev/null; fi\n', + 'uv': '#!/bin/bash\nif [[ "$1" == --version ]]; then printf "uv 0.12.5 synthetic-only\\n"; else exit 37; fi\n', + 'pg_config': '#!/bin/bash\nprintf "%s\\n" "$SYNTHETIC_TOOLS"\n', + 'initdb': '#!/bin/bash\nmkdir -p "$2"\n', + 'pg_ctl': '#!/bin/bash\nif [[ "$*" == *start* ]]; then touch "$2/postmaster.pid"; else printf "stop\\n" >> "$SYNTHETIC_TRACE"; if [[ "$SYNTHETIC_STOP_EXIT" == 0 ]]; then rm -f "$2/postmaster.pid"; fi; exit "$SYNTHETIC_STOP_EXIT"; fi\n', + 'createdb': '#!/bin/bash\nexit 0\n', + 'psql': '#!/bin/bash\nexit 0\n', + } + for name, content in scripts.items(): + path = tools / name + path.write_text(content) + path.chmod(0o700) + # Deep pytest roots exceed socket limits; choose a portable canonical base. + with tempfile.TemporaryDirectory(prefix='l.', dir=short_temp_root()) as temp: + sentinel = Path(temp) / 'keep' + sentinel.write_text('unrelated') + assert len(str(Path(temp) / 'lawci.XXXXXX')) < 65 + env = {**os.environ, 'PATH': str(tools) + os.pathsep + os.environ['PATH'], + 'LAW_CI_SOURCE': str(ROOT), 'LAW_CI_SOURCE_SHA': SHA, + 'LAW_CI_PYTHON_VERSION': '3.14', 'RUNNER_TEMP': temp, + 'SYNTHETIC_TOOLS': str(tools), 'SYNTHETIC_TRACE': str(tmp_path / 'trace'), + 'SYNTHETIC_STOP_EXIT': '9' if stop_failure else '0'} + result = helper('run', env=env) + assert result.returncode == 37 + assert (tmp_path / 'trace').read_text() == 'stop\n' + assert bool(list(Path(temp).glob('lawci.*'))) == stop_failure + if stop_failure: + env['SYNTHETIC_STOP_EXIT'] = '0' + assert helper('cleanup', env=env).returncode == 0 + assert sentinel.read_text() == 'unrelated' + assert list(Path(temp).iterdir()) == [sentinel] + + +@pytest.mark.parametrize('unsafe', ['nested', 'public', 'symlink', 'parent-symlink', 'public-marker']) +def test_cleanup_refuses_unowned_or_non_immediate_roots(tmp_path, unsafe): + """Hostile marker paths never grant recursive deletion authority.""" + runner = tmp_path / 'runner' + runner.mkdir(mode=0o700) + root = runner / 'lawci.keep' + root.mkdir(mode=0o700) + target = root + if unsafe == 'nested': + target = root / 'lawci.child' + target.mkdir(mode=0o700) + elif unsafe == 'public': + root.chmod(0o755) + elif unsafe == 'symlink': + target = runner / 'lawci.link' + target.symlink_to(root, target_is_directory=True) + elif unsafe == 'parent-symlink': + alias = tmp_path / 'alias' + alias.symlink_to(runner, target_is_directory=True) + runner = alias + target = alias / root.name + marker = runner / 'law-ci-state-3.14' + marker.write_text(str(target) + '\n') + marker.chmod(0o644 if unsafe == 'public-marker' else 0o600) + tools = tmp_path / 'tools' + tools.mkdir() + pg = tools / 'pg_config' + pg.write_text('#!/bin/bash\nprintf "%s\\n" "$SYNTHETIC_TOOLS"\n') + pg.chmod(0o700) + env = {**os.environ, 'RUNNER_TEMP': str(runner), 'SYNTHETIC_TOOLS': str(tools), + 'PATH': str(tools) + os.pathsep + os.environ['PATH']} + assert helper('cleanup', env=env).returncode != 0 + assert root.is_dir() + assert marker.is_file() + + +def test_run_installs_trap_before_state_and_verifies_actual_python_prefix(): + """State collisions preserve old markers and leave no newly owned root.""" + text = HELPER.read_text() + assert text.index('trap on_exit EXIT') < text.index('test ! -e "$state"') + assert 'state_owned=0' in text + assert 'sys.version_info[:2]' in text + assert 'Path(sys.prefix).resolve()' in text diff --git a/tests/test_law_runner_temp_whitespace.py b/tests/test_law_runner_temp_whitespace.py new file mode 100644 index 0000000000..37dbb02284 --- /dev/null +++ b/tests/test_law_runner_temp_whitespace.py @@ -0,0 +1,77 @@ +"""Regression seam for PostgreSQL startup paths containing spaces.""" +from __future__ import annotations + +import json +from pathlib import Path +import re +import shlex +import subprocess +import textwrap + + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / "scripts/ci/law_ai_agent_ci.sh" + + +def _run_pg_ctl_fragment(tmp_path: Path) -> list[str]: + """Execute the exact pg_ctl invocation from the production helper.""" + source = HELPER.read_text() + match = re.search( + r"(?m)^ (\"\$pg_bin/pg_ctl\".*?)(?=^ \"\$pg_bin/createdb\")", + source, + re.S, + ) + assert match, "production pg_ctl invocation not found" + pg_bin = tmp_path / "bin" + pg_bin.mkdir() + capture = tmp_path / "argv.json" + (pg_bin / "pg_ctl").write_text( + "#!/bin/sh\n" + "python3 -c 'import json,sys; print(json.dumps(sys.argv[1:]))' " + f'"$@" > {shlex.quote(str(capture))}\n' + ) + (pg_bin / "pg_ctl").chmod(0o700) + private = tmp_path / "runner temp" / "lawci.data" / "private" / "postgres" + private.mkdir(parents=True) + script = textwrap.dedent( + f""" + set -eu + pg_bin={shlex.quote(str(pg_bin))} + private={shlex.quote(str(private))} + LAW_AGENT_POSTGRES_PORT=55439 + scratch={shlex.quote(str(tmp_path / 'runner temp' / 'lawci.data'))} + {textwrap.dedent(match.group(1))} + """ + ) + result = subprocess.run(["bash", "-eu", "-c", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return json.loads(capture.read_text()) + + +def test_pg_ctl_preserves_spaced_socket_path(tmp_path): + """The server option parser receives one complete socket path token.""" + argv = _run_pg_ctl_fragment(tmp_path) + payload = argv[argv.index("-o") + 1] + parsed = shlex.split(payload) + assert parsed[parsed.index("-k") + 1] == str(tmp_path / "runner temp" / "lawci.data" / "private" / "postgres" / "socket") + + +def test_libpq_conninfo_preserves_spaced_socket_path(tmp_path): + """The consumer conninfo parser receives one complete host value.""" + source = HELPER.read_text() + line = next(line.strip() for line in source.splitlines() if "LAW_AGENT_TEST_DATABASE_URL=" in line) + private = tmp_path / "runner temp" / "lawci.data" / "private" / "postgres" + script = textwrap.dedent( + f""" + set -eu + private={shlex.quote(str(private))} + LAW_AGENT_POSTGRES_USER=runner + LAW_AGENT_POSTGRES_PORT=55439 + {line} + printf '%s' "$LAW_AGENT_TEST_DATABASE_URL" + """ + ) + result = subprocess.run(["bash", "-eu", "-c", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + fields = shlex.split(result.stdout) + assert fields[fields.index("port=55439") - 1] == "host=" + str(private / "socket")