diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000..dbd43187c2 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +# Source validation only; labels do not attest runtime isolation. +self-hosted-runner: + labels: + - cwlab-ci-isolated + - cwlab-control diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 356244f7fc..1be7ae0449 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -60,7 +60,9 @@ jobs: name: Detect CodeQL languages # Draft PRs get no runner; ready_for_review re-runs this on the same head. if: (github.event.action != 'closed') && (github.event.pull_request.draft != true || github.event.action == 'converted_to_draft' || github.event.action == 'closed') - runs-on: ${{ fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '"ubuntu-24.04"') }} + runs-on: + group: ${{ (fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '{"group":"CWL CI isolated","labels":["self-hosted","linux","x64","cwlab-ci-isolated"]}')).group }} + labels: ${{ (fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '{"group":"CWL CI isolated","labels":["self-hosted","linux","x64","cwlab-ci-isolated"]}')).labels }} timeout-minutes: 5 permissions: contents: read @@ -164,7 +166,9 @@ jobs: # dependency exactly; the only case where it's genuinely skipped is a # closed PR, where this job being implicitly skipped too is fine because # closed PRs need no required check. - runs-on: ${{ fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '"ubuntu-24.04"') }} + runs-on: + group: ${{ (fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '{"group":"CWL CI isolated","labels":["self-hosted","linux","x64","cwlab-ci-isolated"]}')).group }} + labels: ${{ (fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '{"group":"CWL CI isolated","labels":["self-hosted","linux","x64","cwlab-ci-isolated"]}')).labels }} # Verdict reads have exceeded five minutes; retain the ten-minute control budget. timeout-minutes: 10 permissions: @@ -367,7 +371,9 @@ jobs: && github.event.pull_request.state != 'closed' && needs.detect-languages.result == 'success' && needs.detect-languages.outputs.code == 'true' - runs-on: ${{ fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '"ubuntu-24.04"') }} + runs-on: + group: ${{ (fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '{"group":"CWL CI isolated","labels":["self-hosted","linux","x64","cwlab-ci-isolated"]}')).group }} + labels: ${{ (fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '{"group":"CWL CI isolated","labels":["self-hosted","linux","x64","cwlab-ci-isolated"]}')).labels }} timeout-minutes: 5 permissions: contents: read diff --git a/.github/workflows/package-description-boundary.yml b/.github/workflows/package-description-boundary.yml new file mode 100644 index 0000000000..8241893628 --- /dev/null +++ b/.github/workflows/package-description-boundary.yml @@ -0,0 +1,202 @@ +# Reusable gate (workflow_call) for what a package's registry page will show. +# +# A repository README is developer-facing; links to internal design records and +# working notes belong there. The same file becomes the package description on +# PyPI, npm, or crates.io, where only the distribution's own files exist and the +# reader is installing rather than developing. A repo-relative link that works on +# GitHub is a 404 on the registry page, and internal go-to-market framing, a +# hard-coded deal value, or a quoted module path is plumbing on a product page. +# +# Motivated by a live finding: fast-mlsirm 0.11.3's published description carried +# 68 of these, including a KRW-denominated readiness gate and 16 dead links. +# +# This checks the BUILT description (PKG-INFO from an sdist, METADATA from a +# wheel) rather than the README on disk, because that is what a registry renders. +# Repositories with no release yet can point `readme` at their README instead. +# +# The `on:` trigger stays in each calling repo: a workflow_call target cannot +# also be triggered directly. Pin `uses:` to this file's commit SHA, never @main. +# +# name: Package description boundary +# on: +# pull_request: +# jobs: +# package-description-boundary: +# uses: ContextualWisdomLab/.github/.github/workflows/package-description-boundary.yml@ +# with: +# build: sdist +# dist-path: dist +# +# Converting an existing standalone check to this renames the published context +# to " / package-description-boundary"; update branch protection in +# the same window (ADR 0024). +name: Package description boundary + +on: + workflow_call: + inputs: + build: + description: "What to build before checking: sdist, wheel, or none to check the README instead" + type: string + default: "sdist" + dist-path: + description: "Directory holding the built sdist or wheel" + type: string + default: "dist" + readme-path: + description: "README to check when no distribution is built yet" + type: string + default: "README.md" + python-version: + description: "CPython used to build the distribution" + type: string + default: "3.12" + allow: + description: "Space-separated rule names to report without failing, for a staged migration" + type: string + default: "" + +permissions: + contents: read + +jobs: + build-distribution: + # Caller-controlled build backends run only on the disposable isolated pool. + # Never fall back to a GitHub-hosted runner for untrusted package code. + runs-on: + group: CWL CI isolated + labels: [self-hosted, linux, x64, cwlab-ci-isolated] + timeout-minutes: 10 + steps: + - name: Check out the calling repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: .caller-source + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + if: ${{ inputs.build != 'none' }} + with: + python-version: ${{ inputs.python-version }} + - name: Set up pinned uv build frontend + if: ${{ inputs.build != 'none' }} + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + # `build` is a bounded choice, never a command. An earlier revision took a + # free-form `build-command` and interpolated it into this `run:` block, + # which is template injection (a caller could pass arbitrary shell) and is + # also what ADR 0023 forbids: reusable inputs are data and capability + # flags, not shell source. Semgrep's run-shell-injection rule caught it + # before this shipped. + - name: Build the distribution + if: ${{ inputs.build != 'none' }} + working-directory: .caller-source + env: + BUILD: ${{ inputs.build }} + DIST_PATH: ${{ inputs.dist-path }} + run: | + set -euo pipefail + case "$BUILD" in + sdist) target=--sdist ;; + wheel) target=--wheel ;; + *) echo "build must be one of: sdist, wheel, none (got: $BUILD)" >&2; exit 2 ;; + esac + uv build "$target" --out-dir "$DIST_PATH" + mkdir -p ../package-description-input/dist + find "$DIST_PATH" -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.whl' \) \ + -exec cp -- '{}' ../package-description-input/dist/ \; + - name: Stage README before a first release + if: ${{ inputs.build == 'none' }} + env: + README_PATH: ${{ inputs.readme-path }} + run: | + set -euo pipefail + caller_root="$(realpath .caller-source)" + readme_source="$(realpath -e ".caller-source/$README_PATH")" + case "$readme_source" in + "$caller_root"/*) ;; + *) echo "readme-path must stay inside the calling repository" >&2; exit 2 ;; + esac + mkdir -p package-description-input + cp -- "$readme_source" package-description-input/README.md + - name: Upload the immutable inspection input + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: package-description-input + path: package-description-input + if-no-files-found: error + retention-days: 1 + + package-description-boundary: + needs: build-distribution + if: ${{ always() }} + # The trusted inspector also stays on the isolated pool. Missing capacity + # must remain an incomplete check, never a hosted fallback or silent pass. + runs-on: + group: CWL CI isolated + labels: [self-hosted, linux, x64, cwlab-ci-isolated] + timeout-minutes: 10 + steps: + - name: Reject a failed input producer + env: + PRODUCER_RESULT: ${{ needs.build-distribution.result }} + run: | + set -euo pipefail + if [ "$PRODUCER_RESULT" != "success" ]; then + echo "package description input producer did not succeed: $PRODUCER_RESULT" >&2 + exit 2 + fi + - name: Validate called workflow identity + env: + WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} + WORKFLOW_SHA: ${{ job.workflow_sha }} + run: | + set -euo pipefail + if [ "$WORKFLOW_REPOSITORY" != "ContextualWisdomLab/.github" ]; then + echo "called workflow repository mismatch: $WORKFLOW_REPOSITORY" >&2 + exit 2 + fi + if [[ ! "$WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "called workflow SHA is not an exact commit: $WORKFLOW_SHA" >&2 + exit 2 + fi + - name: Check out the central gate + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .central-gate + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: ${{ inputs.python-version }} + - name: Download the immutable inspection input + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: package-description-input + path: package-description-input + - name: Check the description a registry will render + env: + BUILD: ${{ inputs.build }} + ALLOW: ${{ inputs.allow }} + run: | + set -euo pipefail + allow_args=() + for rule in $ALLOW; do allow_args+=(--allow "$rule"); done + if [ "$BUILD" = "none" ]; then + source_args=(--readme "package-description-input/README.md") + else + source_args=(--dist "package-description-input/dist") + fi + python -I .central-gate/scripts/ci/package_description_boundary.py \ + "${source_args[@]}" \ + --json package-description-boundary.json \ + "${allow_args[@]}" + - name: Upload the boundary report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: package-description-boundary + path: package-description-boundary.json + if-no-files-found: ignore diff --git a/CHANGELOG.d/20260920-strix-trusted-binder-runtime-fixture.md b/CHANGELOG.d/20260920-strix-trusted-binder-runtime-fixture.md new file mode 100644 index 0000000000..c0f4cd22c1 --- /dev/null +++ b/CHANGELOG.d/20260920-strix-trusted-binder-runtime-fixture.md @@ -0,0 +1,22 @@ +### Strix keeps trusted evidence binding outside consumer workspaces + +- The Strix gate resolves its evidence binder beside the trusted gate source. + The executable core harness now materializes that trusted runtime under a + separate source directory, passes a binder-free consumer workspace through + `STRIX_REPO_ROOT`, and invokes the trusted gate by its absolute path. +- OpenCode coverage assertions follow the consolidated + `validate-pr-metadata` owner instead of the removed + `coverage-source-tree` job and failure-report step. +- The commercial-readiness receipt contract now compares the complete parsed + harden-runner endpoint set instead of treating an expected hostname as a URL + substring. This closes the exact CodeQL + `py/incomplete-url-substring-sanitization` finding without suppressing it or + widening egress. +- The branch adopts the current central dependency owner, including the + explicit AnyIO 4.14.2 source-to-hash pin required by the Python security + gate. +- The package-description reusable workflow now isolates arbitrary caller build + backends in an artifact producer job. A fresh inspector job receives only the + staged distribution or README, checks out the exact central workflow source, + and runs the gate with Python isolated mode, so caller code cannot rewrite the + trusted gate or poison its interpreter before a GREEN verdict. diff --git a/CHANGELOG.d/20261002-strix-sandbox-lifecycle-diagnostics.md b/CHANGELOG.d/20261002-strix-sandbox-lifecycle-diagnostics.md new file mode 100644 index 0000000000..f9bc152c50 --- /dev/null +++ b/CHANGELOG.d/20261002-strix-sandbox-lifecycle-diagnostics.md @@ -0,0 +1,5 @@ +### 보안 검사 관측 + +- Guest 인증 경계에서 이미 존재하는 sandbox metadata만 제한된 형식으로 기록한다. 값은 cached 상태로 명시하며 민감한 본문이나 인증값은 기록하지 않는다. +- 모델·backend·ownership·cleanup·인증 결과·재시도·시간 제한은 변경하지 않는다. 진단이 지원되지 않거나 실패해도 원 scan 경로를 유지한다. +- 이 관측만으로 실제 Caido 장애 복구나 보안 검사 통과를 주장하지 않는다. [source·검증 한계](../docs/doctoring/strix-sandbox-lifecycle-diagnostics-20261002.md)를 참조한다. diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d7903f11..857aa98eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,10 @@ - The queue-health workflow contract now pins both workflow-level and collector-job permissions to exactly `contents: read` plus `actions: read`, rejecting scalar `read-all`/`write-all`, quoting/spacing variants, inline maps, and unexpected write scopes. +### Strix supplies bounded Job Analysis authority context from the trusted base + +- Orgmetra #63 changes `packages/hris-kernel/src/orgmetra_hris_kernel/job_analysis.py`, but the Strix scan workspace previously omitted the unchanged authorization, HTTP, snapshot, and persistence collaborators that establish its resource-ownership boundary. That incomplete context produced a false HIGH IDOR finding even though the product reconstructs owner scope and authorizes resource fields before port access. A source-first executable fixture now requires the changed PR-head module, exactly five unchanged Job Analysis authority files from the authenticated trusted base, and exclusion of an unrelated administration file. RED `1fd22f4e` failed because `auth.py` was absent; the gate now recognizes only the normalized Job Analysis trigger and adds the five fixed context paths through the existing trusted-base materialization boundary. No consumer source, provider/model policy, severity gate, timeout, or write authority changes. + ### OpenCode coverage image materializes every Dockerfile lock input - Required OpenCode run `35370902053` for `.github#2266@12621f75e` failed before executing PR code because its trusted Dockerfile copied `requirements-noema-document-ci-hashes.txt` while the isolated build context contained only the OpenCode lockfile. The coverage owner now validates both lockfiles as regular non-symlink files and copies both into the trusted build context before the networked image build. `tests/test_opencode_agent_contract.py` pins the complete input boundary. Hosted exact-head acceptance remains Proposed until the new run reaches the image-build and coverage steps. diff --git a/docs/doctoring/package-description-boundary.md b/docs/doctoring/package-description-boundary.md new file mode 100644 index 0000000000..fb6f52296f --- /dev/null +++ b/docs/doctoring/package-description-boundary.md @@ -0,0 +1,184 @@ +# The package description is not the repository README + +Date: 2026-09-18 + +## The finding + +`fast-mlsirm` 0.11.3's published PyPI description carried a `## Commercial +Readiness` section: an enterprise sales gate, a KRW 2,000,000,000 product gate, +buyer packet and procurement due-diligence links, and a transcript of the +internal evidence pipeline. Checking the live artifact rather than the README on +disk found 68 boundary findings in `fast_mlsirm-0.11.3.tar.gz`, including 16 +repo-relative links that are 404s on the registry page because only `LICENSE` +and the package sources ship in the distribution. + +Two other live packages were checked the same way: `threadweave` 0.1.0 and +`rankweave` 0.1.0 each carry 3 dead links, one of them pointing at a source +module path. `appguardrail` and `egressweave` carry dead links but no +vocabulary leak. + +## Why a README check is not enough + +The registry renders the description recorded in the built artifact — PKG-INFO +for an sdist, METADATA for a wheel — not the file in the repository. Those can +differ: a release is built from a commit, and packaging config decides what is +included. `scripts/ci/package_description_boundary.py` therefore reads the +built artifact and falls back to the README only for a repository that has not +released yet. + +## What is and is not a finding + +A repository README may link internal design records; that is public +development history and the organization publishes it deliberately. The same +text on a registry page is different, because only the distribution's files +exist there and the reader is installing rather than developing. + +Blocking mechanical defects: + +- **relative-link** — works on GitHub, 404 on the registry page. +- **mutable-release-link** — a published contract points at GitHub's moving + `main`, `master`, or `develop` branch instead of a release tag or exact + commit. +- **source-path** — a quoted module path tells the reader which file implements + a feature instead of what they can do with it. +- **monetary-target** — a hard-coded internal deal value. The organization + already decided this in `fast-mlsirm`'s + `docs/doctoring/acquisition_readiness_gate.md`: commercial readiness + verification may validate an explicitly supplied deal scenario, but product + quality evidence must not depend on a hard-coded monetary target. Its CLI + defaults `--contract-value-krw` to unset for that reason; a public package + page must follow the same rule. + +Advisory unless a repository opts into `--strict`: + +- **internal-working-record** — `docs/superpowers`, `docs/product`, + `docs/commercial`, `docs/planning`, `docs/doctoring`. These often hold + internal evidence, but some repositories intentionally keep public operator + guidance there. +- **go-to-market-vocabulary** and **requirement-map** — sales framing and + PRD/TRD implementation tables usually are internal artifacts, but can be + legitimate product-domain vocabulary. + +Deliberately not a finding: + +- **`docs/adr` links.** A public architecture decision record is legitimate to + advertise. It only has to be an absolute URL. +- **Product vocabulary that happens to look commercial.** `appguardrail` ships + a real `buyer-diligence` CLI subcommand and `scopeweave` really does check + procurement packages. Those belong in their documentation. The rule targets + internal framing, not a product's domain. + +## Why two of the rules only advise + +The first run against the three repositories that had just been corrected found +16, 1 and 2 remaining "findings" — and nearly all of them were wrong. + +`contextual-orchestrator` genuinely ships `/api/v1/commercial_readiness/latest`, +`/api/v1/saleability_decisions/latest` and `/api/v1/commercial_due_diligence_rooms/latest`, +with tests named after them. `wardnet`'s crate genuinely computes commercial +readiness snapshots. `semantic-data-portal`'s single finding was the sentence +that explains PRD/TRD records are excluded. The stated exception — product +domain vocabulary is not a leak — was in the prose but not in the regex. + +A gate that fires on a correct fix gets switched off the first week. So +`go-to-market-vocabulary` and `requirement-map` now report without failing, and +`--strict` promotes them for a repository that wants them enforced. The +mechanical rules still block, because they need no judgement: a relative link is +dead on the registry page, a quoted module path is plumbing, and a hard-coded +deal value is never a product feature. + +The same pass found ADRs filed under `docs/planning/adrs/` being flagged by the +`docs/planning/` prefix. An architecture decision record is a public design +record wherever a repository files it, so the path check now exempts it. + +Checked after the change: the live `fast_mlsirm-0.11.3.tar.gz` still reports 22 +blocking findings (63 with `--strict`), the corrected fast-mlsirm README is +clean, and the three corrected READMEs pass with advisory notes only. + +## The directory name cannot decide, either + +A second false positive came from real data. `pg-llm-batch`'s README links +`docs/doctoring/bootstrap-dsn-precedence.md`, `cli-secret-input.md` and +`postgres-logical-restore.md` — and those are operator documentation a package +user genuinely needs, not internal working notes. That repository files +operational guidance under the same directory name this one uses for incident +records. A worker removed those links to satisfy the gate, then restored them in +the next commit because they were legitimate, which is the gate causing damage +rather than preventing it. + +So `internal-working-record` advises too. Nothing is lost: a link into such a +directory that is ALSO repo-relative is still blocked, by `relative-link`, and +that is the mechanical defect — the page cannot resolve it. An absolute URL to +the same file resolves fine, and whether that audience wants it is judgement. + +The pattern across both corrections: block only what is broken regardless of +context, advise on anything that needs to know what the product is. + +## The gate's own first revision failed the org's SAST gate + +Worth recording, because it is the same class of mistake this document warns +about. The first revision of the reusable workflow took a free-form +`build-command` string input and interpolated it directly into a `run:` block. +That is template injection — a caller could pass arbitrary shell into a +workflow running in its own repository context — and it is exactly what ADR +0023 forbids: reusable inputs are data and capability flags, not shell source. + +The contract test for the fast-mlsirm reusable workflow asserts that no input +reaches a `run:` body. The same rule was not applied here, and the author did +not notice until Semgrep's `run-shell-injection` rule failed the PR. + +The input is now `build: sdist | wheel | none`, validated in-shell so an +unexpected value exits loudly, and a contract test pins both the absence of +`build-command` and the absence of any `${{ inputs.* }}` inside a `run:` block. + +## Staged adoption + +`--allow ` reports a rule without failing, so a repository mid-migration +can adopt the gate before its README is fully converted rather than landing a +red check it cannot fix in one PR. + +## Reusable-workflow and release-artifact identity + +The called workflow cannot use `github.workflow_sha` to retrieve its own source: +inside a reusable workflow the `github` context remains associated with the +caller. The gate therefore validates `job.workflow_repository` as +`ContextualWisdomLab/.github`, validates `job.workflow_sha` as a 40-hex commit, +and uses those two called-job fields for the central checkout. There is no +caller-SHA fallback. + +The build frontend is the exact-action-pinned `astral-sh/setup-uv` with an exact +uv version; the inherited workflow performs no unhashed runtime `pip install`. +The caller checkout sets `persist-credentials: false` before any project build +backend runs. A PEP 517 backend is nevertheless arbitrary caller code, so it +runs in a producer job that never checks out the central gate. That job uploads +only a fixed `package-description-input` artifact. A fresh consumer job checks +out the gate at the called workflow SHA, downloads the artifact into a fixed +path, and invokes the inspector with Python isolated mode (`-I`); it runs no +caller build backend, setup-uv step, or caller-controlled interpreter. When +`build` requests an sdist or wheel, a missing or empty staged artifact is an +upload error; only the explicit `build: none` mode stages the selected README, +so a failed artifact boundary cannot silently downgrade itself. +PyPA's source-distribution and wheel specifications identify the root +`{name}-{version}/PKG-INFO` and `{distribution}-{version}.dist-info/METADATA` +as the authoritative metadata locations. The gate requires exactly one such +root entry and rejects ambiguous archives instead of selecting by path depth or +ZIP order. When a dist directory contains both an sdist and wheel, all candidate +metadata descriptions must be identical or the gate fails closed. Published Markdown +links using GitHub's `blob/main`, `tree/main`, `master`, or `develop` forms are +blocking `mutable-release-link` findings; release tags and exact commits remain +valid. + +## Runner boundary + +Both jobs in `.github/workflows/package-description-boundary.yml` now require +`CWL CI isolated` with `self-hosted`, `linux`, `x64`, and +`cwlab-ci-isolated` labels. The producer executes caller-controlled packaging +code; the inspector executes the trusted central gate. Neither job has a +GitHub-hosted fallback. Missing isolated capacity therefore remains incomplete +hosted evidence and cannot become a silent pass. + +This is a package-gate slice of the broader all-workflow migration owned by +`ContextualWisdomLab/.github#2565`. It does not provision runners, widen group +access, or prove disposable cleanup. A real caller can be accepted only after +operator-provided group access, isolated capacity, cleanup canary, and fresh +exact-head terminal checks. diff --git a/docs/doctoring/strix-sandbox-lifecycle-diagnostics-20261002.md b/docs/doctoring/strix-sandbox-lifecycle-diagnostics-20261002.md new file mode 100644 index 0000000000..c52a454111 --- /dev/null +++ b/docs/doctoring/strix-sandbox-lifecycle-diagnostics-20261002.md @@ -0,0 +1,40 @@ +# Strix sandbox의 관측 전용 진단 + +## 확인된 실패와 형상 한계 + +PR #2261 run `36873538850`과 PR #2550 run `36872425586`은 gateway health·provider route·chat preflight를 통과한 뒤, sandbox 내부 `127.0.0.1:48080`의 Caido guest 인증에서 curl 7로 실패했다. 스캔 토큰과 취약점 수 0은 완료된 보안 검사 근거가 아니다. + +역사 로그는 `ghcr.io/usestrix/strix-sandbox:1.3.0` tag를 선택했다고 기록하지만 실제 image identity와 lifecycle 상태는 제공하지 않는다. Git v1.3.0 entrypoint에는 runtime `chown`이 없지만 후속 공개 OCI 조회의 현재 tag 1.3.0에는 Git v1.5.3과 같은 조건부 UID/GID remapping·`chown`이 있다. 현재 amd64 manifest는 `sha256:e5e5d9927f15ca95ad49804ef7d22439771cd27378f400da6edd47556799baff`다. Mutable OCI tag 이름을 같은 이름의 Git tag source와 결합하면 잘못된 결론이 된다. 현재 OCI 내용도 역사 실행과의 동일성을 증명하지 않아 `chown`이나 entrypoint 누락을 실제 원인으로 귀속하지 않는다. + +## 최소 관측 계약 + +기존 version-gated inference launcher를 재사용한다. 모델·provider·secret·workflow·이미지·backend factory·session ownership·cleanup·retry·deadline·guest 인증 결과는 변경하지 않는다. 기존 Strix 1.5.3 inference version gate는 유지한다. 관측은 그 초기화가 끝난 뒤 설치하며 `load_settings()`를 추가 호출하지 않는다. + +추가 진단만 SDK 0.19.4 및 원래 guest 함수 ABI에 결속한다. Version·import·ABI가 지원되지 않으면 `STRIX_SANDBOX_DIAGNOSTICS unavailable`라는 고정 marker만 기록하고 원 scan 경로를 유지한다. 이 marker는 승인이나 실패 판정을 대체하지 않는다. + +인증 시작·성공·실패 때 검증된 Docker session의 `_inner` 타입, container object ID, state의 container ID와 이미 존재하는 Docker attributes의 ID가 일치할 때만 다음 값을 보존한다. + +- `phase`: 세 개의 고정 authentication 단계 +- `freshness=cached`: 값은 이미 존재한 SDK-owned cache다. 현재 Docker 상태라고 주장하지 않는다. +- `image_config_id`: container inspect의 `Image`가 정확히 `sha256:` + 소문자 64 hex인 경우만 보존한다. Registry manifest digest나 공개 source commit이 아니다. +- `status`: 고정 Docker lifecycle enum +- `exit_code`: bool이 아닌 0~255 정수 +- `oom_killed`: bool + +`reload()`·daemon·image lookup·readiness probe·settings 읽기는 전혀 추가하지 않는다. Binding 불일치, malformed cache, 접근 실패는 `` 필드로 기록한다. Non-Docker session의 private Docker 속성은 건드리지 않는다. 진단 출력 실패도 원 인증 return·exception·cancellation을 변경하지 않는다. Cached terminal 값에서도 원 인증을 생략하지 않는다. 별도 delete·backend wrapper·weak registry는 없다. + +Container ID·이름·경로·환경값·raw container log·daemon error·guest token·provider body는 출력하지 않는다. 관측값은 운영 증거일 뿐 스캔 결과가 아니다. + +## 실제 검증 + +Test fixture는 hash-pinned Strix wheel의 `_login_as_guest` 함수 그대로이며 함수 SHA-256은 `4e23ddba5879b791440169980433f83b17f1046892d4ee6d2496f27a56853d1c`다. Apache-2.0 원문·attribution을 보존한다. Clock과 curl I/O만 합성하며 validation 결과를 mock하지 않는다. + +현재 관측 전용 변경은 관련 tests **43 passed**, 두 helper statement·branch coverage **100%**, docstring **100%**, diff check PASS다. 이 값은 축소 전 73/89개 테스트 결과와 별개다. 정상 token·terminal cache에서 원 auth 실행, 실제 token parser 오류·동일 retry 수, 원 예외/cancellation 객체, cache mismatch/접근 실패, 출력 실패, unsupported version/import/ABI의 비간섭과 backend/settings 미변경을 검증했다. + +모델·컨테이너·GitHub API를 실행하지 않았으며 역사 CI 원인이나 장애 복구를 증명하지 않는다. Cache의 freshness 한계 때문에 fresh lifecycle 증명이 필요하면 별도 검토된 실행 진단으로 확보해야 한다. 이 변경으로 필수 실패를 성공으로 바꾸거나 보안 검사를 면제하지 않는다. + +## 확인한 package source + +- `strix-agent` 1.5.3 wheel SHA-256: `ba0b6b13f13f41e45f3eb4dba515641d1bc71363ca6e758d0cd05c20ff56b6ea`, Apache-2.0 +- `openai-agents` 0.19.4 wheel SHA-256: `12e0372fae9698fe6f78e05aaeb4ccdb229602f7ef99b8195a7d68dc82869f51`, MIT +- SDK source의 `DockerSandboxSession._container`, `container_id`, facade `_inner` binding을 확인했다. 일반 최신 문서는 보조 근거이며 pinned source를 대체하지 않는다. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ac32ad2877..adb0c94cd4 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3478,6 +3478,73 @@ alone -- it is a documented multi-PR hot-file collision zone. Contract: **Evidence / remaining condition.** The standalone fixture mechanism was executed locally against Python stdlib and produced one canonical request followed by terminal HTTP 302 for every hostile target. This is mechanism evidence, not repository acceptance. Final authority requires focused/full exact-tree GREEN, fresh exact-head Security/SAST/Python Security/CodeQL/runtime-quality checks, no unresolved actionable review, ordinary protected-main integration, and downstream consumer validation. No scanner suppression, redirect allowlist widening, provider fallback, workflow gate weakening, or credential-boundary change is included. +## 2026-09-20 Strix trusted-binder consumer-isolation gap + +**Status:** Proposed on `ContextualWisdomLab/.github#2291`; exact-head hosted +checks, independent review, and protected-main integration remain required. + +**Context Map / owner.** The central `.github` CI bounded context owns +`strix_quick_gate.sh`, its evidence binder, and the executable gate harness. +Consumer repositories supply only the scan workspace through +`STRIX_REPO_ROOT`; they do not copy or own the binder. + +**Gap / root cause.** The production gate incorrectly resolved the trusted +binder from the consumer root. The first repair correctly moved that lookup to +`SCRIPT_DIR`, but its test harness copied only the gate and model helper into +the isolated fixture. The current PR head therefore still reproduced the same +missing-binder exit in the `success` scenario. Three assertions in that harness +also described the removed standalone `coverage-source-tree` job after its +responsibility moved into `validate-pr-metadata`. + +**Action / evidence.** The production gate resolves +`strix_evidence_binding.py` beside its trusted source. RED `191bd630` +requires the generic executable consumer fixture to contain no binder. GREEN +`ef1a8667` materializes the gate, model helper, and binder under a separate +`trusted-source/scripts/ci` directory, passes only the binder-free consumer +workspace through `STRIX_REPO_ROOT`, and invokes the trusted gate by its +absolute path. This makes the core executable fixture reproduce the production +owner boundary instead of proving a co-located copy. The full exact-tree Strix +harness and hosted checks remain the release authority; no provider, model, +timeout, severity, or consumer ownership boundary changes. + +**2026-09-26 exact-head RCA / owner integration.** Exact Python-security job +`107750961662` on head `1794626af3473ef23b9c2e678c3f06fd6c11636f` +found AnyIO 4.14.0's CVE-2026-63374, CVE-2026-64847, and CVE-2026-63349 in +`requirements-strix-ci-hashes.txt`; this branch had not adopted the central +source-to-hash AnyIO 4.14.2 repair from `ContextualWisdomLab/.github#2385`. +Exact CodeQL dispatch run `36204821293`, Python job `108319933572`, separately +produced one Medium+ SARIF result: +`py/incomplete-url-substring-sanitization` at +`tests/test_organization_commercial_readiness_loop_receipt_contract.py:60`. +The receipt test parsed the complete YAML endpoint block but then expressed the +expected receiver hostname through a subset/membership-style assertion that +CodeQL correctly rejects on URL-security surfaces. The ordinary two-parent +owner integration adopts #2385's AnyIO contract; the test now compares the +complete seven-entry endpoint set exactly. This strengthens the egress oracle: +an unexpected endpoint fails rather than being tolerated. No CodeQL query, +severity, SARIF gate, dependency audit, or endpoint allowlist is suppressed or +widened. Fresh exact-head hosted Python Security and CodeQL remain mandatory. + +**2026-09-27 Job Analysis bounded-context repair.** Orgmetra #63 exact head +`d88800a5ca3ca15df332e8def5e25064c46e4005` changes the HRIS-kernel Job +Analysis aggregate module, while the trusted scan workspace previously omitted +the unchanged product-owned authority context that explains its ownership +checks. Strix consequently reported a HIGH IDOR finding against an incomplete +workspace even though the Job Analysis API reconstructs the canonical owner and +authorizes resource fields before snapshot or PostgreSQL port access. Source- +first RED `1fd22f4e1e86d0ebfe5dab932697e95593c9ad10` adds an executable +pull-request-target fixture whose fake scanner refuses to run unless the changed +PR-head `job_analysis.py` is accompanied by exactly the five fixed trusted-base +collaborators (`auth.py`, `authorization.py`, `http.py`, `postgres.py`, and +`snapshot.py`); it also proves an unrelated administration module is excluded. +The minimal GREEN recognizes only that normalized trigger and emits those five +paths through the existing trusted-base context materializer. This is a bounded +CI-context repair, not a transfer of product domain truth: no Orgmetra source, +authorization order, persistence boundary, model/provider policy, severity, +timeout, or write capability changes. Exact-head hosted Strix acceptance, +independent review, ordinary protected-main integration, and a fresh Orgmetra +#63 consumer run remain mandatory before the false-positive gap is complete. + ## 2026-09-27 exact release distribution/scope evidence coverage **Status:** Proposed on `ContextualWisdomLab/.github#2400`; the current diff --git a/scripts/ci/package_description_boundary.py b/scripts/ci/package_description_boundary.py new file mode 100644 index 0000000000..2917903973 --- /dev/null +++ b/scripts/ci/package_description_boundary.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Check what a package's description will look like on the registry page. + +A repository README is developer-facing: links to internal design records and +working notes are legitimate there. The same file becomes the package +description on PyPI, npm, or crates.io, where two things change: + +* Only the distribution's own files exist. ``docs/``, ``scripts/`` and + ``examples/`` are normally not shipped, so a repo-relative link that works on + GitHub is a 404 on the registry page. +* The audience is someone installing the package, not someone developing it. + Internal go-to-market framing, a hard-coded internal deal value, or a quoted + module path is implementation and commercial plumbing leaking into a public + product page. + +This reads the *built* description rather than the README on disk: for Python +it parses the sdist's PKG-INFO, which is what PyPI actually renders. Falling +back to the README is allowed only when no distribution is supplied. + +Exit status is 1 when a blocking finding is present, so a CI job can gate on it. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tarfile +import zipfile +from dataclasses import dataclass, field +from email.parser import Parser +from pathlib import Path + +# Links a registry page cannot resolve. Absolute URLs, anchors and mailto are fine. +_RELATIVE_LINK = re.compile(r"\[[^\]]*\]\((?!https?://|#|mailto:|data:)([^)\s]+)") + +# A built package is immutable, so its release-contract links must not follow a +# moving GitHub branch. Exact commit and release-tag links remain allowed. +_MUTABLE_GITHUB_LINK = re.compile( + r"\[[^\]]*\]\((https://github\.com/[^/\s)]+/[^/\s)]+/" + r"(?:blob|tree)/(?:main|master|develop)(?:/[^)\s]*)?)\)", + re.IGNORECASE, +) + +# Internal working records. docs/adr is deliberately absent: a public design +# record is legitimate to advertise, it just has to be an absolute URL. +_INTERNAL_DOCS = re.compile(r"docs/(?:superpowers|product|commercial|planning|doctoring)/") + +# What these directories hold is not consistent across the organization. The +# central repository files operational incident records under docs/doctoring/; +# pg-llm-batch files operator documentation there ("Bootstrap source +# precedence", "cli-secret-input") that a package user genuinely needs. So the +# directory name alone cannot decide, and this rule advises rather than blocks. +# A link into one of these that is also repo-relative is still caught, and +# blocked, by the relative-link rule, which is the mechanical defect. +# +# An architecture decision record is a public design record wherever it is filed, +# including under docs/planning/adrs/. Only its link has to be absolute. +_ADR_PATH = re.compile(r"/adrs?[/-]", re.IGNORECASE) + +# A quoted source path tells the reader which file implements the feature, +# which is plumbing rather than a job they can do. +_SOURCE_PATH = re.compile( + r"`(?:src|python|scripts|crates|lib|app|internal|pkg)/[\w./{}-]+" + r"\.(?:py|rs|ts|tsx|js|go|kt|java|rb)`" +) + +# A hard-coded internal deal value has no meaning to someone installing a package. +_MONETARY_TARGET = re.compile(r"KRW\s*[\d,]{7,}|\b\d+B KRW\b|\b2,000,000,000\b") + +_GTM_VOCAB = re.compile( + r"commercial readiness|sales.readiness|enterprise sales|buyer (?:packet|evidence|demo|handoff)" + r"|procurement (?:readiness|evidence)|due.dilig|PR queue governance|ROI evidence|saleability", + re.IGNORECASE, +) + +_REQUIREMENT_MAP = re.compile(r"PRD/TRD|implementation-compliance|requirements? traceability", re.I) + + +# Mechanical rules block: a relative link is dead, a module path is plumbing, a +# hard-coded deal value is never a product feature. The remaining two need human +# judgement - a product whose domain IS commercial readiness will name its own +# endpoints and tests that way - so they are advisory unless --strict is passed. +_ADVISORY_RULES = frozenset( + {"go-to-market-vocabulary", "requirement-map", "internal-working-record"} +) + + +@dataclass +class Finding: + """One boundary problem in a published package description.""" + + rule: str + detail: str + blocking: bool = True + + +@dataclass +class Report: + """Everything found in one package description.""" + + source: str + characters: int + findings: list[Finding] = field(default_factory=list) + + @property + def blocking(self) -> list[Finding]: + """Findings that should fail the gate.""" + return [f for f in self.findings if f.blocking] + + def as_dict(self) -> dict[str, object]: + """Machine-readable form for a CI job summary.""" + return { + "source": self.source, + "characters": self.characters, + "status": "failed" if self.blocking else "ok", + "findings": [ + {"rule": f.rule, "detail": f.detail, "blocking": f.blocking} + for f in self.findings + ], + } + + +def description_from_sdist(path: Path) -> str: + """Return the long description PyPI will render for a Python sdist.""" + with tarfile.open(path, "r:*") as archive: + members = [ + member + for member in archive.getmembers() + if member.isfile() + and member.name.count("/") == 1 + and member.name.endswith("/PKG-INFO") + ] + if len(members) != 1: + raise ValueError( + f"{path.name} must contain exactly one root PKG-INFO; " + f"found {len(members)}" + ) + member = members[0] + handle = archive.extractfile(member) + if handle is None: + raise ValueError(f"{path.name} PKG-INFO is not a regular file") + parsed = Parser().parsestr(handle.read().decode("utf-8", "replace")) + body = parsed.get_payload() + return body if body.strip() else (parsed.get("Description") or "") + + +def description_from_wheel(path: Path) -> str: + """Return the long description recorded in a built wheel's METADATA.""" + with zipfile.ZipFile(path) as archive: + names = [ + name + for name in archive.namelist() + if name.count("/") == 1 and name.endswith(".dist-info/METADATA") + ] + if len(names) != 1: + raise ValueError( + f"{path.name} must contain exactly one root .dist-info/METADATA; " + f"found {len(names)}" + ) + parsed = Parser().parsestr(archive.read(names[0]).decode("utf-8", "replace")) + body = parsed.get_payload() + return body if body.strip() else (parsed.get("Description") or "") + + +def load_description(dist: Path | None, readme: Path | None) -> tuple[str, str]: + """Return ``(description, source label)`` from a distribution or a README.""" + if dist is not None: + if dist.is_dir(): + candidates = sorted(dist.glob("*.tar.gz")) + sorted(dist.glob("*.whl")) + if not candidates: + raise ValueError(f"{dist} holds no sdist or wheel") + descriptions = [ + description_from_wheel(candidate) + if candidate.suffix == ".whl" + else description_from_sdist(candidate) + for candidate in candidates + ] + if any(description != descriptions[0] for description in descriptions[1:]): + names = ", ".join(candidate.name for candidate in candidates) + raise ValueError( + f"distribution descriptions differ across upload artifacts: {names}" + ) + return descriptions[0], ", ".join( + candidate.name for candidate in candidates + ) + if dist.suffix == ".whl": + return description_from_wheel(dist), dist.name + return description_from_sdist(dist), dist.name + if readme is None: + raise ValueError("pass --dist or --readme") + return readme.read_text(encoding="utf-8"), readme.name + + +def inspect(description: str) -> Report: + """Collect every boundary finding in one description.""" + report = Report(source="", characters=len(description)) + + for link in _RELATIVE_LINK.findall(description): + report.findings.append( + Finding( + "relative-link", + f"{link} does not resolve on a registry page; use an absolute URL", + ) + ) + for link in _MUTABLE_GITHUB_LINK.findall(description): + report.findings.append( + Finding( + "mutable-release-link", + f"{link} follows a moving branch; pin a release tag or exact commit", + ) + ) + for match in _INTERNAL_DOCS.finditer(description): + line = _line_at(description, match.start()) + if _ADR_PATH.search(description[match.start() : match.start() + 120]): + continue + report.findings.append(Finding("internal-working-record", line, blocking=False)) + for match in _SOURCE_PATH.finditer(description): + report.findings.append(Finding("source-path", match.group(0))) + for match in _MONETARY_TARGET.finditer(description): + report.findings.append(Finding("monetary-target", _line_at(description, match.start()))) + for match in _GTM_VOCAB.finditer(description): + report.findings.append( + Finding("go-to-market-vocabulary", _line_at(description, match.start()), blocking=False) + ) + for match in _REQUIREMENT_MAP.finditer(description): + report.findings.append( + Finding("requirement-map", _line_at(description, match.start()), blocking=False) + ) + return report + + +def _line_at(text: str, index: int) -> str: + """Return the trimmed source line containing ``index``.""" + start = text.rfind("\n", 0, index) + 1 + end = text.find("\n", index) + return text[start : end if end != -1 else len(text)].strip()[:200] + + +def build_parser() -> argparse.ArgumentParser: + """Command-line contract for the boundary gate.""" + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--dist", type=Path, help="built sdist/wheel, or a dist directory") + source.add_argument("--readme", type=Path, help="README to check when no distribution exists") + parser.add_argument("--json", type=Path, help="write the machine-readable report here") + parser.add_argument( + "--allow", + action="append", + default=[], + choices=sorted({"relative-link", "mutable-release-link", "internal-working-record", + "source-path", "monetary-target", "go-to-market-vocabulary", + "requirement-map"}), + help="report this rule without failing (repeatable)", + ) + parser.add_argument( + "--strict", + action="store_true", + help="also fail on the advisory rules that normally need human judgement", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run the gate and return a process exit status.""" + args = build_parser().parse_args(argv) + try: + description, source = load_description(args.dist, args.readme) + except (OSError, ValueError) as error: + print(f"package description boundary: {error}", file=sys.stderr) + return 2 + + report = inspect(description) + report.source = source + for finding in report.findings: + if args.strict and finding.rule in _ADVISORY_RULES: + finding.blocking = True + if finding.rule in args.allow: + finding.blocking = False + + if args.json: + args.json.write_text(json.dumps(report.as_dict(), indent=2), encoding="utf-8") + + if not report.findings: + print(f"package description boundary: {source} is clean ({report.characters} chars)") + return 0 + + for finding in report.findings: + marker = "FAIL" if finding.blocking else "warn" + print(f"{marker} [{finding.rule}] {finding.detail}") + blocking = len(report.blocking) + print(f"\n{source}: {len(report.findings)} finding(s), {blocking} blocking") + return 1 if blocking else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index cb97fd8eb8..f22ec5c1cf 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -1384,6 +1384,7 @@ pull_request_scope_context_files() { local needs_backend_app_python=0 local needs_contextual_orchestrator_python=0 local needs_frontend_email_api_context=0 + local needs_orgmetra_job_analysis_authority_context=0 local needs_deployment_context=0 local changed_file normalized_changed_file for changed_file in "$@"; do @@ -1400,6 +1401,9 @@ pull_request_scope_context_files() { contextual_orchestrator/*.py) needs_contextual_orchestrator_python=1 ;; + packages/hris-kernel/src/orgmetra_hris_kernel/job_analysis.py) + needs_orgmetra_job_analysis_authority_context=1 + ;; # The app shell, email components, threading URL builder, and API client can # shape frontend email retrieval flows; include backend auth context with them. frontend/src/components/EmailDetail.tsx | frontend/src/components/EmailList.tsx | frontend/src/app/page.tsx | frontend/src/lib/api-client.ts | frontend/src/lib/email-threading.ts) @@ -1549,6 +1553,16 @@ backend/services/threading_service.py EOF fi + if [ "$needs_orgmetra_job_analysis_authority_context" -eq 1 ]; then + cat <<'EOF' +services/job-analysis-api/src/orgmetra_job_analysis_api/auth.py +services/job-analysis-api/src/orgmetra_job_analysis_api/authorization.py +services/job-analysis-api/src/orgmetra_job_analysis_api/http.py +services/job-analysis-api/src/orgmetra_job_analysis_api/postgres.py +services/job-analysis-api/src/orgmetra_job_analysis_api/snapshot.py +EOF + fi + if [ "$needs_deployment_context" -eq 1 ]; then cat <<'EOF' Dockerfile diff --git a/scripts/ci/strix_timeout_compat.py b/scripts/ci/strix_timeout_compat.py index 7ddb290654..2a18975873 100755 --- a/scripts/ci/strix_timeout_compat.py +++ b/scripts/ci/strix_timeout_compat.py @@ -11,15 +11,21 @@ from __future__ import annotations import importlib.metadata +import inspect +import re import os import sys -from collections.abc import Awaitable, MutableMapping +from collections.abc import Awaitable, Mapping, MutableMapping from functools import wraps from typing import Any SUPPORTED_VERSION = "1.5.3" STRIX_DISTRIBUTION = "strix-agent" +SUPPORTED_SANDBOX_SDK_VERSION = "0.19.4" +_OMITTED = "" +_SANDBOX_PHASES = frozenset({"authentication_start", "authentication_success", "authentication_failure"}) +_SANDBOX_STATES = frozenset({"created", "running", "paused", "restarting", "exited", "removing", "dead"}) def normalize_inference_timeout_environment(environment: MutableMapping[str, str]) -> None: @@ -58,6 +64,89 @@ def _require_supported_version() -> None: ) +def emit_sandbox_diagnostic(session: Any, phase: str, docker_session_type: type | None) -> None: + """Read already cached bound Docker metadata without I/O or authentication interference.""" + if type(phase) is not str or phase not in _SANDBOX_PHASES: + return + fields = (_OMITTED, _OMITTED, _OMITTED, _OMITTED) + try: + inner = getattr(session, "_inner", None) + if docker_session_type is not None and isinstance(inner, docker_session_type): + container = inner._container + identity = inner.container_id + if isinstance(identity, str) and re.fullmatch(r"[0-9a-f]{64}", identity) and container.id == identity: + # attrs is the SDK-owned Docker object's already available cache. + # No reload, daemon request, image lookup or readiness probe occurs. + attrs = container.attrs + if isinstance(attrs, Mapping) and attrs.get("Id") == identity: + state = attrs.get("State") + state = state if isinstance(state, Mapping) else {} + image = attrs.get("Image") + image = image if isinstance(image, str) and re.fullmatch(r"sha256:[0-9a-f]{64}", image) else _OMITTED + status = state.get("Status") + status = status if isinstance(status, str) and status in _SANDBOX_STATES else _OMITTED + exit_code = state.get("ExitCode") + exit_code = str(exit_code) if type(exit_code) is int and 0 <= exit_code <= 255 else _OMITTED + oom = state.get("OOMKilled") + oom = str(oom).lower() if type(oom) is bool else _OMITTED + fields = (image, status, exit_code, oom) + except BaseException: + # Cache inspection is best effort, never a new scan verdict. + pass + try: + image, status, exit_code, oom = fields + print(f"STRIX_SANDBOX_STATE phase={phase} freshness=cached image_config_id={image} status={status} " + f"exit_code={exit_code} oom_killed={oom}", file=sys.stderr) + except BaseException: + # Output failure must not replace the original authentication result. + pass + + +def wrap_sandbox_authentication(original: Any, docker_session_type: type) -> Any: + """Observe authentication while preserving the exact call, return and exception objects.""" + @wraps(original) + async def observed_login(session: Any, *, container_url: str, attempts: int = 10) -> str: + """Observe only cached metadata around the unmodified guest-token operation.""" + emit_sandbox_diagnostic(session, "authentication_start", docker_session_type) + try: + token = await original(session, container_url=container_url, attempts=attempts) + except BaseException: + emit_sandbox_diagnostic(session, "authentication_failure", docker_session_type) + raise + emit_sandbox_diagnostic(session, "authentication_success", docker_session_type) + return token + return observed_login + + +def install_sandbox_diagnostics() -> None: + """Optional observations cannot impose new runtime version or ABI requirements.""" + try: + if importlib.metadata.version("openai-agents") != SUPPORTED_SANDBOX_SDK_VERSION: + raise ValueError("unsupported SDK") + from agents.sandbox.sandboxes.docker import DockerSandboxSession + from strix.runtime import caido_bootstrap + + original = caido_bootstrap._login_as_guest + parameters = inspect.signature(original).parameters + if (tuple(parameters) != ("session", "container_url", "attempts") + or parameters["session"].kind is not inspect.Parameter.POSITIONAL_OR_KEYWORD + or parameters["session"].default is not inspect.Parameter.empty + or parameters["container_url"].kind is not inspect.Parameter.KEYWORD_ONLY + or parameters["container_url"].default is not inspect.Parameter.empty + or parameters["attempts"].kind is not inspect.Parameter.KEYWORD_ONLY + or parameters["attempts"].default != 10 + or not inspect.iscoroutinefunction(original)): + raise ValueError("unsupported ABI") + observed = wrap_sandbox_authentication(original, DockerSandboxSession) + except BaseException: + try: + print("STRIX_SANDBOX_DIAGNOSTICS unavailable", file=sys.stderr) + except BaseException: + pass + return + caido_bootstrap._login_as_guest = observed + + def install_runtime_compatibility() -> Any: """Install narrowly scoped model-timeout compatibility and return Strix main.""" _require_supported_version() @@ -98,6 +187,7 @@ def make_model_settings_without_request_deadline(*args: Any, **kwargs: Any) -> A strix_main = sys.modules["strix.interface.main"] strix_main.asyncio = UnboundedInferenceAsyncio(strix_main.asyncio) + install_sandbox_diagnostics() return strix_main diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index e53741bd51..0a6edab599 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -505,6 +505,8 @@ assert_changed_file_membership_uses_cached_normalized_paths() { assert_strix_evidence_binding_contract() { assert_file_contains "$GATE_SCRIPT" "sanitize_remediation_evidence_claims" "strix gate sanitizes false already-applied remediation claims" assert_file_contains "$GATE_SCRIPT" 'scripts/ci/strix_evidence_binding.py' "strix gate binds remediation evidence through the tested Python binder" + assert_file_contains "$GATE_SCRIPT" 'local binder="$SCRIPT_DIR/strix_evidence_binding.py"' "strix gate resolves its trusted evidence binder from the central script directory" + assert_file_not_contains "$GATE_SCRIPT" 'local binder="$REPO_ROOT/scripts/ci/strix_evidence_binding.py"' "strix gate never resolves the trusted binder from the consumer repository root" assert_file_contains "$GATE_SCRIPT" "evidence_scope=pr_delta" "strix gate labels PR-delta findings with authenticated provenance" assert_file_contains "$GATE_SCRIPT" "evidence_scope=repository_baseline" "strix gate labels unchanged-path findings as repository_baseline" assert_file_contains "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" 'PR_DELTA = "pr_delta"' "strix evidence binder defines pr_delta scope" @@ -988,6 +990,8 @@ assert_opencode_review_uses_codegraph_and_contextual_orchestrator() { assert_file_contains "$workflow_file" "Exchange OpenCode app token for target repository coverage reads" "coverage source materialization can read private target repositories during central manual dispatch" assert_file_contains "$workflow_file" "Upload materialized pull request merge tree" "coverage source materialization passes only a prepared merge tree artifact to the PR-head coverage job" assert_file_contains "$workflow_file" "Download materialized pull request merge tree" "coverage evidence consumes the prepared merge tree artifact without target-repository credentials" + assert_file_contains "$workflow_file" "Coverage fetch could not authenticate" "coverage source materialization reports target-repository read failures" + assert_file_contains "$workflow_file" "Coverage merge tree could not be materialized" "coverage source materialization reports merge failures" assert_file_contains "$workflow_file" "needs.validate-pr-metadata.result == 'success'" "coverage evidence requires successful source materialization in the admission job" local coverage_merge_tree_step coverage_merge_tree_step="$( @@ -3318,6 +3322,9 @@ run_gate_case() { cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" fi + if [ -e "$repo_root_dir/scripts/ci/strix_evidence_binding.py" ]; then + record_failure "scenario=$scenario consumer fixture must not own the trusted evidence binder" + fi local fake_strix="$bin_dir/strix" local path_hijack_log="$tmp_dir/path-hijack.log" cat >"$untrusted_bin_dir/strix" <<'EOF' @@ -5830,6 +5837,7 @@ PY STRIX_EXECUTABLE_PATH="$fake_strix" FAKE_STRIX_PATH_HIJACK_LOG="$path_hijack_log" STRIX_INPUT_FILE_ROOT="$tmp_dir" + STRIX_REPO_ROOT="$repo_root_dir" GITHUB_EVENT_NAME="" GITHUB_EVENT_PATH="" FAKE_STRIX_SCENARIO="$scenario" @@ -7002,6 +7010,9 @@ run_filtered_gate_case_if_requested() { "1" \ "Container build manifest changed; materialized full PR-head blob scope" ;; + pull-request-target-job-analysis-authority-context) + run_pull_request_target_job_analysis_authority_context_scope_case + ;; repository-dispatch-pr-scope-uses-head-blob) run_pull_request_target_head_scope_case \ "repository-dispatch-pr-scope-uses-head-blob" \ @@ -8092,6 +8103,143 @@ EOF rm -rf "$tmp_dir" } +run_pull_request_target_job_analysis_authority_context_scope_case() { + local changed_file="packages/hris-kernel/src/orgmetra_hris_kernel/job_analysis.py" + local case_name="pull-request-target-job-analysis-authority-context" + local tmp_dir + tmp_dir="$(mktemp -d)" + local bin_dir="$tmp_dir/bin" + local repo_root_dir="$tmp_dir/repo" + mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" + local trusted_script_dir="$tmp_dir/trusted-source/scripts/ci" + materialize_trusted_gate_fixture "$trusted_script_dir" + + local context_files=( + "services/job-analysis-api/src/orgmetra_job_analysis_api/auth.py" + "services/job-analysis-api/src/orgmetra_job_analysis_api/authorization.py" + "services/job-analysis-api/src/orgmetra_job_analysis_api/http.py" + "services/job-analysis-api/src/orgmetra_job_analysis_api/postgres.py" + "services/job-analysis-api/src/orgmetra_job_analysis_api/snapshot.py" + ) + local context_files_text + context_files_text="$(printf '%s\n' "${context_files[@]}")" + local fake_strix="$bin_dir/strix" + local output_log="$tmp_dir/output.log" + local strix_llm_file="$tmp_dir/strix_llm.txt" + local llm_api_key_file="$tmp_dir/llm_api_key.txt" + + cat >"$fake_strix" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +target_path="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-t" ] && [ "$#" -ge 2 ]; then + target_path="$2" + break + fi + shift +done + +changed_file="$target_path/${FAKE_STRIX_EXPECTED_CHANGED_FILE:?}" +if ! grep -Fq -- 'HEAD_JOB_ANALYSIS_KERNEL_SHOULD_BE_SCANNED' "$changed_file"; then + echo "Error: Job Analysis kernel PR-head content was not scanned" >&2 + exit 93 +fi + +while IFS= read -r context_file; do + [ -n "$context_file" ] || continue + context_path="$target_path/$context_file" + if [ ! -f "$context_path" ]; then + echo "Error: Job Analysis authorization context missing: $context_file" >&2 + exit 94 + fi + if ! grep -Fqx -- "BASE_JOB_ANALYSIS_AUTHORITY_CONTEXT:$context_file" "$context_path"; then + echo "Error: Job Analysis context did not use trusted base content: $context_file" >&2 + exit 95 + fi + if grep -Fq -- "HEAD_JOB_ANALYSIS_CONTEXT_SHOULD_NOT_BE_SCANNED:$context_file" "$context_path"; then + echo "Error: unchanged Job Analysis context leaked PR-head content: $context_file" >&2 + exit 96 + fi +done <<<"${FAKE_STRIX_EXPECTED_CONTEXT_FILES:?}" + +if [ -e "$target_path/services/job-analysis-api/src/orgmetra_job_analysis_api/unrelated_admin.py" ]; then + echo "Error: unrelated service source leaked into bounded Job Analysis scope" >&2 + exit 97 +fi + +echo "scan ok with trusted Job Analysis authorization and persistence context" +EOF + chmod +x "$fake_strix" + printf '%s' 'gemini/test-model' >"$strix_llm_file" + printf '%s' 'dummy' >"$llm_api_key_file" + + ( + cd "$repo_root_dir" + git init -q + git config user.name 'Strix Test' + git config user.email 'strix-test@example.invalid' + local context_file + for context_file in "${context_files[@]}"; do + mkdir -p "$(dirname -- "$context_file")" + printf 'BASE_JOB_ANALYSIS_AUTHORITY_CONTEXT:%s\n' "$context_file" >"$context_file" + done + mkdir -p "$(dirname -- "$changed_file")" \ + services/job-analysis-api/src/orgmetra_job_analysis_api + printf '%s\n' 'BASE_JOB_ANALYSIS_KERNEL_SHOULD_NOT_BE_SCANNED' >"$changed_file" + printf '%s\n' 'UNRELATED_SERVICE_SHOULD_NOT_BE_SCANNED' \ + >services/job-analysis-api/src/orgmetra_job_analysis_api/unrelated_admin.py + git add . + git commit -qm 'base commit' + ) + local base_sha + base_sha="$(git -C "$repo_root_dir" rev-parse HEAD)" + ( + cd "$repo_root_dir" + local context_file + for context_file in "${context_files[@]}"; do + printf 'HEAD_JOB_ANALYSIS_CONTEXT_SHOULD_NOT_BE_SCANNED:%s\n' "$context_file" >"$context_file" + done + printf '%s\n' 'HEAD_JOB_ANALYSIS_KERNEL_SHOULD_BE_SCANNED' >"$changed_file" + git add . + git commit -qm 'head commit' + ) + local head_sha + head_sha="$(git -C "$repo_root_dir" rev-parse HEAD)" + git -C "$repo_root_dir" checkout -q "$base_sha" + + set +e + ( + cd "$repo_root_dir" + env -u GITHUB_EVENT_PATH \ + PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ + STRIX_INPUT_FILE_ROOT="$tmp_dir" \ + GITHUB_EVENT_NAME="pull_request_target" \ + PR_BASE_SHA="$base_sha" \ + PR_HEAD_SHA="$head_sha" \ + STRIX_TEST_CHANGED_FILES_OVERRIDE="$changed_file" \ + STRIX_DISABLE_PR_SCOPING="0" \ + FAKE_STRIX_EXPECTED_CHANGED_FILE="$changed_file" \ + FAKE_STRIX_EXPECTED_CONTEXT_FILES="$context_files_text" \ + STRIX_LLM_FILE="$strix_llm_file" \ + LLM_API_KEY_FILE="$llm_api_key_file" \ + STRIX_TARGET_PATH="." \ + STRIX_REPORTS_DIR="$repo_root_dir/strix_runs" \ + STRIX_REPO_ROOT="$repo_root_dir" bash "$trusted_script_dir/strix_quick_gate.sh" >"$output_log" 2>&1 + ) + local rc=$? + set -e + + assert_equals "0" "$rc" "case=$case_name exit code" + assert_file_contains "$output_log" \ + "scan ok with trusted Job Analysis authorization and persistence context" \ + "case=$case_name output" + + rm -rf "$tmp_dir" +} + run_pull_request_target_shallow_head_merge_base_fallback_case() { local tmp_dir tmp_dir="$(mktemp -d)" @@ -9897,6 +10045,8 @@ run_pull_request_target_frontend_email_context_scope_case \ run_pull_request_target_frontend_email_context_scope_case \ "frontend/src/lib/email-threading.ts" +run_pull_request_target_job_analysis_authority_context_scope_case + run_pull_request_target_aborts_on_pr_head_blob_failure_case \ "pull-request-target-added-file-pr-head-blob-read-failure" \ "src/new_module.py" \ diff --git a/tests/fixtures/strix_login_as_guest_1_5_3.LICENSE.txt b/tests/fixtures/strix_login_as_guest_1_5_3.LICENSE.txt new file mode 100644 index 0000000000..65c4e8f58a --- /dev/null +++ b/tests/fixtures/strix_login_as_guest_1_5_3.LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 OmniSecure Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/fixtures/strix_login_as_guest_1_5_3.py b/tests/fixtures/strix_login_as_guest_1_5_3.py new file mode 100644 index 0000000000..7e2269a11c --- /dev/null +++ b/tests/fixtures/strix_login_as_guest_1_5_3.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2025 OmniSecure Inc. +# Modified file: test-only module excerpt; authentication function is unchanged. +# License: strix_login_as_guest_1_5_3.LICENSE.txt +# Source wheel SHA256: ba0b6b13f13f41e45f3eb4dba515641d1bc71363ca6e758d0cd05c20ff56b6ea +# Upstream source: https://github.com/usestrix/strix/blob/7cc9fa9faa0179fc7e35111102fe3d20a9028393/strix/runtime/caido_bootstrap.py +from __future__ import annotations + +import asyncio +import json +import logging +logger = logging.getLogger(__name__) + +_LOGIN_AS_GUEST_BODY = ( + '{"query":"mutation LoginAsGuest { loginAsGuest { token { accessToken } } }"}' +) + +async def _login_as_guest( + session: BaseSandboxSession, + *, + container_url: str, + attempts: int = 10, +) -> str: + """``session.exec`` curl to fetch a guest token; retry until ready. + + Caido's GraphQL listener may not be up the instant the container + starts. The retry loop also doubles as the Caido readiness probe — + no separate TCP healthcheck needed. + """ + last_err: str | None = None + for i in range(1, attempts + 1): + result = await session.exec( + "curl", + "-fsS", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-d", + _LOGIN_AS_GUEST_BODY, + f"{container_url}/graphql", + timeout=15, + ) + if result.ok(): + try: + payload = json.loads(result.stdout) + token = ( + payload.get("data", {}) + .get("loginAsGuest", {}) + .get("token", {}) + .get("accessToken") + ) + if token: + return str(token) + last_err = f"loginAsGuest returned no token: {payload}" + except json.JSONDecodeError as exc: + last_err = f"unparseable response: {exc}: {result.stdout!r}" + else: + stderr = result.stderr.decode("utf-8", errors="replace")[:200] + last_err = f"curl exit {result.exit_code}: {stderr}" + logger.debug("loginAsGuest attempt %d/%d failed: %s", i, attempts, last_err) + await asyncio.sleep(min(2.0 * i, 8.0)) + + raise RuntimeError(f"loginAsGuest failed after {attempts} attempts: {last_err}") diff --git a/tests/test_audit_org_codeql_coverage.py b/tests/test_audit_org_codeql_coverage.py index 0ffc8fa749..194540ad53 100644 --- a/tests/test_audit_org_codeql_coverage.py +++ b/tests/test_audit_org_codeql_coverage.py @@ -263,9 +263,13 @@ def test_null_latest_analysis_is_not_coverage() -> None: def test_audit_codeql_coverage_defaults_now_to_current_time() -> None: - repositories = [covered_by_recent_analysis("DefaultNowRepo", days_ago=0)] + """Exercise the real default clock with a genuinely current analysis.""" + repository = covered_by_recent_analysis("DefaultNowRepo", days_ago=0) + repository["latest_codeql_analysis"]["created_at"] = datetime.now( + timezone.utc + ).isoformat() - assert audit.audit_codeql_coverage(repositories) == [] + assert audit.audit_codeql_coverage([repository]) == [] def test_load_payload_reads_from_stdin(monkeypatch) -> None: @@ -299,7 +303,17 @@ def test_main_fail_path_reports_gaps_from_stdin(monkeypatch, capsys) -> None: assert "FAIL: 1 repositories have no CodeQL coverage" in captured.err -def test_main_pass_path_reports_from_file_arg(tmp_path, capsys) -> None: +def test_main_pass_path_reports_from_file_arg(tmp_path, capsys, monkeypatch) -> None: + """Keep the CLI freshness witness bound to the fixture's reference clock.""" + class FixedDatetime(datetime): + """Supply the same reference instant used by the analysis fixture.""" + + @classmethod + def now(cls, tz=None): + """Return the fixture clock in the caller's requested timezone.""" + return NOW.astimezone(tz) if tz is not None else NOW.replace(tzinfo=None) + + monkeypatch.setattr(audit, "datetime", FixedDatetime) payload_path = tmp_path / "repositories.json" payload_path.write_text( json.dumps([covered_by_default_setup("ELUNVERA"), covered_by_recent_analysis("Orgmetra")]), diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index 82569e89c7..c19cac190f 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -1147,10 +1147,10 @@ def test_codeql_moved_head_requires_forward_ancestry(tmp_path: Path) -> None: assert enforce.returncode != 0 -def test_codeql_control_routing_keeps_pr_workflows_hosted() -> None: +def test_codeql_control_routing_keeps_pr_workflows_isolated() -> None: """Only the trusted main revision may request the restricted control group.""" workflow = WORKFLOW_PATH.read_text(encoding="utf-8") - selectors = [line.strip() for line in workflow.splitlines() if line.strip().startswith("runs-on:")] + selectors = [line.strip() for line in workflow.splitlines() if line.startswith(" group:")] trusted = "ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main" assert len(selectors) == 3 for selector in selectors: @@ -1158,7 +1158,10 @@ def test_codeql_control_routing_keeps_pr_workflows_hosted() -> None: choices = re.findall(r"'([^']*)'", selector) assert choices[0] == trusted assert json.loads(choices[1]) == {"group": "CWL central control", "labels": ["self-hosted", "linux", "x64"]} - assert json.loads(choices[2]) == "ubuntu-24.04" + assert json.loads(choices[2]) == { + "group": "CWL CI isolated", + "labels": ["self-hosted", "linux", "x64", "cwlab-ci-isolated"], + } def test_codeql_pr_rejects_invalid_required_run_time(tmp_path: Path) -> None: diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index a2da04ae25..9022bfd164 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -1,5 +1,6 @@ from __future__ import annotations +import gzip import hashlib import io import json @@ -721,17 +722,30 @@ def _trusted_uv_archive( ) -> bytes: """Build a deterministic uv tar archive for supply-chain boundary tests.""" payload = io.BytesIO() - with tarfile.open(fileobj=payload, mode="w:gz") as bundle: - member = tarfile.TarInfo(member_name) - if regular: - member.size = len(binary) - bundle.addfile(member, io.BytesIO(binary)) - else: - member.type = tarfile.DIRTYPE - bundle.addfile(member) + with gzip.GzipFile(fileobj=payload, mode="wb", mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as bundle: + member = tarfile.TarInfo(member_name) + if regular: + member.size = len(binary) + bundle.addfile(member, io.BytesIO(binary)) + else: + member.type = tarfile.DIRTYPE + bundle.addfile(member) return payload.getvalue() +def test_trusted_uv_archive_has_stable_bytes_for_collection_ids( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Repeated supply-chain fixtures must not make pytest node IDs time-dependent.""" + import gzip + + instants = iter((1_000, 2_000)) + monkeypatch.setattr(gzip.time, "time", lambda: next(instants)) + + assert _trusted_uv_archive(regular=False) == _trusted_uv_archive(regular=False) + + def test_download_trusted_uv_archive_accepts_fixed_https_origin( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_organization_commercial_readiness_loop_receipt_contract.py b/tests/test_organization_commercial_readiness_loop_receipt_contract.py index 56225ff2d1..a4c34a6646 100644 --- a/tests/test_organization_commercial_readiness_loop_receipt_contract.py +++ b/tests/test_organization_commercial_readiness_loop_receipt_contract.py @@ -56,9 +56,14 @@ def test_json_receipt_is_retained_as_an_immutable_short_lived_artifact() -> None assert "if-no-files-found: error" in source assert "retention-days: 3" in source endpoints = _harden_runner_allowed_endpoints(source) - assert { + assert endpoints == { + "api.github.com:443", + "api.opencode.ai:443", + "github.com:443", + "objects.githubusercontent.com:443", + "release-assets.githubusercontent.com:443", "results-receiver.actions.githubusercontent.com:443", "*.actions.githubusercontent.com:443", "*.blob.core.windows.net:443", - }.issubset(endpoints) + } assert "- name: Checkout exact trusted coordinator source" not in endpoints diff --git a/tests/test_package_description_boundary.py b/tests/test_package_description_boundary.py new file mode 100644 index 0000000000..78bb267712 --- /dev/null +++ b/tests/test_package_description_boundary.py @@ -0,0 +1,532 @@ +"""Contract for the package-description boundary gate. + +A repository README may link internal design records; a package description +may not. ``scripts/ci/package_description_boundary.py`` reads the description +a registry will actually render - PKG-INFO from a built sdist, METADATA from a +wheel - rather than the README on disk, because those are what PyPI shows. + +Every expectation below was taken from a real published artifact: the findings +in ``fast_mlsirm-0.11.3.tar.gz`` on PyPI are what motivated the gate. +""" + +from __future__ import annotations + +import importlib.util +import io +import json +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest + +_SCRIPT = Path("scripts/ci/package_description_boundary.py") + + +def _module(): + """Load the gate as a module without installing it. + + The module is registered in ``sys.modules`` before execution because + ``@dataclass`` resolves its own module there while the class body runs. + """ + name = "package_description_boundary" + if name in sys.modules: + return sys.modules[name] + spec = importlib.util.spec_from_file_location(name, _SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _sdist(tmp_path: Path, description: str) -> Path: + """Build a minimal sdist whose PKG-INFO carries ``description``.""" + pkg_info = ( + "Metadata-Version: 2.1\n" + "Name: example\n" + "Version: 1.0.0\n" + "Description-Content-Type: text/markdown\n" + "\n" + f"{description}" + ).encode("utf-8") + path = tmp_path / "example-1.0.0.tar.gz" + with tarfile.open(path, "w:gz") as archive: + info = tarfile.TarInfo("example-1.0.0/PKG-INFO") + info.size = len(pkg_info) + archive.addfile(info, io.BytesIO(pkg_info)) + return path + + +def _wheel(tmp_path: Path, description: str) -> Path: + """Build a minimal wheel whose METADATA carries ``description``.""" + metadata = ( + "Metadata-Version: 2.1\n" + "Name: example\n" + "Version: 1.0.0\n" + "Description-Content-Type: text/markdown\n" + "\n" + f"{description}" + ).encode("utf-8") + path = tmp_path / "example-1.0.0-py3-none-any.whl" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("example-1.0.0.dist-info/METADATA", metadata) + return path + + +def test_clean_description_passes(tmp_path: Path) -> None: + """An absolute-linked, user-facing description is not a finding.""" + module = _module() + dist = _sdist(tmp_path, "# example\n\nSee the [guide](https://example.com/guide).\n") + assert module.main(["--dist", str(dist)]) == 0 + + +def test_relative_link_blocks(tmp_path: Path) -> None: + """A link that works on GitHub is a 404 on the registry page.""" + module = _module() + dist = _sdist(tmp_path, "See [the design](docs/design.md) and [LICENSE](LICENSE).\n") + assert module.main(["--dist", str(dist)]) == 1 + + +def test_absolute_and_anchor_links_are_not_findings(tmp_path: Path) -> None: + """Only links a registry cannot resolve count.""" + module = _module() + release_commit = "a" * 40 + description = ( + f"[docs](https://github.com/o/r/blob/{release_commit}/docs/a.md) " + "[top](#overview) [mail](mailto:x@example.com)\n" + ) + assert module.inspect(description).findings == [] + + +@pytest.mark.parametrize( + ("github_view", "branch_name"), + [("blob", "main"), ("blob", "master"), ("blob", "develop"), ("tree", "main")], +) +def test_mutable_github_release_contract_url_blocks( + github_view: str, branch_name: str +) -> None: + """Published metadata must not bind release contracts to moving branches.""" + module = _module() + description = ( + "See [the released contract]" + f"(https://github.com/ContextualWisdomLab/example/{github_view}/{branch_name}/docs/contract.md).\n" + ) + findings = module.inspect(description).findings + assert [(finding.rule, finding.blocking) for finding in findings] == [ + ("mutable-release-link", True) + ] + + +def test_internal_working_records_advise_and_adr_says_nothing() -> None: + """The directory name cannot decide what a repository keeps there. + + The central repository files operational incident records under + docs/doctoring/; pg-llm-batch files operator documentation there that a + package user genuinely needs. So this reports and does not block. A + repo-relative link into such a directory is still blocked, by relative-link, + which is the mechanical defect. + """ + module = _module() + internal = module.inspect( + "see https://github.com/o/r/blob/main/docs/superpowers/plans/x.md\n" + ) + assert [(f.rule, f.blocking) for f in internal.findings] == [ + ("internal-working-record", False) + ] + adr = module.inspect("see https://github.com/o/r/blob/main/docs/adr/0007-x.md\n") + assert adr.findings == [] + + +def test_relative_link_into_a_working_record_still_blocks() -> None: + """Advising on the directory must not stop the dead-link rule firing.""" + module = _module() + rules = { + (f.rule, f.blocking) + for f in module.inspect("see [plan](docs/superpowers/plans/x.md)\n").findings + } + assert ("relative-link", True) in rules + + +def test_monetary_target_blocks_and_vocabulary_only_advises() -> None: + """A deal value is never a product feature; vocabulary can be one. + + The organization already forbids gating product evidence on a deal value, so + monetary-target blocks. Go-to-market wording cannot be judged mechanically: + contextual-orchestrator genuinely ships ``/api/v1/commercial_readiness/latest`` + and wardnet's crate genuinely computes commercial readiness snapshots, so the + same words are the product there. That rule advises instead of blocking. + """ + module = _module() + findings = module.inspect( + "A KRW 2,000,000,000 commercial readiness gate for buyer packet review.\n" + ).findings + by_rule = {f.rule: f for f in findings} + assert by_rule["monetary-target"].blocking is True + assert by_rule["go-to-market-vocabulary"].blocking is False + + +def test_strict_promotes_the_advisory_rules(tmp_path: Path) -> None: + """A repo that wants the judgement rules enforced can ask for it.""" + module = _module() + dist = _sdist(tmp_path, "A commercial readiness gate.\n") + assert module.main(["--dist", str(dist)]) == 0 + assert module.main(["--dist", str(dist), "--strict"]) == 1 + + +def test_adr_under_a_planning_directory_is_not_a_working_record() -> None: + """An ADR is a public design record wherever the repository files it. + + contextual-orchestrator keeps its ADRs under ``docs/planning/adrs/``; the + directory prefix must not turn them into findings. + """ + module = _module() + release_commit = "b" * 40 + adr = module.inspect( + f"See [ADR 0001](https://github.com/o/r/blob/{release_commit}/docs/planning/adrs/0001-x.md).\n" + ) + assert [f.rule for f in adr.findings] == [] + plan = module.inspect( + f"See [plan](https://github.com/o/r/blob/{release_commit}/docs/planning/2026-07-02-x.md).\n" + ) + assert [(f.rule, f.blocking) for f in plan.findings] == [ + ("internal-working-record", False) + ] + + +def test_quoted_source_path_blocks() -> None: + """A module path is plumbing, not something the reader can act on.""" + module = _module() + assert [f.rule for f in module.inspect("`src/pkg/steward_review.py` holds it.\n").findings] == [ + "source-path" + ] + + +def test_allow_downgrades_a_rule_without_hiding_it(tmp_path: Path) -> None: + """A repo mid-migration can report a rule without failing on it.""" + module = _module() + dist = _sdist(tmp_path, "See [the design](docs/design.md).\n") + assert module.main(["--dist", str(dist), "--allow", "relative-link"]) == 0 + + +def test_json_report_records_every_finding(tmp_path: Path) -> None: + """CI needs the machine-readable form, not only the printed lines.""" + module = _module() + dist = _sdist( + tmp_path, + "[a](docs/a.md)\n`src/pkg/x.py`\nA commercial readiness tool.\n", + ) + out = tmp_path / "report.json" + module.main(["--dist", str(dist), "--json", str(out)]) + report = json.loads(out.read_text(encoding="utf-8")) + assert report["status"] == "failed" + assert report["findings"] == [ + { + "rule": "relative-link", + "detail": "docs/a.md does not resolve on a registry page; use an absolute URL", + "blocking": True, + }, + { + "rule": "source-path", + "detail": "`src/pkg/x.py`", + "blocking": True, + }, + { + "rule": "go-to-market-vocabulary", + "detail": "A commercial readiness tool.", + "blocking": False, + }, + ] + + +def test_missing_metadata_is_an_error_not_a_pass(tmp_path: Path) -> None: + """An unreadable distribution must never look like a clean one.""" + module = _module() + empty = tmp_path / "empty-1.0.0.tar.gz" + with tarfile.open(empty, "w:gz"): + pass + assert module.main(["--dist", str(empty)]) == 2 + + +def test_dist_directory_rejects_sdist_wheel_description_divergence(tmp_path: Path) -> None: + """Every upload artifact must publish byte-identical registry metadata.""" + module = _module() + _sdist(tmp_path, "sdist description\n") + _wheel(tmp_path, "wheel description\n") + assert module.main(["--dist", str(tmp_path)]) == 2 + + +def test_dist_directory_accepts_matching_sdist_and_wheel_descriptions( + tmp_path: Path, +) -> None: + """Parity validation must not reject a normal two-artifact upload.""" + module = _module() + description = "same published description\n" + _sdist(tmp_path, description) + _wheel(tmp_path, description) + assert module.main(["--dist", str(tmp_path)]) == 0 + + +def test_missing_description_sources_fail_closed(tmp_path: Path) -> None: + """An empty distribution directory or absent source is never a clean report.""" + module = _module() + assert module.main(["--dist", str(tmp_path)]) == 2 + with pytest.raises(ValueError, match="pass --dist or --readme"): + module.load_description(None, None) + + +@pytest.mark.parametrize("builder", [_sdist, _wheel]) +def test_empty_distribution_description_uses_metadata_fallback( + tmp_path: Path, builder +) -> None: + """Missing body and Description header produce an empty description.""" + description, _source = _module().load_description(builder(tmp_path, ""), None) + assert description == "" + + +def test_unreadable_sdist_metadata_fails_closed(tmp_path: Path, monkeypatch) -> None: + """A missing metadata stream must fail instead of inspecting empty content.""" + dist = _sdist(tmp_path, "description\n") + monkeypatch.setattr(tarfile.TarFile, "extractfile", lambda _archive, _member: None) + assert _module().main(["--dist", str(dist)]) == 2 + + +def test_wheel_cli_entry_point_returns_the_gate_result(tmp_path: Path, monkeypatch) -> None: + """The executable entry point reads the wheel directly and exits cleanly.""" + import runpy + + dist = _wheel(tmp_path, "released description\n") + monkeypatch.setattr(sys, "argv", [str(_SCRIPT), "--dist", str(dist)]) + with pytest.raises(SystemExit) as result: + runpy.run_path(str(_SCRIPT), run_name="__main__") + assert result.value.code == 0 + + +def test_requirement_map_is_advisory_and_recorded(tmp_path: Path) -> None: + """Requirement vocabulary remains visible without blocking the default gate.""" + module = _module() + dist = _sdist(tmp_path, "PRD/TRD traceability\n") + out = tmp_path / "report.json" + assert module.main(["--dist", str(dist), "--json", str(out)]) == 0 + assert json.loads(out.read_text())["findings"] == [ + { + "rule": "requirement-map", + "detail": "PRD/TRD traceability", + "blocking": False, + } + ] + + +def test_readme_fallback_is_available_before_a_first_release(tmp_path: Path) -> None: + """A repo with no distribution yet can still be gated on its README.""" + module = _module() + readme = tmp_path / "README.md" + readme.write_text("[design](docs/design.md)\n", encoding="utf-8") + assert module.main(["--readme", str(readme)]) == 1 + + +_WORKFLOW = Path(".github/workflows/package-description-boundary.yml") + + +def _workflow() -> dict: + """Parse the reusable gate workflow.""" + import yaml + + return yaml.safe_load(_WORKFLOW.read_text(encoding="utf-8")) + + +def test_workflow_is_callable_only() -> None: + """A required-workflow ruleset must not be able to admit a build-and-run file.""" + assert set(_workflow()[True]) == {"workflow_call"} + + +def test_workflow_runner_is_central_isolated_self_hosted() -> None: + """Both jobs reject GitHub-hosted fallback and use the isolated pool.""" + jobs = _workflow()["jobs"] + expected = { + "group": "CWL CI isolated", + "labels": ["self-hosted", "linux", "x64", "cwlab-ci-isolated"], + } + assert jobs["build-distribution"]["runs-on"] == expected + assert jobs["package-description-boundary"]["runs-on"] == expected + text = _WORKFLOW.read_text(encoding="utf-8") + assert "ubuntu-latest" not in text + assert "ubuntu-24.04" not in text + + +def test_workflow_checks_out_the_gate_at_its_own_commit() -> None: + """The caller must run the gate revision it pinned, not whatever main holds.""" + steps = _workflow()["jobs"]["package-description-boundary"]["steps"] + central = [ + step + for step in steps + if (step.get("with") or {}).get("path") == ".central-gate" + ] + assert central, "the gate is never checked out" + assert central[0]["with"]["repository"] == "${{ job.workflow_repository }}" + assert central[0]["with"]["ref"] == "${{ job.workflow_sha }}" + assert central[0]["with"]["persist-credentials"] is False + + +def test_untrusted_build_cannot_mutate_the_inspection_runtime() -> None: + """Caller build code and the trusted inspector must use different jobs. + + A PEP 517 backend is arbitrary caller-controlled code. If it runs after + the central checkout in the same writable job, it can replace the gate or + poison Python startup before inspection. The only value crossing this + boundary may be the built distribution artifact. + """ + workflow = _workflow() + jobs = workflow["jobs"] + assert set(jobs) == {"build-distribution", "package-description-boundary"} + + build_steps = jobs["build-distribution"]["steps"] + inspect_job = jobs["package-description-boundary"] + inspect_steps = inspect_job["steps"] + assert inspect_job["needs"] == "build-distribution" + assert inspect_job["if"] == "${{ always() }}" + assert "needs.build-distribution.result" in str(inspect_steps[0]["env"]) + assert '"$PRODUCER_RESULT" != "success"' in inspect_steps[0]["run"] + + build_text = "\n".join(step.get("run", "") for step in build_steps) + inspect_text = "\n".join(step.get("run", "") for step in inspect_steps) + assert "uv build" in build_text + assert ".central-gate" not in build_text + assert "uv build" not in inspect_text + assert "python -I .central-gate/scripts/ci/package_description_boundary.py" in inspect_text + + downloads = [ + step + for step in inspect_steps + if str(step.get("uses", "")).startswith("actions/download-artifact@") + ] + assert len(downloads) == 1 + assert downloads[0]["with"]["path"] == "package-description-input" + assert not downloads[0]["with"]["path"].startswith(".") + assert 'source_args=(--dist "package-description-input/dist")' in inspect_text + + +def test_workflow_fails_closed_on_called_workflow_identity() -> None: + """A caller SHA must never be accepted as the central gate revision.""" + text = _WORKFLOW.read_text(encoding="utf-8") + assert "WORKFLOW_REPOSITORY: ${{ job.workflow_repository }}" in text + assert "WORKFLOW_SHA: ${{ job.workflow_sha }}" in text + assert '"$WORKFLOW_REPOSITORY" != "ContextualWisdomLab/.github"' in text + assert "^[0-9a-f]{40}$" in text + assert "github.workflow_sha" not in text + + +def test_workflow_uses_pinned_uv_without_unhashed_pip_install() -> None: + """The inherited build frontend must have immutable action/tool identity.""" + text = _WORKFLOW.read_text(encoding="utf-8") + assert "astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9" in text + assert 'version: "0.11.28"' in text + assert "uv build \"$target\" --out-dir \"$DIST_PATH\"" in text + assert "pip install" not in text + + +def test_workflow_pins_every_action_to_a_commit_sha() -> None: + """A mutable tag in a workflow every consumer inherits is a supply-chain hole.""" + for line in _WORKFLOW.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped.startswith(("- uses:", "uses:")): + continue + reference = stripped.partition("uses:")[2].strip().split()[0] + version = reference.partition("@")[2] + assert len(version) == 40 and all(c in "0123456789abcdef" for c in version), stripped + + +def test_workflow_takes_no_shell_from_a_caller() -> None: + """A caller may choose what to build, never how. + + An earlier revision took a free-form ``build-command`` input and + interpolated it straight into a ``run:`` block, which is template injection + and is what ADR 0023 forbids. Semgrep's run-shell-injection rule caught it + before it shipped; this pins the fix. + """ + workflow = _workflow() + inputs = workflow[True]["workflow_call"]["inputs"] + assert "build-command" not in inputs + assert inputs["build"]["default"] == "sdist" + text = _WORKFLOW.read_text(encoding="utf-8") + for job in workflow["jobs"].values(): + for step in job.get("steps", []): + assert "${{ inputs." not in step.get("run", ""), step.get("name") + # The bounded choice is validated in-shell, so an unexpected value fails + # loudly instead of silently building nothing. + assert "build must be one of: sdist, wheel, none" in text + + +def test_workflow_declares_least_privilege() -> None: + """The gate only reads code.""" + assert _workflow()["permissions"] == {"contents": "read"} + + +def test_workflow_falls_back_to_readme_before_a_first_release() -> None: + """A repo with no distribution yet is still gated.""" + steps = _workflow()["jobs"]["package-description-boundary"]["steps"] + check = [s for s in steps if "package_description_boundary.py" in s.get("run", "")] + assert check, "the gate is never invoked" + assert "--readme" in check[0]["run"] + assert "--dist" in check[0]["run"] + + +def test_workflow_caller_checkout_does_not_persist_credentials() -> None: + """Untrusted build code must not inherit the caller checkout credential.""" + jobs = _workflow()["jobs"] + build_checkouts = [ + step + for step in jobs["build-distribution"]["steps"] + if str(step.get("uses", "")).startswith("actions/checkout@") + ] + inspect_checkouts = [ + step + for step in jobs["package-description-boundary"]["steps"] + if str(step.get("uses", "")).startswith("actions/checkout@") + ] + assert len(build_checkouts) == 1 + assert build_checkouts[0]["with"]["path"] == ".caller-source" + assert build_checkouts[0]["with"]["persist-credentials"] is False + assert len(inspect_checkouts) == 1 + assert inspect_checkouts[0]["with"]["path"] == ".central-gate" + assert inspect_checkouts[0]["with"]["persist-credentials"] is False + + +def test_workflow_never_falls_back_to_readme_after_distribution_build() -> None: + """A requested build must inspect its dist path or fail closed.""" + steps = _workflow()["jobs"]["package-description-boundary"]["steps"] + check = next(step for step in steps if "package_description_boundary.py" in step.get("run", "")) + run = check["run"] + assert 'if [ "$BUILD" = "none" ]; then' in run + assert 'source_args=(--readme "package-description-input/README.md")' in run + assert 'source_args=(--dist "package-description-input/dist")' in run + assert "README_PATH" not in run + assert "DIST_PATH" not in run + + +def test_sdist_rejects_ambiguous_root_pkg_info(tmp_path: Path) -> None: + """Archive order must not choose between duplicate authoritative metadata.""" + module = _module() + path = tmp_path / "example-1.0.0.tar.gz" + first = b"Metadata-Version: 2.1\nName: example\nVersion: 1.0.0\n\nfirst\n" + second = b"Metadata-Version: 2.1\nName: example\nVersion: 1.0.0\n\nsecond\n" + with tarfile.open(path, "w:gz") as archive: + for payload in (first, second): + info = tarfile.TarInfo("example-1.0.0/PKG-INFO") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + with pytest.raises(ValueError, match="exactly one root PKG-INFO"): + module.description_from_sdist(path) + + +def test_wheel_rejects_ambiguous_root_metadata(tmp_path: Path) -> None: + """ZIP order must not choose between multiple authoritative METADATA files.""" + module = _module() + path = tmp_path / "example-1.0.0-py3-none-any.whl" + payload = "Metadata-Version: 2.1\nName: example\nVersion: 1.0.0\n\ntext\n" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("example-1.0.0.dist-info/METADATA", payload) + archive.writestr("spoof-9.9.9.dist-info/METADATA", payload) + with pytest.raises(ValueError, match="exactly one root .dist-info/METADATA"): + module.description_from_wheel(path) diff --git a/tests/test_required_review_runner_image_contract.py b/tests/test_required_review_runner_image_contract.py index ab7ede7aa4..b299244ad7 100644 --- a/tests/test_required_review_runner_image_contract.py +++ b/tests/test_required_review_runner_image_contract.py @@ -95,10 +95,17 @@ def test_opencode_review_dispatch_uses_explicit_supported_image(self) -> None: unittest.main() -def test_codeql_pr_routes_trusted_main_to_control_and_pr_revisions_to_hosted() -> None: - """Separate short metadata work from model work without granting PR runner access.""" +def test_codeql_pr_routes_trusted_main_to_control_and_pr_revisions_to_isolated() -> None: + """Keep trusted workflow control separate from isolated PR execution.""" workflow = Path(".github/workflows/codeql-pr.yml").read_text() - assert workflow.count('"group":"CWL central control"') == 3 - assert workflow.count("github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main'") == 3 - assert workflow.count("|| '\"ubuntu-24.04\"'") == 3 + trusted = "github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main'" + selectors = [line for line in workflow.splitlines() if line.startswith(" group:")] + labels = [line for line in workflow.splitlines() if line.startswith(" labels:")] + assert len(selectors) == len(labels) == 3 + for selector, label in zip(selectors, labels, strict=True): + assert trusted in selector and trusted in label + assert '"group":"CWL central control"' in selector + assert '"group":"CWL CI isolated"' in selector + assert '"cwlab-ci-isolated"' in selector and '"cwlab-ci-isolated"' in label + assert '"ubuntu-24.04"' not in selector + label assert '"group":"CWL MCP remediation"' not in workflow diff --git a/tests/test_sandboxed_web_e2e.py b/tests/test_sandboxed_web_e2e.py index 1b1cdf3722..e43aed95e0 100644 --- a/tests/test_sandboxed_web_e2e.py +++ b/tests/test_sandboxed_web_e2e.py @@ -1581,6 +1581,8 @@ def test_isolated_command_resolves_repo_local_launcher_against_sandboxed_cwd(mon resolution instead goes through the sandboxed-``cwd``-relative path this fix adds, not a PATH search. """ + # Model the guest home independently of the host scratch directory. + monkeypatch.setattr(Path, "home", lambda: tmp_path / "synthetic-home") monkeypatch.setattr(sandboxed_web_e2e.shutil, "which", lambda *_args, **_kwargs: None) sandbox = tmp_path / "sandbox" repo = sandbox / "repo" @@ -1613,6 +1615,8 @@ def test_isolated_command_resolves_bare_command_via_relative_path_entry(monkeypa ``shutil.which`` to ``None`` here proves resolution instead falls through to the cwd-anchored ``PATH`` search this fix adds. """ + # Model the guest home independently of the host scratch directory. + monkeypatch.setattr(Path, "home", lambda: tmp_path / "synthetic-home") monkeypatch.setattr(sandboxed_web_e2e.shutil, "which", lambda *_args, **_kwargs: None) sandbox = tmp_path / "sandbox" repo = sandbox / "repo" @@ -1646,6 +1650,8 @@ def test_isolated_command_translates_absolute_workspace_launcher_to_sandbox_moun that does not exist inside the sandbox, where only ``SANDBOX_MOUNT`` is bound, so the command would fail to launch there unchanged. """ + # Model the guest home independently of the host scratch directory. + monkeypatch.setattr(Path, "home", lambda: tmp_path / "synthetic-home") monkeypatch.setattr(sandboxed_web_e2e.shutil, "which", lambda *_args, **_kwargs: None) sandbox = tmp_path / "sandbox" repo = sandbox / "repo" @@ -1706,6 +1712,8 @@ def test_isolated_command_rejects_relative_path_entry_escaping_sandbox(monkeypat def test_isolated_command_rejects_repo_local_path_traversal(monkeypatch, tmp_path): """A repo-local launcher path that lexically escapes the sandbox root is still rejected.""" + # Model the guest home independently of the host scratch directory. + monkeypatch.setattr(Path, "home", lambda: tmp_path / "synthetic-home") monkeypatch.setattr(sandboxed_web_e2e.shutil, "which", lambda *_args, **_kwargs: None) sandbox = tmp_path / "sandbox" repo = sandbox / "repo" @@ -1726,6 +1734,8 @@ def test_isolated_command_rejects_repo_local_path_traversal(monkeypatch, tmp_pat def test_isolated_command_rejects_explicit_external_path(monkeypatch, tmp_path): """An explicit absolute path outside the workspace and bind roots is still rejected.""" + # Model the guest home independently of the host scratch directory. + monkeypatch.setattr(Path, "home", lambda: tmp_path / "synthetic-home") monkeypatch.setattr(sandboxed_web_e2e.shutil, "which", lambda *_args, **_kwargs: None) sandbox = tmp_path / "sandbox" repo = sandbox / "repo" diff --git a/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py b/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py index 42efce621e..d6cd65c9de 100644 --- a/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py +++ b/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py @@ -49,12 +49,12 @@ def test_hourly_review_repair_uses_explicit_supported_image(self) -> None: self.assertIn("labels: [self-hosted, linux, x64]", workflow) def test_codeql_pr_uses_explicit_supported_image(self) -> None: - """Require trusted-main control routing and Ubuntu fallback for all three jobs.""" + """Require trusted-main control routing and isolated fallback for all three jobs.""" workflow = CODEQL_PR.read_text(encoding="utf-8") self.assertNotIn("runs-on: ubuntu-latest", workflow) selectors = [ line.strip() for line in workflow.splitlines() - if line.strip().startswith("runs-on:") + if line.startswith(" group:") ] self.assertEqual(len(selectors), 3) for selector in selectors: @@ -64,7 +64,7 @@ def test_codeql_pr_uses_explicit_supported_image(self) -> None: ) self.assertIn('"group":"CWL central control"', selector) self.assertIn('"labels":["self-hosted","linux","x64"]', selector) - self.assertIn("|| '\"ubuntu-24.04\"'", selector) + self.assertIn("|| '{\"group\":\"CWL CI isolated\",\"labels\":[\"self-hosted\",\"linux\",\"x64\",\"cwlab-ci-isolated\"]}'", selector) def test_codeql_scan_dispatch_uses_explicit_supported_image(self) -> None: """Require validation, scan, and attempt wake jobs in the dedicated group.""" diff --git a/tests/test_strix_llm_timeout_contract.py b/tests/test_strix_llm_timeout_contract.py index b46630c898..0900afb67d 100644 --- a/tests/test_strix_llm_timeout_contract.py +++ b/tests/test_strix_llm_timeout_contract.py @@ -39,6 +39,38 @@ def _load_installer(): return _load_module(INSTALLER, "install_strix_timeout_compat") +def _fake_sandbox_modules(monkeypatch, backend=None): + """Supply pinned import interfaces without loading Docker or a third-party runtime.""" + runtime = types.ModuleType("strix.runtime") + bootstrap = types.ModuleType("strix.runtime.caido_bootstrap") + bootstrap._login_as_guest = runpy.run_path( + str(ROOT / "tests/fixtures/strix_login_as_guest_1_5_3.py") + )["_login_as_guest"] + runtime.caido_bootstrap = bootstrap + backends = types.ModuleType("strix.runtime.backends") + async def docker_backend(*, image, manifest, exposed_ports, bind_mounts=None): + raise AssertionError("No real sandbox creation in compatibility installation tests") + backend = backend or docker_backend + backends._docker_backend = backend + registry = {"docker": backend} + backends.get_backend = lambda name: registry[name] + def register_backend(name, backend, *, supports_bind_mounts=False): + assert supports_bind_mounts is True + registry[name] = backend + backends.register_backend = register_backend + runtime.backends = backends + monkeypatch.setitem(sys.modules, "strix.runtime.backends", backends) + docker = types.ModuleType("agents.sandbox.sandboxes.docker") + docker.DockerSandboxSession = type("DockerSandboxSession", (), {}) + monkeypatch.setitem(sys.modules, "strix.runtime", runtime) + monkeypatch.setitem(sys.modules, "strix.runtime.caido_bootstrap", bootstrap) + monkeypatch.setitem(sys.modules, "agents.sandbox.sandboxes.docker", docker) + monkeypatch.setattr(importlib.metadata, "version", lambda name: { + "strix-agent": "1.5.3", "openai-agents": "0.19.4", + }[name]) + return bootstrap + + def test_strix_timeout_compat_is_installed_after_the_pinned_runtime() -> None: """Keep the upstream 1.5.3 parser value from becoming a real inference deadline.""" workflow = WORKFLOW.read_text(encoding="utf-8") @@ -143,6 +175,7 @@ def make_model_settings(*args, **kwargs): monkeypatch.setitem(sys.modules, "strix.interface.scan_setup", scan_setup_module) monkeypatch.setitem(sys.modules, "strix.interface.main", main_module) monkeypatch.setattr(launcher, "_require_supported_version", lambda: None) + _fake_sandbox_modules(monkeypatch) monkeypatch.setenv("LLM_TIMEOUT", "300") monkeypatch.setenv("LLM_STREAM_IDLE_TIMEOUT", "300") @@ -393,7 +426,7 @@ def test_launcher_script_entrypoint_enters_patched_strix(monkeypatch) -> None: scan_setup_module, ) monkeypatch.setitem(sys.modules, "strix.interface.main", main_module) - monkeypatch.setattr(importlib.metadata, "version", lambda _name: "1.5.3") + _fake_sandbox_modules(monkeypatch) monkeypatch.setenv("LLM_TIMEOUT", "300") monkeypatch.setenv("LLM_STREAM_IDLE_TIMEOUT", "300") @@ -436,6 +469,7 @@ def test_runtime_compatibility_survives_the_package_level_main_shadow(monkeypatc monkeypatch.setitem(sys.modules, "strix.interface.scan_setup", scan_setup_module) monkeypatch.setitem(sys.modules, "strix.interface.main", main_module) monkeypatch.setattr(launcher, "_require_supported_version", lambda: None) + _fake_sandbox_modules(monkeypatch) monkeypatch.setenv("LLM_TIMEOUT", "300") monkeypatch.setenv("LLM_STREAM_IDLE_TIMEOUT", "300") diff --git a/tests/test_strix_sandbox_diagnostics.py b/tests/test_strix_sandbox_diagnostics.py new file mode 100644 index 0000000000..1ce8bd4b41 --- /dev/null +++ b/tests/test_strix_sandbox_diagnostics.py @@ -0,0 +1,245 @@ +"""Cached Docker observations must never alter original Strix authentication.""" + +from __future__ import annotations + +import asyncio +import importlib.metadata +from pathlib import Path +import runpy +import sys +import types + +import pytest + +from tests.test_strix_llm_timeout_contract import _fake_sandbox_modules, _load_launcher + + +IDENTITY = "a" * 64 +IMAGE = "sha256:" + "b" * 64 +FIXTURE = Path(__file__).parent / "fixtures" / "strix_login_as_guest_1_5_3.py" + + +class Container: + """Supply existing Docker attributes and forbid every lifecycle or refresh call.""" + + id = IDENTITY + + def __init__(self): + self.attrs = {"Id": IDENTITY, "Image": IMAGE, + "State": {"Status": "running", "ExitCode": 0, "OOMKilled": False}} + + def reload(self): + pytest.fail("Observer must not refresh Docker state") + + def remove(self): + pytest.fail("Observer must not remove Docker containers") + + +class Inner: + """Mirror the reviewed SDK's bound Docker object and identity fields.""" + + def __init__(self, container): + self._container = container + self.container_id = IDENTITY + + +class Session: + """Simulate only curl I/O for the unchanged pinned guest-token function.""" + + def __init__(self, payload=b'{"data":{"loginAsGuest":{"token":{"accessToken":"guest-secret"}}}}', code=0): + self._inner = Inner(Container()) + self.payload = payload + self.code = code + self.calls = [] + + async def exec(self, *args, **kwargs): + self.calls.append((args, kwargs)) + assert args[0:3] == ("curl", "-fsS", "-X") + assert args[-1] == "http://127.0.0.1:48080/graphql" + assert kwargs == {"timeout": 15} + return types.SimpleNamespace(ok=lambda: self.code == 0, stdout=self.payload, + stderr=b"synthetic connection refused", exit_code=self.code) + + +def original_login(): + """Use the unchanged parser/token checks with only the retry clock replaced.""" + function = runpy.run_path(str(FIXTURE))["_login_as_guest"] + async def sleep(_delay): + return None + function.__globals__["asyncio"] = types.SimpleNamespace(sleep=sleep) + return function + + +def test_fixture_preserves_the_exact_pinned_authentication_source(): + import ast + import hashlib + source = FIXTURE.read_text() + node = next(node for node in ast.parse(source).body + if isinstance(node, ast.AsyncFunctionDef) and node.name == "_login_as_guest") + assert hashlib.sha256(ast.get_source_segment(source, node).encode()).hexdigest() == ( + "4e23ddba5879b791440169980433f83b17f1046892d4ee6d2496f27a56853d1c" + ) + + +@pytest.mark.parametrize("state", ["running", "exited", "dead", "removing"]) +def test_even_cached_terminal_state_does_not_skip_original_authentication(state, capsys): + launcher = _load_launcher() + session = Session() + session._inner._container.attrs["State"]["Status"] = state + observed = launcher.wrap_sandbox_authentication(original_login(), Inner) + assert asyncio.run(observed(session, container_url="http://127.0.0.1:48080")) == "guest-secret" + assert len(session.calls) == 1 + output = capsys.readouterr().err + assert "phase=authentication_start freshness=cached" in output + assert "phase=authentication_success freshness=cached" in output + assert f"image_config_id={IMAGE} status={state} exit_code=0 oom_killed=false" in output + assert "guest-secret" not in output and IDENTITY not in output + + +@pytest.mark.parametrize("payload,code,message", [ + (b'{"data":{"loginAsGuest":{"token":{}}}}', 0, "returned no token"), + (b"not-json", 0, "unparseable response"), + (b"", 7, "curl exit 7"), +]) +def test_actual_token_parser_and_retry_count_are_unchanged(payload, code, message, capsys): + launcher = _load_launcher() + session = Session(payload, code) + observed = launcher.wrap_sandbox_authentication(original_login(), Inner) + with pytest.raises(RuntimeError, match=message): + asyncio.run(observed(session, container_url="http://127.0.0.1:48080", attempts=2)) + assert len(session.calls) == 2 + output = capsys.readouterr().err + assert "phase=authentication_failure" in output + assert "not-json" not in output and "connection refused" not in output + + +@pytest.mark.parametrize("kind", ["error", "cancel"]) +def test_exact_original_exception_and_cancellation_survive_observation(kind, capsys): + launcher = _load_launcher() + error = RuntimeError("private original error") if kind == "error" else asyncio.CancelledError() + calls = [] + async def original(session, *, container_url, attempts=10): + calls.append((session, container_url, attempts)) + raise error + session = Session() + observed = launcher.wrap_sandbox_authentication(original, Inner) + with pytest.raises(type(error)) as result: + asyncio.run(observed(session, container_url="http://127.0.0.1:48080", attempts=3)) + assert result.value is error + assert calls == [(session, "http://127.0.0.1:48080", 3)] + assert "private" not in capsys.readouterr().err + + +@pytest.mark.parametrize("mutation", ["foreign-inner", "missing-object", "wrong-id", "missing-id", "object-id", + "bad-attrs", "bad-state", "malformed-fields", "cache-error"]) +def test_missing_or_malformed_cached_binding_emits_only_omitted_values(mutation, capsys): + launcher = _load_launcher() + session = Session() + container = session._inner._container + if mutation == "foreign-inner": + session._inner = object() + elif mutation == "missing-object": + del session._inner._container + elif mutation == "wrong-id": + container.attrs["Id"] = "c" * 64 + elif mutation == "missing-id": + del container.attrs["Id"] + elif mutation == "object-id": + container.id = "c" * 64 + elif mutation == "bad-attrs": + container.attrs = [] + elif mutation == "bad-state": + container.attrs = {"Id": IDENTITY, "Image": "private-tag", "State": "private-state"} + elif mutation == "malformed-fields": + container.attrs = {"Id": IDENTITY, "Image": "sha256:" + "B" * 64, + "State": {"Status": "private\nstatus", "ExitCode": True, "OOMKilled": "private"}} + else: + class BrokenInner(Inner): + @property + def container_id(self): + raise RuntimeError("private observer error") + @container_id.setter + def container_id(self, value): + pass + session._inner = BrokenInner(container) + observed = launcher.wrap_sandbox_authentication(original_login(), Inner) + assert asyncio.run(observed(session, container_url="http://127.0.0.1:48080")) == "guest-secret" + output = capsys.readouterr().err + assert "image_config_id= status= exit_code= oom_killed=" in output + assert "private" not in output + + +def test_non_docker_binding_is_not_inspected(capsys): + """A foreign inner session keeps guest parsing without private Docker access.""" + launcher = _load_launcher() + class ForeignInner: + @property + def _container(self): + raise AssertionError("Non-Docker private attributes were accessed") + session = Session() + session._inner = ForeignInner() + observed = launcher.wrap_sandbox_authentication(original_login(), Inner) + assert asyncio.run(observed(session, container_url="http://127.0.0.1:48080")) == "guest-secret" + assert "image_config_id=" in capsys.readouterr().err + + +def test_invalid_phase_and_failed_output_do_not_change_authentication(monkeypatch, capsys): + launcher = _load_launcher() + session = Session() + launcher.emit_sandbox_diagnostic(session, "private-phase", Inner) + assert capsys.readouterr().err == "" + class ClosedOutput: + def write(self, value): + raise OSError("private output failure") + monkeypatch.setattr(sys, "stderr", ClosedOutput()) + observed = launcher.wrap_sandbox_authentication(original_login(), Inner) + assert asyncio.run(observed(session, container_url="http://127.0.0.1:48080")) == "guest-secret" + + +def test_installation_only_wraps_guest_function_and_never_touches_backend_or_settings(monkeypatch, capsys): + launcher = _load_launcher() + bootstrap = _fake_sandbox_modules(monkeypatch) + monkeypatch.setitem(sys.modules, "strix", types.ModuleType("strix")) + sys.modules["agents.sandbox.sandboxes.docker"].DockerSandboxSession = Inner + config = types.ModuleType("strix.config") + config.load_settings = lambda: pytest.fail("Observer must not create/cache settings") + monkeypatch.setitem(sys.modules, "strix.config", config) + backends = sys.modules["strix.runtime.backends"] + factory = backends.get_backend("docker") + original = bootstrap._login_as_guest + launcher.install_sandbox_diagnostics() + assert bootstrap._login_as_guest.__wrapped__ is original + assert backends.get_backend("docker") is factory + assert capsys.readouterr().err == "" + + +@pytest.mark.parametrize("failure", ["version", "missing-sdk", "import", "abi"]) +def test_unavailable_diagnostics_do_not_block_the_existing_scan(monkeypatch, capsys, failure): + launcher = _load_launcher() + bootstrap = _fake_sandbox_modules(monkeypatch) + monkeypatch.setitem(sys.modules, "strix", types.ModuleType("strix")) + original = bootstrap._login_as_guest + if failure == "version": + monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.19.5") + elif failure == "missing-sdk": + def missing(name): + raise importlib.metadata.PackageNotFoundError(name) + monkeypatch.setattr(importlib.metadata, "version", missing) + elif failure == "import": + monkeypatch.setitem(sys.modules, "agents.sandbox.sandboxes.docker", None) + else: + bootstrap._login_as_guest = lambda: None + original = bootstrap._login_as_guest + launcher.install_sandbox_diagnostics() + assert bootstrap._login_as_guest is original + assert capsys.readouterr().err == "STRIX_SANDBOX_DIAGNOSTICS unavailable\n" + + +def test_unavailable_marker_output_error_is_nonfatal(monkeypatch): + launcher = _load_launcher() + monkeypatch.setattr(importlib.metadata, "version", lambda name: "unsupported") + class ClosedOutput: + def write(self, value): + raise OSError("private output failure") + monkeypatch.setattr(sys, "stderr", ClosedOutput()) + launcher.install_sandbox_diagnostics()