diff --git a/.github/workflows/package-description-boundary.yml b/.github/workflows/package-description-boundary.yml new file mode 100644 index 0000000000..88dc8faeef --- /dev/null +++ b/.github/workflows/package-description-boundary.yml @@ -0,0 +1,144 @@ +# 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: + package-description-boundary: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out the calling repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - 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: Set up pinned uv build frontend + 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' }} + 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" + - name: Check the description a registry will render + env: + DIST_PATH: ${{ inputs.dist-path }} + README_PATH: ${{ inputs.readme-path }} + 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 "$README_PATH") + else + source_args=(--dist "$DIST_PATH") + fi + python .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..f3165fe979 --- /dev/null +++ b/CHANGELOG.d/20260920-strix-trusted-binder-runtime-fixture.md @@ -0,0 +1,17 @@ +### 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index d90fa0c899..f48e5c345b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,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..cfe44565db --- /dev/null +++ b/docs/doctoring/package-description-boundary.md @@ -0,0 +1,163 @@ +# 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. When `build` requests an sdist or wheel, a missing or empty dist +path is an error from the central gate; only the explicit `build: none` mode may +inspect a 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. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6e5f1c549a..461ab7e8a5 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3438,6 +3438,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 ccf08f48eb..536a7a5019 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/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 80c4832243..70f12c604d 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -503,6 +503,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" @@ -985,6 +987,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="$( @@ -3312,6 +3316,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' @@ -5794,6 +5801,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" @@ -6944,6 +6952,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" \ @@ -8034,6 +8045,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)" @@ -9839,6 +9987,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/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..f6dc0363f4 --- /dev/null +++ b/tests/test_package_description_boundary.py @@ -0,0 +1,397 @@ +"""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) and `src/pkg/x.py`\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 {f["rule"] for f in report["findings"]} == {"relative-link", "source-path"} + + +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_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_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_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.""" + steps = _workflow()["jobs"]["package-description-boundary"]["steps"] + checkouts = [step for step in steps if str(step.get("uses", "")).startswith("actions/checkout@")] + assert len(checkouts) == 2 + assert checkouts[0].get("with", {}).get("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 "$README_PATH")' in run + assert 'source_args=(--dist "$DIST_PATH")' in run + assert '[ -d "$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)