diff --git a/.changeset/issue-1388-lint-provisioning.md b/.changeset/issue-1388-lint-provisioning.md new file mode 100644 index 0000000000..37285812c3 --- /dev/null +++ b/.changeset/issue-1388-lint-provisioning.md @@ -0,0 +1,18 @@ +--- +bump: patch +type: Added +--- + +- **Provision the bounded lint toolchain before the model runs.** The installer now ships the + lint manifest and publishes a digest-bound compatibility marker (`.prflow/install-state.json`) + only after validating the staged tuple of manifest, readers, setup action, and implement + workflow. `setup-project-env` gains a closed `lint_mode` input (`provision` installs the + manifest's ShellCheck/Ruff set run-local, digest- and version-verified, before the Claude + action; `none` does no lint work and validates no manifest; an unknown value is refused), wired + `none`/`provision`/`none` across `devflow.yml`/`devflow-implement.yml`/`devflow-runner.yml`. The + review runner hardens its setup invocation by materializing trusted base-ref bytes over the + composite-action directory before it runs, so the read-only review job executes the base-ref + action body rather than a PR-head edit, and CI validates and exercises the candidate manifest + with no repository write credentials. An unsupported platform degrades with a warning instead of + failing, and a version-verified pre-provisioned runner-image tool is reused instead of + downloaded. (#1963) diff --git a/.github/actions/setup-project-env/action.yml b/.github/actions/setup-project-env/action.yml index e9a434aacb..9497e28f66 100644 --- a/.github/actions/setup-project-env/action.yml +++ b/.github/actions/setup-project-env/action.yml @@ -25,6 +25,14 @@ inputs: config_json: description: '.prflow/config.json contents, as emitted by the read-project-config action (steps..outputs.json). The automated reviewer (devflow-runner.yml, provision_env path) instead passes the TRUSTED base-ref config it reads directly via git show + `jq -c` — a different provenance but the same shape (a JSON object with a `.setup` block), so this action consumes it identically.' required: true + lint_mode: + description: >- + Lint-tool provisioning mode. Closed set: "provision" installs the + manifest's bounded ShellCheck/Ruff toolchain (run-local, no sudo, digest + and version verified) before the Claude action; "none" does no lint-tool + work and validates no manifest. Any other value is refused. + required: false + default: 'none' outputs: health_summary: @@ -223,6 +231,70 @@ runs: } >> "$GITHUB_OUTPUT" fi + # The hashFiles(manifest, marker) key covers the whole AC5 tuple transitively — + # do not add tuple components to it by hand. A restored $DEST_BIN is still + # re-verified by the provisioning step: a cache hit is never verification. + - name: Cache the provisioned lint toolchain + if: inputs.lint_mode == 'provision' + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/prflow-lint-bin + key: lintprov-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.prflow/lint-manifest.json', '.prflow/install-state.json') }} + + # Must stay ordered BEFORE the Claude action (provisioning after model start + # defeats #1388), and the os/arch → artifact/URL mapping stays in the trusted + # Python helpers — never manifest-supplied strings (#1276 trust model). + - name: Provision lint toolchain (before model) + shell: bash + env: + LINT_MODE: ${{ inputs.lint_mode }} + run: | + set -euo pipefail + case "$LINT_MODE" in + none) + echo "lint_mode=none: no lint-tool provisioning and no manifest validation." + exit 0 + ;; + provision) ;; + *) + echo "::error::setup-project-env: unknown lint_mode '$LINT_MODE' (closed set: provision | none)." + exit 1 + ;; + esac + # Resolve the trusted bundled helpers: this repo's own scripts/ (self / + # committed), else the vendored slice a thin consumer materializes. + # Probe BOTH helpers the provisioning step invokes, and refuse when neither + # candidate carries them: probing only lint_provision.py let a failed vendor + # materialization fall through and surface as a marker-readiness refusal, + # sending the operator to re-run an installer that was never the problem. + SCRIPTS_DIR="" + for _cand in "scripts" ".prflow/vendor/prflow/scripts"; do + if [ -f "$_cand/lint_provision.py" ] && [ -f "$_cand/install_state.py" ]; then + SCRIPTS_DIR="$_cand" + break + fi + done + if [ -z "$SCRIPTS_DIR" ]; then + echo "::error::setup-project-env: neither scripts/ nor .prflow/vendor/prflow/scripts/ carries both lint_provision.py and install_state.py — the plugin vendor step did not materialize the bundled helpers." + exit 1 + fi + case "${RUNNER_OS:-}" in + Linux) TARGET_OS=linux ;; + macOS) TARGET_OS=macos ;; + Windows) TARGET_OS=windows ;; + *) echo "::error::setup-project-env: unsupported RUNNER_OS '${RUNNER_OS:-}'"; exit 1 ;; + esac + case "${RUNNER_ARCH:-}" in + X64) TARGET_ARCH=x86_64 ;; + ARM64) TARGET_ARCH=arm64 ;; + *) echo "::error::setup-project-env: unsupported RUNNER_ARCH '${RUNNER_ARCH:-}'"; exit 1 ;; + esac + export LINT_MANIFEST=".prflow/lint-manifest.json" + export INSTALL_STATE=".prflow/install-state.json" + export DEST_BIN="${RUNNER_TEMP:-/tmp}/prflow-lint-bin" + export TARGET_OS TARGET_ARCH SCRIPTS_DIR + bash "$GITHUB_ACTION_PATH/provision-lint-tools.sh" + - name: Provision project dependencies shell: bash env: diff --git a/.github/actions/setup-project-env/provision-lint-tools.sh b/.github/actions/setup-project-env/provision-lint-tools.sh new file mode 100755 index 0000000000..610ed997cb --- /dev/null +++ b/.github/actions/setup-project-env/provision-lint-tools.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Daniel Radman +# SPDX-License-Identifier: MIT +# ============================================================================ +# provision-lint-tools.sh — install the manifest's bounded lint toolchain BEFORE +# the model runs (issue #1388). +# ============================================================================ +# Manifest validation, platform resolution, and the compatibility-marker +# readiness gate live in the Python helpers (scripts/lint_provision.py, +# scripts/install_state.py); this script orchestrates: gate on readiness, +# resolve each tool's artifact, download → verify the pinned ARCHIVE digest → +# extract → install run-local (NO sudo) → verify the executable reports the +# pinned version. A binary already at the destination, or on PATH, is reused +# only after re-passing that version check. Every INTEGRITY failure fails +# CLOSED naming the tool, before the model runs — missing installer primitive, +# checksum mismatch, archive that will not extract, wrong version, network +# failure, unwritable target, unknown tool. An unsupported platform tuple +# degrades instead: reuse a version-matching PATH tool, else warn and continue. +# +# Required environment: +# LINT_MANIFEST path to .prflow/lint-manifest.json +# INSTALL_STATE path to .prflow/install-state.json (the compatibility marker) +# DEST_BIN directory to install the tool executables into (added to PATH) +# TARGET_OS linux | macos | windows +# TARGET_ARCH x86_64 | arm64 +# SCRIPTS_DIR directory holding lint_provision.py + install_state.py +# Optional (overridable so lib/test can drive the fail-closed arms offline): +# INSTALLER_VERSION overrides the marker's installer_version (cache-key component); +# derived from the marker after the readiness gate when unset +# TOOLS space-separated tool list (default: the manifest's own tool set) +# LINTPROV_PYTHON python3 interpreter (default python3) +# LINTPROV_CURL downloader; called as "$LINTPROV_CURL" -fsSL -o OUT URL (default curl) +# LINTPROV_TAR tar extractor (default tar) +# LINTPROV_UNZIP zip extractor (default unzip) +# LINTPROV_SKIP_PATH_REUSE set to 1 to skip the pre-provisioned-runner PATH +# reuse check on an established plan, forcing the download +# path (the unsupported-plan PATH check is unaffected) +# ============================================================================ +set -euo pipefail + +PY="${LINTPROV_PYTHON:-python3}" +CURL="${LINTPROV_CURL:-curl}" +TAR="${LINTPROV_TAR:-tar}" +UNZIP="${LINTPROV_UNZIP:-unzip}" +# TOOLS is derived from the validated manifest below, after the readiness gate. An +# explicit value still wins (the suite drives one tool at a time). + +# Set to the in-flight work directory while one exists; _die removes it. `exit` does +# NOT run a RETURN trap, so every fail-closed arm leaked its mktemp -d without this, +# and the suite drives this helper repeatedly in one process. +_WORKDIR="" + +_die() { + # $1 = tool (or "-"), $2 = reason. One diagnostic per fail-closed arm so a + # reader can tell which tool and which condition detonated. + [ -n "$_WORKDIR" ] && rm -rf "$_WORKDIR" + printf 'provision-lint-tools: %s: %s\n' "$1" "$2" >&2 + exit 1 +} + +_have() { command -v "$1" >/dev/null 2>&1; } + +# Match the pinned version as a WHOLE token in the tool's --version output, never a +# substring: pinned "1.2" must NOT match reported "1.24.1". Portable ERE token +# boundary (no GNU \b, which BSD grep silently ignores). $1 = reported text, $2 = version. +_version_token_match() { + local esc="${2//./\\.}" + printf '%s' "$1" | grep -Eq "(^|[^0-9.])${esc}([^0-9.]|\$)" +} + +# sha256 of a file via the trusted python interpreter (no sha256sum dependency — +# it is not preflight-guaranteed and diverges across BSD/GNU). +_digest() { + "$PY" - "$1" <<'PY' +import hashlib, sys +with open(sys.argv[1], "rb") as fh: + print("sha256:" + hashlib.sha256(fh.read()).hexdigest()) +PY +} + +for v in LINT_MANIFEST INSTALL_STATE DEST_BIN TARGET_OS TARGET_ARCH SCRIPTS_DIR; do + eval "val=\${$v:-}" + [ -n "$val" ] || _die - "missing required environment variable $v" +done + +_have "$PY" || _die - "installer primitive not found: python3 ($PY)" + +# Refuse the WHOLE pass before touching any tool: a component digest that +# disagrees means the readers and the manifest may not understand each other. +if ! ready="$("$PY" "$SCRIPTS_DIR/install_state.py" verify --state "$INSTALL_STATE" --manifest "$LINT_MANIFEST" 2>&1)"; then + _die - "install-state readiness refused: ${ready#NOT-READY } — remedy: re-run the PRFlow installer (install.sh), which republishes the marker over the components actually installed in this tree" +fi + +# Derive the tool set from the manifest the gate just validated, so the shipped set +# has ONE source: a hardcoded list that omitted a manifest tool left that tool +# silently never provisioned while the readiness gate still reported READY. +if [ -z "${TOOLS:-}" ]; then + TOOLS="$("$PY" -c 'import json,sys; print(" ".join(json.load(open(sys.argv[1]))["tools"]))' "$LINT_MANIFEST")" \ + || _die - "could not derive the tool set from the manifest" + [ -n "$TOOLS" ] || _die - "manifest declares no tools to provision" +fi + +# The marker validated above, so its installer_version is present and typed. An +# explicit INSTALLER_VERSION env overrides it (tests); otherwise derive it here. +INSTALLER_VERSION="${INSTALLER_VERSION:-}" +if [ -z "$INSTALLER_VERSION" ]; then + INSTALLER_VERSION="$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["installer_version"])' "$INSTALL_STATE")" \ + || _die - "could not read installer_version from the validated marker" +fi + +mkdir -p "$DEST_BIN" 2>/dev/null || _die - "unwritable target: cannot create $DEST_BIN" + +PROVISIONED="" +UNPROVISIONED="" + +_provision_one() { + local tool="$1" + local plan rc plan_err plan_err_file + # Keep stderr OUT of $plan: the tab-parse below splits $plan into fields, so + # interpreter noise merged via 2>&1 would corrupt the field split. + plan_err_file="$(mktemp)" + set +e + plan="$("$PY" "$SCRIPTS_DIR/lint_provision.py" plan \ + --manifest "$LINT_MANIFEST" --tool "$tool" --os "$TARGET_OS" --arch "$TARGET_ARCH" 2>"$plan_err_file")" + rc=$? + set -e + plan_err="$(<"$plan_err_file")" || plan_err="" + rm -f "$plan_err_file" + if [ "$rc" -eq 3 ]; then + local unsupported_version sys_unsupported + unsupported_version="$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["tools"][sys.argv[2]]["version"])' \ + "$LINT_MANIFEST" "$tool" 2>/dev/null || true)" + sys_unsupported="$(command -v "$tool" 2>/dev/null || true)" + if [ -n "$sys_unsupported" ] && [ -n "$unsupported_version" ] \ + && _version_token_match "$("$sys_unsupported" --version 2>&1 || true)" "$unsupported_version"; then + printf 'provision-lint-tools: %s: reused pre-provisioned %s (%s) from the runner image\n' \ + "$tool" "$sys_unsupported" "$unsupported_version" + PROVISIONED="$PROVISIONED $tool" + return 0 + fi + printf 'provision-lint-tools: %s: unsupported-lint-platform (%s/%s); continuing without provisioning this tool\n' \ + "$tool" "$TARGET_OS" "$TARGET_ARCH" >&2 + printf '::warning::provision-lint-tools: %s: unsupported-lint-platform (%s/%s) and no pre-provisioned %s at pinned version %s on PATH; continuing without provisioning this tool\n' \ + "$tool" "$TARGET_OS" "$TARGET_ARCH" "$tool" "${unsupported_version:-unknown}" >&2 + UNPROVISIONED="$UNPROVISIONED $tool" + return 0 + elif [ "$rc" -eq 4 ]; then + # Unknown tool ≠ unsupported platform: nothing can provision it, so skipping + # it like a platform gap would silently drop a lint the manifest never covers. + _die "$tool" "unknown-lint-tool: not in the resolver's known tool set" + elif [ "$rc" -ne 0 ]; then + _die "$tool" "manifest resolution failed: ${plan}${plan_err:+ ${plan_err}}" + fi + + local digest archive_type member strategy version url + IFS=$'\t' read -r digest archive_type member strategy version url <<<"$plan" + [ -n "$url" ] || _die "$tool" "manifest resolution returned no download URL" + + # cache_key appears in log lines ONLY — the cross-run cache gate is action.yml's + # hashFiles key; do not wire this value into cache restore/save logic. + local cache_key + cache_key="$("$PY" "$SCRIPTS_DIR/lint_provision.py" cache-key \ + --manifest "$LINT_MANIFEST" --tool "$tool" --os "$TARGET_OS" --arch "$TARGET_ARCH" \ + --installer-version "$INSTALLER_VERSION")" \ + || _die "$tool" "cache-key computation failed" + + local dest="$DEST_BIN/$member" + + # Never reuse a cached executable without re-running the version check: the cache + # slot is keyed on the tuple, but a restored binary is otherwise unverified bytes. + if [ -x "$dest" ]; then + local cached_ver + cached_ver="$("$dest" --version 2>&1 || true)" + if _version_token_match "$cached_ver" "$version"; then + printf 'provision-lint-tools: %s: reused verified install (%s, key %s)\n' "$tool" "$version" "$cache_key" + PROVISIONED="$PROVISIONED $tool" + return 0 + fi + fi + + # PATH, not $dest — this must never substitute for the cache-restore check above. + # LINTPROV_SKIP_PATH_REUSE=1 keeps the download path exercised in tests. + if [ "${LINTPROV_SKIP_PATH_REUSE:-}" != "1" ]; then + local sys + sys="$(command -v "$tool" 2>/dev/null || true)" + if [ -n "$sys" ] && [ "$sys" != "$dest" ] \ + && _version_token_match "$("$sys" --version 2>&1 || true)" "$version"; then + printf 'provision-lint-tools: %s: reused pre-provisioned %s (%s) from the runner image\n' \ + "$tool" "$sys" "$version" + PROVISIONED="$PROVISIONED $tool" + return 0 + fi + fi + + # Installer primitives for this artifact's strategy. + _have "$CURL" || _die "$tool" "installer primitive not found: downloader ($CURL)" + case "$strategy" in + extract-tar) _have "$TAR" || _die "$tool" "installer primitive not found: tar ($TAR)" ;; + extract-zip) _have "$UNZIP" || _die "$tool" "installer primitive not found: unzip ($UNZIP)" ;; + *) _die "$tool" "unknown extraction strategy $strategy" ;; + esac + + local work archive extract_dir + work="$(mktemp -d)" + _WORKDIR="$work" + # shellcheck disable=SC2064 + trap "rm -rf '$work'; _WORKDIR=''" RETURN + archive="$work/artifact" + extract_dir="$work/x" + mkdir -p "$extract_dir" + + # Download — a network failure fails closed naming the tool. + "$CURL" -fsSL -o "$archive" "$url" || _die "$tool" "network failure downloading $url" + [ -s "$archive" ] || _die "$tool" "network failure: empty download from $url" + + # Verify the pinned digest BEFORE extracting — a checksum mismatch is a + # supply-chain refusal, not a warning. + local got + got="$(_digest "$archive")" || _die "$tool" "digest computation failed" + [ "$got" = "$digest" ] || _die "$tool" "checksum mismatch: expected $digest got $got" + + # Extract per the closed strategy — an archive that will not extract is refused. + case "$strategy" in + extract-tar) "$TAR" -xf "$archive" -C "$extract_dir" 2>/dev/null || _die "$tool" "archive mismatch: $archive_type archive did not extract" ;; + extract-zip) "$UNZIP" -q -o "$archive" -d "$extract_dir" 2>/dev/null || _die "$tool" "archive mismatch: $archive_type archive did not extract" ;; + esac + + # Locate the member anywhere in the extracted tree (upstream archives nest it + # under a versioned directory) and install it run-local, no sudo. + local found + found="$(find "$extract_dir" -type f -name "$member" -print 2>/dev/null | head -n 1 || true)" + [ -n "$found" ] || _die "$tool" "archive mismatch: member $member not found in archive" + install -m 0755 "$found" "$dest" 2>/dev/null || cp "$found" "$dest" 2>/dev/null \ + || _die "$tool" "unwritable target: cannot install into $DEST_BIN" + chmod 0755 "$dest" 2>/dev/null || _die "$tool" "unwritable target: cannot chmod $dest" + + # Verify the installed executable reports the manifest's exact version (one exec). + local reported + reported="$("$dest" --version 2>&1)" || _die "$tool" "installed executable is not runnable" + _version_token_match "$reported" "$version" \ + || _die "$tool" "wrong version: $dest does not report $version" + + printf 'provision-lint-tools: %s: installed %s (%s), version-verified (key %s)\n' \ + "$tool" "$member" "$version" "$cache_key" + PROVISIONED="$PROVISIONED $tool" +} + +for tool in $TOOLS; do + _provision_one "$tool" +done + +# Put the provisioned tools on PATH for later steps (before the model runs). +if [ -n "${GITHUB_PATH:-}" ]; then + printf '%s\n' "$DEST_BIN" >> "$GITHUB_PATH" +fi +# Report only what actually landed: a tool that took the unsupported-platform +# degrade must not be listed as provisioned beside its own ::warning::. +if [ -n "$UNPROVISIONED" ]; then + printf 'provision-lint-tools: readiness verified; provisioned:%s; unprovisioned (degraded):%s\n' \ + "${PROVISIONED:- (none)}" "$UNPROVISIONED" +else + printf 'provision-lint-tools: readiness verified; provisioned:%s\n' "${PROVISIONED:- (none)}" +fi diff --git a/.github/actions/vendor-plugin/vendor-slice.sh b/.github/actions/vendor-plugin/vendor-slice.sh index e99a933525..e9322de2e1 100755 --- a/.github/actions/vendor-plugin/vendor-slice.sh +++ b/.github/actions/vendor-plugin/vendor-slice.sh @@ -75,8 +75,12 @@ devflow_copy_slice() { # Only the committed templates/registry — not the whole .prflow/ tree (which # would drag in learnings/ and a possibly-dirty config.json). mkdir -p "$stage/.prflow" + # The lint manifest and its digest-bound compatibility marker ship (issue #1388): + # the setup action's provisioning phase reads .prflow/lint-manifest.json and gates + # on .prflow/install-state.json, so a consumer that lacks them cannot provision. cp "$src/.prflow/config.example.json" "$src/.prflow/config.schema.json" \ - "$src/.prflow/tool-presets.json" \ + "$src/.prflow/tool-presets.json" "$src/.prflow/lint-manifest.json" \ + "$src/.prflow/install-state.json" \ "$stage/.prflow/" # The vendored copy is a plugin, not a marketplace — keep only plugin.json. rm -f "$stage/.claude-plugin/marketplace.json" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea7624f811..9f05a8735a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,61 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # Never give this job write credentials or an App token (issue #1388): it exercises + # a lint manifest supplied by the PR head, which is untrusted until merged. + lint-manifest: + name: lint-manifest (validate + exercise, no write creds) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Validate the candidate manifest (strict reader/validator) + run: python3 scripts/lint_manifest.py .prflow/lint-manifest.json + - name: Exercise the candidate manifest (platform matrix + marker readiness + drift gate) + run: | + set -euo pipefail + # Every declared (tool, os, arch) resolves deterministically to an + # `established` plan or `unsupported-lint-platform` — never a crash. + python3 - <<'PY' + import importlib.util, itertools, sys + from pathlib import Path + def _load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); return m + lp = _load("lint_provision", Path("scripts/lint_provision.py")) + manifest = Path(".prflow/lint-manifest.json") + established = 0 + for tool, os_name, arch in itertools.product(lp.KNOWN_TOOLS, lp.KNOWN_OS, lp.KNOWN_ARCH): + plan = lp.build_plan(manifest, tool, os_name, arch) + assert plan.status in ("established", "unsupported"), (tool, os_name, arch, plan.status, plan.reason) + if plan.status == "established": + assert plan.url and plan.digest.startswith("sha256:") + established += 1 + assert established > 0, "no (tool, os, arch) tuple resolved — manifest declares no artifacts" + print(f"exercised the manifest matrix: {established} established tuples") + PY + # The compatibility marker is READY against the tree, and it is in sync + # with its bound components. + python3 scripts/install_state.py verify --state .prflow/install-state.json --manifest .prflow/lint-manifest.json + python3 lib/generate-install-state.py --check + - name: Exercise a real provisioning pass (linux-x86_64, real downloads + digest verify) + env: + LINT_MANIFEST: .prflow/lint-manifest.json + INSTALL_STATE: .prflow/install-state.json + DEST_BIN: ${{ runner.temp }}/lint-exercise-bin + TARGET_OS: linux + TARGET_ARCH: x86_64 + SCRIPTS_DIR: scripts + LINTPROV_SKIP_PATH_REUSE: "1" + run: bash .github/actions/setup-project-env/provision-lint-tools.sh + - name: Assert the provisioned binaries run + run: | + "$RUNNER_TEMP"/lint-exercise-bin/shellcheck --version + "$RUNNER_TEMP"/lint-exercise-bin/ruff --version + # The suite is split across concurrent SHARD jobs (issue #877) so wall-clock is # bounded by the slowest shard rather than the sum. Each shard runs a subset via # lib/test/run-shard.sh — the `monolith` shard runs lib/test/run.sh with the module diff --git a/.github/workflows/devflow-implement.yml b/.github/workflows/devflow-implement.yml index 52314b6326..638d4f82ef 100644 --- a/.github/workflows/devflow-implement.yml +++ b/.github/workflows/devflow-implement.yml @@ -726,6 +726,10 @@ jobs: - uses: ./.github/actions/setup-project-env with: config_json: ${{ steps.cfg.outputs.json }} + # Install the manifest's bounded ShellCheck/Ruff toolchain before the + # model runs, so implement runs start with verified lint tools instead + # of improvising installs in paid turns (issue #1388). + lint_mode: provision # ── Long-run credential refresh (issue #487) ────────────────────────── # A GitHub App installation token expires 60 minutes after minting and diff --git a/.github/workflows/devflow-runner.yml b/.github/workflows/devflow-runner.yml index e174c92834..8c44bb90cf 100644 --- a/.github/workflows/devflow-runner.yml +++ b/.github/workflows/devflow-runner.yml @@ -1390,21 +1390,102 @@ jobs: adelim="OUT_EOF_$(date +%s%N)_$$" { printf 'args<<%s\n' "$adelim"; printf '%s\n' "$ARGS"; printf '%s\n' "$adelim"; } >> "$GITHUB_OUTPUT" + # A local `uses: ./…` action runs the WORKSPACE copy, and this job checks out + # the PR head — so never let the trusted bytes fall back to the PR-head copy, + # or a PR editing this action runs its own edit in the review job (issue #1388). + - name: Harden setup-project-env onto trusted base-ref bytes + id: hardensetup + if: steps.baseprovision.outputs.provision_env == 'true' + shell: bash + env: + # Same trusted derivation as baseprovision's BASE_REF — the PR's target + # branch, never a PR-controlled value. + BASE_REF: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch }} + run: | + set -euo pipefail + [ -n "$BASE_REF" ] || { echo "::error::no BASE_REF to harden setup-project-env against"; exit 1; } + # Keep the fetch's own diagnostic: this is a fail-closed security arm, and + # discarding stderr leaves an operator with no way to tell a missing ref from + # a transport failure on the step that aborts every review run. + if ! _fetch_err=$(git fetch --depth=1 origin "$BASE_REF" 2>&1 >/dev/null); then + echo "::error::could not fetch base ref '$BASE_REF' to harden setup-project-env: ${_fetch_err:-no diagnostic}" + exit 1 + fi + dir=".github/actions/setup-project-env" + # Enumerate the action's files as they exist on the TRUSTED base ref and + # materialize EVERY one over the PR-head copy, so a file the action gains + # later is covered with no edit to this step (a hardcoded file list would + # silently leave a new PR-head helper executable in the review job). Then + # prune any PR-head file NOT present on the base ref, so no PR-authored + # addition survives. action.yml must exist on the base ref or the action + # cannot run. + # while-read, not mapfile: a bash-3.2 self-hosted macOS consumer runner has + # no mapfile, and this security step failing there would abort every review. + base_files=() + while IFS= read -r bf; do base_files+=("$bf"); done < <(git ls-tree -r --name-only FETCH_HEAD -- "$dir") + [ "${#base_files[@]}" -gt 0 ] || { echo "::error::base ref '$BASE_REF' carries no $dir; refusing to run a PR-head action body"; exit 1; } + for f in "${base_files[@]}"; do + mkdir -p "$(dirname "$f")" + if git show "FETCH_HEAD:$f" > "$f.trusted" 2>/dev/null; then + mv "$f.trusted" "$f" + else + rm -f "$f.trusted" + echo "::error::could not materialize trusted $f from base ref '$BASE_REF'"; exit 1 + fi + done + # Membership by bash builtin, never `grep`: preflight guarantees no grep, and + # a missing one fails every test on the left of `||`, where set -e cannot + # fire, and prunes every trusted file this step just materialized. + _is_base_file() { + local cand=$1 bf + for bf in "${base_files[@]}"; do + [ "$bf" = "$cand" ] && return 0 + done + return 1 + } + # A newline-accumulated string, not an array: under `set -u` bash 3.2 treats + # ${#arr[@]} on a never-assigned-to array as unbound and aborts this + # security step on the ordinary nothing-to-prune run. + pruned="" + while IFS= read -r head_f; do + if ! _is_base_file "$head_f"; then + rm -f "$head_f" + pruned="${pruned}${head_f}"$'\n' + fi + done < <(git ls-files -- "$dir") + [ -f "$dir/action.yml" ] || { echo "::error::base ref '$BASE_REF' carries no $dir/action.yml; refusing to run a PR-head action body"; exit 1; } + chmod +x "$dir"/*.sh 2>/dev/null || true + # Disclose what this step displaced. Without it the reviewing agent reads + # these base-ref bytes as untouched PR-head content — on exactly the file a + # PR editing this action is under review for. + if [ -n "${GITHUB_OUTPUT:-}" ]; then + _hs_d="HARDENSETUP_EOF_$(date +%s%N)_$$" + { + printf 'displaced_setup_paths<<%s\n' "$_hs_d" + printf '%s\n' "${base_files[@]}" + [ -n "$pruned" ] && printf '%s' "$pruned" + printf '%s\n' "$_hs_d" + } >> "$GITHUB_OUTPUT" + fi + echo "hardened setup-project-env from trusted base ref '$BASE_REF'" + # Provision the runtime BEFORE Claude runs, but ONLY when the trusted base # config opted in via prflow_runner.provision_env. config_json is the # BASE ref's config (steps.baseprovision), never the PR head, so PR edits # to `setup.install` cannot inject commands here — the same trust boundary - # setup-project-env already documents for `services`. (The action body and - # the install commands still execute from / against the PR checkout once - # enabled; that residual vector is the documented opt-in — see - # docs/internal/cloud-setup.md.) When the flag is absent/false this step is skipped - # and the runner behaves exactly as before: read-only, no build tools. + # setup-project-env already documents for `services`. The action BODY is now + # the trusted base-ref copy too (hardensetup above), closing the residual + # PR-head-action-body vector for the review job. lint_mode is `none`: no + # manifest-derived bytes may enter the read-only review job (issue #1388). + # When the flag is absent/false this step is skipped and the runner behaves + # exactly as before: read-only, no build tools. - name: Provision project environment (opt-in) id: provision if: steps.baseprovision.outputs.provision_env == 'true' uses: ./.github/actions/setup-project-env with: config_json: ${{ steps.baseprovision.outputs.config_json }} + lint_mode: none # Provisioning is best-effort: a service container that never becomes # healthy emits a `::warning::` and the job continues (see @@ -2008,7 +2089,7 @@ jobs: printf '%s\n' "$_df_d" } >> "$GITHUB_OUTPUT" - # Join the two displaced-path producers (issue #874). HARDENED_PATHS used to + # Join every displaced-path producer (issue #874). HARDENED_PATHS used to # bind a SINGLE step's output, and harden_hooks publishes that output EMPTY on # its relevance-gate skip arm — so the truncated prompt-extension paths would # vanish from the grounding block on exactly the consumer runs where the @@ -2038,6 +2119,10 @@ jobs: # the case this producer exists for, and it is a DevFlow-repo case, not the # consumer arm. The consumer below already handles empty (`[ -n "$GUARD_PATHS" ]`). GUARD_PATHS: ${{ steps.harden_guard.outputs.guard_paths }} + # Fourth producer (issue #1388), legitimately empty when hardensetup's + # `provision_env` gate skipped it. Omitting it let the reviewing agent read + # base-ref bytes as untouched PR-head content. + SETUP_PATHS: ${{ steps.hardensetup.outputs.displaced_setup_paths }} run: | set -euo pipefail _j_d="JOINED_EOF_$(date +%s%N)_$$" @@ -2048,6 +2133,7 @@ jobs: [ -n "$HOOK_PATHS" ] && printf '%s\n' "$HOOK_PATHS" [ -n "$EXT_PATHS" ] && printf '%s\n' "$EXT_PATHS" [ -n "$GUARD_PATHS" ] && printf '%s\n' "$GUARD_PATHS" + [ -n "$SETUP_PATHS" ] && printf '%s\n' "$SETUP_PATHS" printf '%s\n' "$_j_d" } >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/devflow.yml b/.github/workflows/devflow.yml index 9be4dc2dd5..57ad4444a9 100644 --- a/.github/workflows/devflow.yml +++ b/.github/workflows/devflow.yml @@ -1041,6 +1041,9 @@ jobs: - uses: ./.github/actions/setup-project-env with: config_json: ${{ steps.cfg.outputs.json }} + # The command tier does no linting itself; lint provisioning is the + # implement tier's concern (issue #1388). + lint_mode: none # Optional, opt-in: for the `/devflow:review` command ONLY, mint a # DEDICATED DevFlow-Reviewer installation token DOWNSCOPED to the review diff --git a/.gitignore b/.gitignore index ca75fca602..07373f8f8f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,12 +51,18 @@ # description of the bounded lint toolchain (exact ShellCheck/Ruff versions, per # platform artifact digests, selectors, exclusions, special-invocation IDs, # timeout bounds, full-profile IDs). Re-included past the /.prflow/* rule so a -# clean checkout / ordinary `git add -A` keeps it tracked. It is NOT shipped to -# consumers: devflow_copy_slice copies an explicit three-file list from .prflow/ -# (config.example.json, config.schema.json, tool-presets.json), so a consumer -# installs scripts/lint_manifest.py with no manifest for it to read. Whether the -# manifest should ship is issue #1388's decision. +# clean checkout / ordinary `git add -A` keeps it tracked. Since issue #1388 it +# SHIPS to consumers: devflow_copy_slice copies it (and install-state.json) beside +# config.example.json/config.schema.json/tool-presets.json, and install.sh copies +# it to the consumer's .prflow/lint-manifest.json so the setup action can read it. !/.prflow/lint-manifest.json +# Committed digest-bound compatibility-tuple marker (issue #1388) — binds the lint +# manifest, its readers, the setup action, the provisioning helper, and the shipped +# implement workflow by sha256 so the setup action refuses provisioning on any skew. +# Re-included past /.prflow/* (same as the manifest) so a clean checkout and CI both +# track it; regenerated by lib/generate-install-state.py. It DOES ship to consumers +# (devflow_copy_slice copies it), where install.sh republishes it over runtime paths. +!/.prflow/install-state.json # DevFlow's own consumer prompt extensions (e.g. the dogfooded versioning rule in # implement.md). Tracked so a clean checkout — and CI — sees the re-homed policy. !/.prflow/prompt-extensions/ diff --git a/.prflow/install-state.json b/.prflow/install-state.json new file mode 100644 index 0000000000..fadf908646 --- /dev/null +++ b/.prflow/install-state.json @@ -0,0 +1,34 @@ +{ + "schema_version": 1, + "installer_version": "main", + "components": { + "manifest": { + "path": ".prflow/lint-manifest.json", + "digest": "sha256:b6bc3d678a1517156c4ed9c4bd0b75fe8ee3ca342410088da14435eee945028f" + }, + "manifest-reader": { + "path": "scripts/lint_manifest.py", + "digest": "sha256:ec30a9d8e389e5fc7e602c9ff2fa1857285a5106288cbb65da4a918f147a679a" + }, + "lint-provision": { + "path": "scripts/lint_provision.py", + "digest": "sha256:82533dc770f2b6735e4b0165505718daed689aa49b89f4d2eb6d987003f10442" + }, + "install-state-reader": { + "path": "scripts/install_state.py", + "digest": "sha256:6736c1a23b5499bd7a1e3f3c740922754322b0414cb2ac9b38823e400a8e10ee" + }, + "setup-action": { + "path": ".github/actions/setup-project-env/action.yml", + "digest": "sha256:2bc7a047f2c817db6aeea5c4303c1d7a894f63eb36486c8c780f83e48082b935" + }, + "provision-helper": { + "path": ".github/actions/setup-project-env/provision-lint-tools.sh", + "digest": "sha256:7451f2340a4372212d24c8f9c7d1b4ecd57bb4311e4718ab1c9fb64460905f38" + }, + "implement-workflow": { + "path": ".github/workflows/devflow-implement.yml", + "digest": "sha256:5d30c74552da1672ac9f79a7b610c6ae8e92e4309e5a88ca0ce821dc4312adf8" + } + } +} diff --git a/.prflow/lint-manifest.json b/.prflow/lint-manifest.json index 0f9b3419b3..740b8d1ba2 100644 --- a/.prflow/lint-manifest.json +++ b/.prflow/lint-manifest.json @@ -8,7 +8,7 @@ { "os": "linux", "arch": "x86_64", - "digest": "sha256:6c881ab0698e4e6ea235245f22832860544f17ba386442fe7e9d629f8cbea39c", + "digest": "sha256:6c881ab0698e4e6ea235245f22832860544f17ba386442fe7e9d629f8cbedf87", "archive_type": "tar.xz", "member": "shellcheck", "strategy": "extract-tar" @@ -24,7 +24,7 @@ { "os": "macos", "arch": "x86_64", - "digest": "sha256:7d3730694707605d6e60cec4efcb79a0632d61babc035aa16cda1b897536acf5", + "digest": "sha256:ef27684f23279d112d8ad84e0823642e43f838993bbb8c0963db9b58a90464c2", "archive_type": "tar.xz", "member": "shellcheck", "strategy": "extract-tar" @@ -32,7 +32,7 @@ { "os": "macos", "arch": "arm64", - "digest": "sha256:00000000000000000000000000000000000000000000000000000000000000aa", + "digest": "sha256:bbd2f14826328eee7679da7221f2bc3afb011f6a928b848c80c321f6046ddf81", "archive_type": "tar.xz", "member": "shellcheck", "strategy": "extract-tar" @@ -40,7 +40,7 @@ { "os": "windows", "arch": "x86_64", - "digest": "sha256:00000000000000000000000000000000000000000000000000000000000000bb", + "digest": "sha256:eb6cd53a54ea97a56540e9d296ce7e2fa68715aa507ff23574646c1e12b2e143", "archive_type": "zip", "member": "shellcheck.exe", "strategy": "extract-zip" @@ -54,7 +54,7 @@ { "os": "linux", "arch": "x86_64", - "digest": "sha256:00000000000000000000000000000000000000000000000000000000000000c1", + "digest": "sha256:ed8ba4cac0c6dfc1c0e9c6c720daa5ea404a3bff0497a95d6e25293a7910e903", "archive_type": "tar.gz", "member": "ruff", "strategy": "extract-tar" @@ -62,7 +62,7 @@ { "os": "linux", "arch": "arm64", - "digest": "sha256:00000000000000000000000000000000000000000000000000000000000000c2", + "digest": "sha256:c61828a103c4de113620b5469b9612efdcf71557dd60006f70615f33c75b2546", "archive_type": "tar.gz", "member": "ruff", "strategy": "extract-tar" @@ -70,7 +70,7 @@ { "os": "macos", "arch": "x86_64", - "digest": "sha256:00000000000000000000000000000000000000000000000000000000000000c3", + "digest": "sha256:34aa37643e30dcb81a3c0e011c3a8df552465ea7580ba92ca727a3b7c6de25d1", "archive_type": "tar.gz", "member": "ruff", "strategy": "extract-tar" @@ -78,7 +78,7 @@ { "os": "macos", "arch": "arm64", - "digest": "sha256:00000000000000000000000000000000000000000000000000000000000000c4", + "digest": "sha256:b94562393a4bf23f1a48521f5495a8e48de885b7c173bd7ea8206d6d09921633", "archive_type": "tar.gz", "member": "ruff", "strategy": "extract-tar" @@ -86,7 +86,7 @@ { "os": "windows", "arch": "x86_64", - "digest": "sha256:00000000000000000000000000000000000000000000000000000000000000c5", + "digest": "sha256:9d10e1282c5f695b2130cf593d55e37266513fc6d497edc4a30a6ed6d8ba4067", "archive_type": "zip", "member": "ruff.exe", "strategy": "extract-zip" diff --git a/CLAUDE.md b/CLAUDE.md index 40597277a6..ccb7c9ab28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,6 +167,7 @@ This subsection and the two below are the **single home** of the suite-running p ## Conventions - **Portability:** no GNU-only flags. Use `python3` for date math (not `date -d`) and ERE / `sed -E` (not `grep -P`); helpers must work on macOS/BSD without GNU coreutils. **`\b` (word boundary) is a GNU extension, and its failure mode is silent:** BSD `sed` and BSD `grep -E` accept a pattern containing `\b` without error and simply match nothing, so a guard built on it *passes while checking nothing* on exactly the platform this convention targets. Use the portable `(^|[^a-zA-Z_])` instead. +- **Consumer runners are not `ubuntu-latest` — account for custom GitHub runners, including Windows (Git Bash) hosts, in every workflow, composite-action, or runner-provisioning change.** The shipped workflows resolve `runs-on` from the consumer's `DEVFLOW_RUNNER` repo variable, so they reach self-hosted Linux/macOS/Windows and arm64 runners with **no workflow edit** — a step that assumes image-preinstalled tools (`unzip`, `sudo`, GNU coreutils), Linux-only paths, or full control of the platform matrix silently breaks those consumers. On a platform the change does not cover, degrade gracefully (warn-and-continue, or reuse a version-verified pre-provisioned tool from the runner image) rather than failing closed; reserve fail-closed for integrity checks (digest/version mismatches), where it stays mandatory. - **A shell snippet in a skill file must survive a non-bash interactive shell — and the unmatched glob is the trap.** Fenced blocks under `skills/` are prose an agent runs verbatim in whatever shell its harness supplies, commonly **zsh**; zsh's default `nomatch` makes an unmatched filename pattern a refusal of *that one command*, which it skips before carrying on. The harm is a **silently empty enumeration** — the step meant to list something emits nothing, and no surrounding prose distinguishes "there is nothing here" from "the shell declined to look". Put one line beside the glob, inside the same block: `[ -n "${ZSH_VERSION:-}" ] && setopt nonomatch || :` (a no-op on every other shell), and better still report the empty case explicitly (`… 2>/dev/null || echo "(none)"`). The mechanical backstop is `lib/test/lint-skills-glob-guard.py`, discharged by that guard line or a `# glob-ok: ` marker; it claims no completeness, and its recognised shape and disclosed residuals live in its own docstring. The sibling rule from the same class: no bash-only builtin (`compgen`) in a `skills/` prose block. - **No secrets, owner-specific IDs, or product names in committed files.** - New `.py`/`.sh` files carry the SPDX header (`SPDX-FileCopyrightText: 2026 Daniel Radman` / `SPDX-License-Identifier: MIT`). diff --git a/docs/external/docs/runs/cloud/installation.md b/docs/external/docs/runs/cloud/installation.md index c937d82b8c..7fe88e2f39 100644 --- a/docs/external/docs/runs/cloud/installation.md +++ b/docs/external/docs/runs/cloud/installation.md @@ -39,6 +39,7 @@ A first installation applies immediately unless you pass `--dry-run` or set `DEV - `.claude-plugin/marketplace.json`. - `.prflow/config.json`, `.prflow/config.schema.json`, `.prflow/.gitignore` and prompt-extension examples. - `.prflow/install-manifest.json`, when Python can record managed-artifact digests. +- `.prflow/lint-manifest.json` and `.prflow/install-state.json`, which let issue-implementation runs provision their lint tools from a verified, digest-bound set before the agent starts. - Repository ignore rules for installer sidecars. Fresh installations do not receive `devflow-review.yml`, `devflow-runner.yml` or `telemetry-push.yml`. Automatic pull-request-triggered review is withdrawn from new installs. Use a collaborator's `/prflow:review` comment instead. diff --git a/docs/external/release-notes.md b/docs/external/release-notes.md index d09cd731ef..7bf7c1f142 100644 --- a/docs/external/release-notes.md +++ b/docs/external/release-notes.md @@ -11,6 +11,7 @@ This page summarizes user-visible PRFlow changes. For a complete change history, ## August 25, 2026 +- **Improvement: Issue-implementation runs start with verified lint tools already installed.** The installer now ships a lint manifest and publishes a digest-bound compatibility marker to your repository, and `/prflow:implement` cloud runs install the pinned ShellCheck and Ruff set — run-local and digest- and version-verified — before the agent starts, so runs no longer spend paid turns rediscovering and installing those tools. The change also hardens the cloud review job so it can never execute the environment-setup action edited in the pull request under review. You get this by re-running the installer to refresh your workflows. [#1963](https://github.com/The01Geek/prflow/issues/1963) - **A completed `/prflow:implement` run can no longer silently leave a prompt-extension record unwritten.** Each run keeps one `prompt extension resolved: …` row per extension it consumes, and an unticked row is meant to be the run's deliberate record that it could not establish that extension's state. But ticking the row was a voluntary bookkeeping step, so a run that resolved an extension and simply forgot to record it produced the exact same unticked row as one that genuinely skipped it — and nothing caught the difference. Finalizing a run as `Complete` is now refused (naming each offending row) while any such row is both unticked and missing its `state not established` note, mirroring the existing refusal on an unticked acceptance criterion. A ticked row, an unticked row with that note, and an older workpad that predates these rows all still finish normally, and `Blocked`/`Failed` outcomes are unchanged. The effect is that an unticked extension row on a completed run is now trustworthy as a deliberate record rather than a possible oversight. You get this through the normal plugin update. [#1943](https://github.com/The01Geek/prflow/issues/1943) - **Fix: a review no longer runs against a partly loaded review engine.** `/prflow:review-and-fix` — and the review step inside `/prflow:implement`, which drives it — reads PRFlow's review engine from your repository as a file, and it used to accept whatever came back as long as it was readable. A file delivered in part was therefore indistinguishable from a whole one, so a run could assess your pull request against review stages that never arrived and still report a result. The run now confirms it reached the end of that file before acting on it, and where it cannot it stops with `engine-root: incomplete` and the path it read, having applied no fixes and produced no verdict. The shadow pass reads the engine the same way and reports the condition as a coverage gap rather than stopping; a review started with `/prflow:review` loads the engine through your client instead of reading it as a file and is unaffected. You get this through the normal plugin update. [#1603](https://github.com/The01Geek/prflow/issues/1603) diff --git a/docs/internal/cloud-setup.md b/docs/internal/cloud-setup.md index a19196b297..3d34411c2c 100644 --- a/docs/internal/cloud-setup.md +++ b/docs/internal/cloud-setup.md @@ -1274,6 +1274,69 @@ The `setup` block covers more than Python/Node, in this provisioning order can't infer. Review its additions before committing; service `env` and `install` lines run in CI from your committed (base-branch) config. +### Lint-tool provisioning (`setup-project-env`'s `lint_mode`, issue #1388) + +`setup-project-env` also carries a closed `lint_mode` input — `provision` or +`none`, and **any other value is refused** (`::error::`, before the model runs). +It is **not** a `.prflow/config.json` key; the calling workflow sets it, and the +three shipped workflows pass tested modes: + +| Workflow | `lint_mode` | Why | +| --- | --- | --- | +| `devflow-implement.yml` (`/prflow:implement`) | `provision` | Implement runs need ShellCheck/Ruff, so they start with verified tools instead of improvising installs in paid turns. | +| `devflow.yml` (light `/prflow:*` command tier) | `none` | The command tier does no linting of its own. | +| `devflow-runner.yml` (automated review runner) | `none` | The read-only reviewer takes in **no manifest-derived bytes** (a security boundary). | + +- **`provision`** — before the Claude action, the action installs the bounded + ShellCheck/Ruff toolchain named in `.prflow/lint-manifest.json`, gated on the + digest-bound `.prflow/install-state.json` marker. Installation is **run-local + (no `sudo`)**, verifies the pinned archive digest before extracting and the + executable version (whole-token) before treating the tool as ready, and + **fails closed** — before the model runs — on any readiness or verification + failure. The readiness reasons (`install-state-missing`, `digest-mismatch`, + `component-missing`, `manifest-missing`) refuse the whole pass before any tool + is selected, so they name the component rather than a tool; the per-tool + failures (a checksum/archive/version mismatch, a network failure, an + unwritable target) name the tool. An unsupported OS/arch tuple + degrades instead: it reuses a version-matching tool already on PATH, else + warns and continues unprovisioned. The toolchain is cached via `actions/cache` keyed on `{OS, arch, manifest + + marker hash}`; a cache-restored binary is re-verified under that key before + use. The closed OS/arch → artifact + trusted-download-URL mapping lives in the + trusted Python helpers (`scripts/lint_provision.py`), never in + manifest-supplied command strings or URLs — the manifest declares *what* to + install, never *how* (issue #1276 trust model). +- **`none`** — no lint-tool work and **no manifest validation** at all. + +### The review runner never runs a PR-head setup action (issue #1388) + +`devflow-runner.yml` checks out the pull-request head, so a plain +`uses: ./.github/actions/setup-project-env` would execute the **PR's own copy** +of the action body — a PR editing that action could run its edit inside the +read-only review job. Gated on the same `prflow_runner.provision_env` opt-in as +the provisioning it protects, a hardening step runs first: it fetches the +trusted **base ref** (the same `BASE_REF` derivation `baseprovision` uses), +enumerates every file of the action directory as it exists on that base ref via +`git ls-tree FETCH_HEAD`, materializes each over the workspace copy, and +**prunes any PR-added file** not present on the base ref — so the subsequent +`uses:` step executes trusted bytes only, and a file the action gains later is +covered with no edit to this step. It **fails closed**: if the trusted bytes +cannot be materialized (no base ref, a failed fetch, or the base ref carrying no +`action.yml`), the whole job aborts rather than falling back to the PR-head +copy. Combined with `lint_mode: none`, no manifest-derived bytes and no PR-head +**setup**-action body ever enter the review job. The runner's other composite +invocations — `read-project-config` and `vendor-plugin` — remain PR-head-resolved: +the recorded residual (it predates issue #1388; see #874 for the `vendor-plugin` +`ref:` hardening), pinned in both directions in `lib/test/test_python_scripts.py` +so extending the hardened set forces this statement to be restated. + +### CI validates the candidate manifest without write credentials + +A `lint-manifest` job in `.github/workflows/ci.yml` validates and exercises the +candidate `.prflow/lint-manifest.json` on every PR under workflow-level +`contents: read` (no repository write credentials), so a manifest change is +proven before it becomes implement-active — which it only does after merge +(workflow/config resolution is post-merge for the trigger-time channel). + ## Extending the tool allowlist The light `/prflow:*` command path runs under a fixed `--allowed-tools` allowlist baked into the diff --git a/docs/internal/install.md b/docs/internal/install.md index 781df6296a..d9b3edf7ec 100644 --- a/docs/internal/install.md +++ b/docs/internal/install.md @@ -334,6 +334,11 @@ Whether a file *exists* is decided without `python3` in both cases, so a genuine Skipping versions is safe: the classification above depends on the recorded digest, not on how far behind you are. +**Two install-time marker files, different jobs — don't confuse them.** The installer writes two sha256-bearing JSON files under `.prflow/`, and they answer different questions: + +- **`.prflow/install-manifest.json`** — the **per-artifact hand-edit provenance** described just above. It records the digest of the bytes the installer wrote for *each* artifact it owns (the local `marketplace.json`, the workflows, the composite actions), so the next upgrade can tell an untouched artifact from one you edited and preserve your edits. It is upgrade-safety bookkeeping and grows an entry per owned artifact. +- **`.prflow/install-state.json`** — the **lint-provisioning compatibility marker** (issue #1388), a single digest-bound *tuple* published **last**, only after the staged `.prflow/lint-manifest.json` validates. It binds the lint manifest, its readers (`scripts/lint_manifest.py`, `scripts/lint_provision.py`, `scripts/install_state.py`), the `setup-project-env` composite action and its `provision-lint-tools.sh` helper, and the implement workflow (`.github/workflows/devflow-implement.yml`) by sha256, recording the **runtime path** each component will occupy. Because the workflows/manifest ship via `install.sh`'s copy loop while the readers ship via the runtime vendor fetch (`.prflow/vendor/prflow/scripts/…`), that install-channel skew is exactly what the tuple exists to reconcile: `setup-project-env`'s lint-provisioning phase refuses to provision when this marker is absent, a component digest disagrees, or the manifest is missing (fail-closed, before the model runs). This repository's own committed copy is regenerated by `lib/generate-install-state.py` (which hardcodes the primary repo root and is not a consumer-facing command — a consumer's marker is republished by re-running `install.sh`), and both it and `.prflow/lint-manifest.json` ship to consumers (re-included past the `/.prflow/*` ignore rule). If the manifest does not validate, the installer does **not** publish the marker, so lint provisioning stays fail-closed until the installer is re-run. + **An installation with no manifest at all heals only partly on its first upgrade, and it is worth knowing which part.** Without a recorded digest there is nothing to compare against, so the table's fourth row applies to every artifact whose bytes differ from the version being installed — whether you edited it or it is simply older. Those are preserved with a `.prflow-new` sidecar and are **not** recorded. Only artifacts already byte-identical to the shipped version take the `unchanged` row, and those are recorded. So on a release that changed a workflow, a pre-manifest installation gets a sidecar for that workflow and a manifest covering everything else. To finish healing an artifact you never edited, do either of these and re-run — both record its digest: diff --git a/install.sh b/install.sh index 61c77d014b..dd34b80aee 100755 --- a/install.sh +++ b/install.sh @@ -1528,6 +1528,44 @@ JSON fi done + # 4b. Lint provisioning (issue #1388). Publish the compatibility marker LAST, only + # after the staged manifest validates — reordering breaks the fail-closed tuple + # gate. Digest the TARGET root: install_managed preserves a locally modified + # artifact and the tier1_rc arm skips the workflow copy, so binding either to + # source bytes it never received refuses provisioning forever. --digest-root is + # the exception: the vendor-fetched readers are absent from this tree yet. + if [ -f "$SRC/.prflow/lint-manifest.json" ] && [ -f "$SRC/scripts/install_state.py" ]; then + install_managed ".prflow/lint-manifest.json" "$SRC/.prflow/lint-manifest.json" + if python3 "$SRC/scripts/lint_manifest.py" "$SRC/.prflow/lint-manifest.json" >/dev/null 2>&1; then + if lint_state_err="$(python3 "$SRC/scripts/install_state.py" build \ + --out ".prflow/install-state.json" \ + --installer-version "$pin" \ + --repo-root "$PWD" \ + --component "manifest=.prflow/lint-manifest.json" \ + --component "manifest-reader=scripts/lint_manifest.py" \ + --component "lint-provision=scripts/lint_provision.py" \ + --component "install-state-reader=scripts/install_state.py" \ + --component "setup-action=.github/actions/setup-project-env/action.yml" \ + --component "provision-helper=.github/actions/setup-project-env/provision-lint-tools.sh" \ + --component "implement-workflow=.github/workflows/devflow-implement.yml" \ + --digest-root "manifest-reader=$SRC" \ + --digest-root "lint-provision=$SRC" \ + --digest-root "install-state-reader=$SRC" \ + --record-path "manifest-reader=.prflow/vendor/prflow/scripts/lint_manifest.py" \ + --record-path "lint-provision=.prflow/vendor/prflow/scripts/lint_provision.py" \ + --record-path "install-state-reader=.prflow/vendor/prflow/scripts/install_state.py" \ + 2>&1 >/dev/null)"; then + log "published .prflow/install-state.json (lint provisioning compatibility marker)" + else + log "warning: could not publish .prflow/install-state.json (${lint_state_err:-no diagnostic}); lint provisioning will fail closed (setup refuses provisioning without the marker) until the installer is re-run." + fi + else + log "warning: .prflow/lint-manifest.json did not validate; NOT publishing the install-state marker (fail-closed: setup will refuse lint provisioning)." + fi + else + log "warning: this source tree carries no .prflow/lint-manifest.json or scripts/install_state.py; NOT publishing the install-state marker (fail-closed: setup will refuse lint provisioning)." + fi + # 5. config scaffold — delegated to the ONE shared scaffolder so the cloud tier # and the /devflow:init skill can never drift. It never overwrites a value the # user has set (it only backfills keys newly added to the example) and always diff --git a/lib/generate-install-state.py b/lib/generate-install-state.py new file mode 100755 index 0000000000..53b7930a0c --- /dev/null +++ b/lib/generate-install-state.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Daniel Radman +# SPDX-License-Identifier: MIT +"""Generate this repository's tracked `.prflow/install-state.json` (issue #1388). + +`.prflow/install-state.json` is the digest-bound compatibility-tuple marker: it +binds the lint manifest, its reader/validator, the provisioning helpers, the +`setup-project-env` composite action, and the shipped implement workflow by +sha256 digest, plus the installer version. The provisioning phase refuses to run +when any bound component's on-disk digest disagrees. + +This repository dogfoods DevFlow, so it TRACKS its own marker (force-added past +`.gitignore`, like `.prflow/config.json`). The marker is therefore a **generated +artifact**: whenever a bound component changes, re-run this generator and commit +the refreshed marker. `--check` (used by the suite) fails RED on drift, naming +the regeneration command — the same fail-on-drift contract the other generated +artifacts carry. + +A thin consumer's marker is instead published by `install.sh` at install time +over that consumer's own runtime paths; this generator is only for the primary +repo's committed copy. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_MARKER = _REPO_ROOT / ".prflow" / "install-state.json" + +# The compatibility tuple: every component whose bytes must agree for lint +# provisioning to be safe, named by its repo-root path. NAME → repo-relative path. +COMPONENTS = { + "manifest": ".prflow/lint-manifest.json", + "manifest-reader": "scripts/lint_manifest.py", + "lint-provision": "scripts/lint_provision.py", + "install-state-reader": "scripts/install_state.py", + "setup-action": ".github/actions/setup-project-env/action.yml", + "provision-helper": ".github/actions/setup-project-env/provision-lint-tools.sh", + "implement-workflow": ".github/workflows/devflow-implement.yml", +} + + +def _load_install_state(): + path = _REPO_ROOT / "scripts" / "install_state.py" + spec = importlib.util.spec_from_file_location("install_state", path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load install_state from {path}") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _installer_version(repo_root: Path) -> str: + """The installer version stamped into the marker. This repo pins it to + `.prflow/config.json`'s `prflow_version` (the ref its runtime fetch tracks), + which is stable for the dogfood repo.""" + cfg = repo_root / ".prflow" / "config.json" + data = json.loads(cfg.read_text(encoding="utf-8")) + iv = data.get("prflow_version") + if not isinstance(iv, str) or not iv: + raise ValueError("prflow_version missing/empty in .prflow/config.json") + return iv + + +def build(repo_root: Path) -> dict: + install_state = _load_install_state() + return install_state.build_state(_installer_version(repo_root), COMPONENTS, repo_root=repo_root) + + +def _force_utf8_streams(): + for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8") + except (AttributeError, ValueError, OSError): + pass + + +def main(argv=None) -> int: + _force_utf8_streams() + argv = list(sys.argv[1:] if argv is None else argv) + # Refuse an unrecognized argument instead of ignoring it: a typo like `--chek` + # silently took the write path and rewrote the marker the caller asked it to check. + unknown = [a for a in argv if a != "--check"] + if unknown: + print(f"usage: generate-install-state.py [--check] (unrecognized: {unknown})", + file=sys.stderr) + return 2 + check = "--check" in argv + fresh = build(_REPO_ROOT) + serialized = json.dumps(fresh, indent=2) + "\n" + if check: + try: + current = _MARKER.read_text(encoding="utf-8") + except FileNotFoundError: + current = None + if current != serialized: + print("install-state DRIFT: .prflow/install-state.json is out of date.", file=sys.stderr) + print("Regenerate with: python3 lib/generate-install-state.py", file=sys.stderr) + return 1 + return 0 + _MARKER.write_text(serialized, encoding="utf-8") + print(f"wrote {_MARKER.relative_to(_REPO_ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lib/test/modules/coverage-map.json b/lib/test/modules/coverage-map.json index 869aaff13e..c6dca84140 100644 --- a/lib/test/modules/coverage-map.json +++ b/lib/test/modules/coverage-map.json @@ -67,6 +67,11 @@ "note": "issue #1004 Tier 3: renders the frozen out-of-repo DEVFLOW_* advisory region from lib/rename-map.json and re-runs the two-arm criterion over the tree (--audit)", "owner": "tier1-rename-migration" }, + "lib/generate-install-state.py": { + "focused_test": "lib/test/test_python_scripts.py", + "note": "issue #1388 generator for the tracked digest-bound install-state marker; the #1388 drift-gate in test_python_scripts.py drives its --check", + "owner": "unmodularized" + }, "lib/generate-plugin-identity.py": { "note": "issue #927 name-agnostic plugin identity: compiles the baked discriminator regions", "owner": "unmodularized" @@ -497,6 +502,11 @@ "note": "", "owner": "installer-wiring" }, + "scripts/install_state.py": { + "focused_test": "lib/test/test_python_scripts.py", + "note": "issue #1388 install-state compatibility-marker builder/validator + provisioning readiness gate", + "owner": "unmodularized" + }, "scripts/inventory-workflow-transcripts.py": { "focused_test": "lib/test/test_workflow_flight_recorder.py", "note": "", @@ -517,6 +527,11 @@ "note": "issue #1276 declarative lint-manifest strict reader/validator", "owner": "unmodularized" }, + "scripts/lint_provision.py": { + "focused_test": "lib/test/test_python_scripts.py", + "note": "issue #1388 manifest platform-resolution + cache-key + trusted download URLs for lint provisioning", + "owner": "unmodularized" + }, "scripts/load-prompt-extension.sh": { "note": "consumer prompt-extension reader; its contract is driven by the prompt-extension-reader module", "owner": "prompt-extension-reader" @@ -1215,6 +1230,10 @@ "note": "", "owner": "unmodularized" }, + "1388": { + "note": "", + "owner": "unmodularized" + }, "139": { "note": "", "owner": "unmodularized" @@ -1631,6 +1650,10 @@ "note": "", "owner": "unmodularized" }, + "1963": { + "note": "", + "owner": "installer-wiring" + }, "197": { "note": "", "owner": "unmodularized" diff --git a/lib/test/modules/installer-wiring.sh b/lib/test/modules/installer-wiring.sh index 26247b127a..1e6edb0a74 100644 --- a/lib/test/modules/installer-wiring.sh +++ b/lib/test/modules/installer-wiring.sh @@ -706,8 +706,12 @@ cp "$LIB/resolve-jq.sh" "$LIB/resolve-bin.sh" "$LIB/rename-map.json" \ # resolves it from ($SELF_DIR/../.prflow/tool-presets.json). Copying it to scripts/ # left the fixture source tree missing it, so these arms silently drove the # presets-absent degraded path instead of the shipped one. +# #1388: lint-manifest.json + install-state.json are now devflow_copy_slice copy-list +# members, so the offline source tree must carry them or a DEVFLOW_VENDOR=1 install's +# slice copy aborts before the vendored tree lands. cp "$LIB/../.prflow/config.example.json" "$LIB/../.prflow/config.schema.json" \ - "$LIB/../.prflow/tool-presets.json" "$IU_SRC/.prflow/" + "$LIB/../.prflow/tool-presets.json" "$LIB/../.prflow/lint-manifest.json" \ + "$LIB/../.prflow/install-state.json" "$IU_SRC/.prflow/" assert_eq "installer-upgrade fixture: the offline source tree carries tool-presets.json where detect-project-tools.sh resolves it" "yes" \ "$([ -f "$IU_SRC/.prflow/tool-presets.json" ] && echo yes || echo no)" cp "$LIB/../.github/workflows/devflow.yml" "$LIB/../.github/workflows/devflow-implement.yml" "$IU_SRC/.github/workflows/" @@ -2323,3 +2327,80 @@ printf '{ "name": "fixture" }\n' > "$IU_C23G/package.json" IU_O23G="$(bash "$LIB/../scripts/detect-project-tools.sh" "$IU_C23G" 2>&1)" assert_eq "installer-upgrade #971: the single-argument form still scans the repo it updates (the /prflow:init path is unchanged)" "yes yes" \ "$(_iu_out_matches "$IU_O23G" 'devflow-detect: detected: node —') $(_iu_has "$IU_C23G/.prflow/config.json" 'Bash(npm:*)')" + +# ──────────────────────────────────────────────────────────────────────────── +echo "install.sh section 4b: lint-provisioning marker publish gate (issue #1388, PR #1963 reception)" +# Drive the REAL 4b shell wiring end-to-end (validate manifest -> publish marker +# LAST, else warn and publish nothing) — the substring pins alone cannot catch an +# inverted or dropped branch. A dedicated source tree adds the python components 4b +# reads; the shared IU_SRC stays without them so every earlier arm is unchanged. +IU_SRC_1388="$_iw_tmp_root/src-1388" +rm -rf "$IU_SRC_1388"; cp -R "$IU_SRC" "$IU_SRC_1388" +cp "$LIB/../scripts/lint_manifest.py" "$LIB/../scripts/lint_provision.py" \ + "$LIB/../scripts/install_state.py" "$IU_SRC_1388/scripts/" + +# Positive control: a valid staged manifest publishes a marker that PARSES as +# established (not merely exists). +IU_C1388="$(_iu_consumer 1388-publish)" +IU_O1388="$(IU_SRC_OVERRIDE="$IU_SRC_1388" _iu_run "$IU_C1388" --apply)" +assert_eq "#1388 4b end-to-end: valid manifest publishes the install-state marker" "yes yes" \ + "$([ -f "$IU_C1388/.prflow/install-state.json" ] && echo yes || echo no) $(printf '%s' "$IU_O1388" | grep -qF 'published .prflow/install-state.json' && echo yes || echo no)" +assert_eq "#1388 4b end-to-end: the published marker validates as established" "established" \ + "$(python3 -c ' +import sys, importlib.util +spec = importlib.util.spec_from_file_location("install_state", sys.argv[1]) +m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m) +print(m.load_state(sys.argv[2]).status) +' "$LIB/../scripts/install_state.py" "$IU_C1388/.prflow/install-state.json")" + +# PR #1963 reception: structural validity is NOT readiness. The published marker +# validated as `established` while binding source bytes the consumer never received, +# so provisioning refused forever — assert the gate the provisioning step actually +# runs, against the consumer tree, with the vendored readers materialized as the +# runtime vendor fetch supplies them. +mkdir -p "$IU_C1388/.prflow/vendor/prflow/scripts" +cp "$IU_SRC_1388/scripts/lint_manifest.py" "$IU_SRC_1388/scripts/lint_provision.py" \ + "$IU_SRC_1388/scripts/install_state.py" "$IU_C1388/.prflow/vendor/prflow/scripts/" +_iu_ready_1963() { + python3 -c ' +import sys, importlib.util +spec = importlib.util.spec_from_file_location("install_state", sys.argv[1]) +m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m) +r = m.check_readiness(sys.argv[2] + "/.prflow/install-state.json", + sys.argv[2] + "/.prflow/lint-manifest.json", repo_root=sys.argv[2]) +print("READY" if r.ready else r.reason) +' "$LIB/../scripts/install_state.py" "$1" +} +assert_eq "#1963 4b end-to-end: the published marker is READY against the consumer tree" "READY" \ + "$(_iu_ready_1963 "$IU_C1388")" + +# The Critical this control exists for: install_managed PRESERVES a locally modified +# artifact, so a marker built from source bytes bound bytes that tree never received +# and refused provisioning permanently, with a re-run remedy that reproduced it. +printf '\n# consumer local edit\n' >> "$IU_C1388/.github/actions/setup-project-env/provision-lint-tools.sh" +IU_O1963R="$(IU_SRC_OVERRIDE="$IU_SRC_1388" _iu_run "$IU_C1388" --apply)" +assert_eq "#1963 4b end-to-end: the re-run PRESERVED the locally modified artifact" "yes" \ + "$(printf '%s' "$IU_O1963R" | grep -qF 'PRESERVED' && echo yes || echo no)" +assert_eq "#1963 4b end-to-end: re-running the installer converges readiness over a preserved artifact" "READY" \ + "$(_iu_ready_1963 "$IU_C1388")" + +# Fail-closed arm 1: an INVALID staged manifest warns 'did not validate' and +# publishes NO marker (attributed rejection: the outer validation branch). +IU_SRC_1388B="$_iw_tmp_root/src-1388-badmanifest" +rm -rf "$IU_SRC_1388B"; cp -R "$IU_SRC_1388" "$IU_SRC_1388B" +printf '{not json' > "$IU_SRC_1388B/.prflow/lint-manifest.json" +IU_C1388B="$(_iu_consumer 1388-badmanifest)" +IU_O1388B="$(IU_SRC_OVERRIDE="$IU_SRC_1388B" _iu_run "$IU_C1388B" --apply)" +assert_eq "#1388 4b end-to-end: invalid manifest -> 'did not validate' warning and NO marker" "no yes" \ + "$([ -f "$IU_C1388B/.prflow/install-state.json" ] && echo yes || echo no) $(printf '%s' "$IU_O1388B" | grep -qF 'lint-manifest.json did not validate' && echo yes || echo no)" + +# Fail-closed arm 2: a valid manifest whose build cannot digest a bound component +# warns 'could not publish' WITH the build diagnostic and publishes NO marker +# (attributed rejection: the inner build branch, distinct from arm 1's). +IU_SRC_1388C="$_iw_tmp_root/src-1388-nocomponent" +rm -rf "$IU_SRC_1388C"; cp -R "$IU_SRC_1388" "$IU_SRC_1388C" +rm -f "$IU_SRC_1388C/.github/workflows/devflow-implement.yml" +IU_C1388C="$(_iu_consumer 1388-nocomponent)" +IU_O1388C="$(IU_SRC_OVERRIDE="$IU_SRC_1388C" _iu_run "$IU_C1388C" --apply)" +assert_eq "#1388 4b end-to-end: unreadable bound component -> 'could not publish' names the build failure and NO marker" "no yes yes" \ + "$([ -f "$IU_C1388C/.prflow/install-state.json" ] && echo yes || echo no) $(printf '%s' "$IU_O1388C" | grep -qF 'could not publish .prflow/install-state.json' && echo yes || echo no) $(printf '%s' "$IU_O1388C" | grep -qF 'cannot digest component' && echo yes || echo no)" diff --git a/lib/test/run.sh b/lib/test/run.sh index 259f618dc7..3ed935a8c8 100755 --- a/lib/test/run.sh +++ b/lib/test/run.sh @@ -21316,7 +21316,9 @@ fi # The setup-project-env step is gated on the base-ref provision flag. assert_eq "provision: setup-project-env step present" "1" \ "$(grep -c 'uses: ./.github/actions/setup-project-env' "$RUNNER" || true)" -assert_eq "provision: setup-project-env gated on base provision_env" "1" \ +# TWO steps gate on the base provision flag: the #1388 setup-env hardening step +# (materializes the trusted base-ref action body) and the provision step it protects. +assert_eq "provision: setup-project-env gated on base provision_env" "2" \ "$(grep -c "if: steps.baseprovision.outputs.provision_env == 'true'" "$RUNNER" || true)" # Coupling: the tool-profile guard and the provision step must read the SAME @@ -22113,15 +22115,15 @@ assert_eq "provision: malformed/non-object base config warns + read-only (basepr # Trust boundary: the flag and setup block come from the base ref. BASE_REF is # sourced from the trusted event payload, fetched from origin, and read out of # FETCH_HEAD — never the checked-out PR head. -# FOUR sites read the trusted BASE_REF from the event payload and fetch it (issue #908 -# review: was three before the new harden_guard step): the baseprovision step, the -# #458 harden-stop-hooks step, the #874 baseversion step, and the #908 harden_guard -# step (all under the same trusted-source rule — the guard's own trusted-copy -# displacement needs its own independent fetch, for the same reason #874's baseversion -# step does not rely on another step's FETCH_HEAD surviving). -assert_eq "provision: base ref from trusted event payload (baseprovision + #458 harden + #874 baseversion + #908 harden_guard)" "4" \ +# These sites read the trusted BASE_REF from the event payload and fetch it (the +# assertions below pin the exact count): the baseprovision step, the #458 +# harden-stop-hooks step, the #874 baseversion step, the #908 harden_guard step, and +# the #1388 setup-env hardening step (all under the same trusted-source rule — each +# trusted-copy displacement needs its own independent fetch, for the same reason +# #874's baseversion step does not rely on another step's FETCH_HEAD surviving). +assert_eq "provision: base ref from trusted event payload (baseprovision + #458 harden + #874 baseversion + #908 harden_guard + #1388 setup-env harden)" "5" \ "$(grep -c 'github.event.pull_request.base.ref || github.event.repository.default_branch' "$RUNNER" || true)" -assert_eq "provision: base config fetched from origin BASE_REF (baseprovision + #458 harden + #874 baseversion + #908 harden_guard)" "4" \ +assert_eq "provision: base config fetched from origin BASE_REF (baseprovision + #458 harden + #874 baseversion + #908 harden_guard + #1388 setup-env harden)" "5" \ "$(grep -c 'git fetch --depth=1 origin "\$BASE_REF"' "$RUNNER" || true)" # Two readers of the trusted base config: baseprovision (provision_env, allowed_tools, # setup) and the #874 baseversion step (prflow_version). @@ -24675,6 +24677,8 @@ mkdir -p "$VS_REMOTE/docs/site" "$VS_REMOTE/docs/external" "$VS_REMOTE/docs/inte printf '{}' > "$VS_REMOTE/.prflow/config.example.json" printf '{}' > "$VS_REMOTE/.prflow/config.schema.json" printf '{}' > "$VS_REMOTE/.prflow/tool-presets.json" +printf '{}' > "$VS_REMOTE/.prflow/lint-manifest.json" # #1388: shipped by devflow_copy_slice +printf '{}' > "$VS_REMOTE/.prflow/install-state.json" # #1388: the compatibility marker ships too ( cd "$VS_REMOTE" && git init -q -b main && git add -A \ && git -c user.email=t@t -c user.name=t commit -qm fixture ) >/dev/null 2>&1 # Capture the BASE (non-tip) commit, then add a second commit carrying a @@ -24797,6 +24801,9 @@ assert_eq "vendor: self ships no docs/ tree after pruning" "no" "$(vexists "$VS_ assert_eq "vendor: self copies lib/" "yes" "$(vexists "$VS_SELF/lib")" assert_eq "vendor: self copies skills/" "yes" "$(vexists "$VS_SELF/skills")" assert_eq "vendor: self copies .prflow/tool-presets.json" "yes" "$(vexists "$VS_SELF/.prflow/tool-presets.json")" +# #1388: the lint manifest and its digest-bound compatibility marker ship to consumers. +assert_eq "#1388 vendor: self ships .prflow/lint-manifest.json" "yes" "$(vexists "$VS_SELF/.prflow/lint-manifest.json")" +assert_eq "#1388 vendor: self ships .prflow/install-state.json marker" "yes" "$(vexists "$VS_SELF/.prflow/install-state.json")" # #677 exclusions: the produced slice must ship neither the published GitHub Pages # HTML (docs/site), the Mintlify source (docs/external), nor DevFlow's own test suite @@ -24908,6 +24915,11 @@ mkdir -p "$VS_FLOORSRC"/.claude-plugin "$VS_FLOORSRC"/agents "$VS_FLOORSRC"/docs printf '{}' > "$VS_FLOORSRC/.prflow/config.example.json" printf '{}' > "$VS_FLOORSRC/.prflow/config.schema.json" printf '{}' > "$VS_FLOORSRC/.prflow/tool-presets.json" +# #1388: lint-manifest.json + install-state.json are now copy-list members too, so +# this fixture must carry them or the .prflow cp aborts BEFORE the floor and case (b) +# silently degrades into case (a) — the exact hazard the comment below guards against. +printf '{}' > "$VS_FLOORSRC/.prflow/lint-manifest.json" +printf '{}' > "$VS_FLOORSRC/.prflow/install-state.json" VS_FLOORSRC_DEST="$(mktemp -d)/dest" VS_FLOORSRC_RC=0 # Capture stderr (the die stream) so we can assert the abort came from the FLOOR, @@ -48373,7 +48385,7 @@ rm -rf "$D487" # hand-edited workflow — driven end to end and joined to the shipped workflow's own # trigger-time guard. if ! devflow_run_full_suite_module "$LIB/test/modules/installer-wiring.sh" \ - "installer-wiring" 297; then + "installer-wiring" 304; then printf 'ERROR: installer-wiring boundary could not record its result\n' exit 1 fi diff --git a/lib/test/test_coverage_map_guard.py b/lib/test/test_coverage_map_guard.py index 6639cb27e7..d0c2011ddc 100755 --- a/lib/test/test_coverage_map_guard.py +++ b/lib/test/test_coverage_map_guard.py @@ -865,7 +865,7 @@ def test_the_shipped_modules_each_derive_their_own_labels(self): # carries) took its coverage in this module, alongside tier1-rename-migration # which carries the same label for the config-key migration itself. self.assertEqual( - {"487", "491", "533", "544", "599", "690", "959", "970", "971", "1002", "1041", "1882", "1925"}, + {"487", "491", "533", "544", "599", "690", "959", "970", "971", "1002", "1041", "1388", "1882", "1925", "1963"}, module_labels["installer-wiring"], ) diff --git a/lib/test/test_python_scripts.py b/lib/test/test_python_scripts.py index 3a57098875..4fde5740a6 100755 --- a/lib/test/test_python_scripts.py +++ b/lib/test/test_python_scripts.py @@ -36601,6 +36601,961 @@ def _cli1027(body, now, threshold, enabled, fmt=None): assert_eq("#1027 decide: stale-advisory with no checkpoint omits the checkpoint clause", True, "last checkpoint" not in _dnc1027.message) +# ── issue #1388: lint-provisioning helpers (lint_provision.py, install_state.py) ── +_lint_provision = _load('lint_provision', SCRIPTS / 'lint_provision.py') +_install_state = _load('install_state', SCRIPTS / 'install_state.py') +_MANIFEST_1388 = SCRIPTS.parent / '.prflow' / 'lint-manifest.json' + +# lint_provision.build_plan — established tuple resolves artifact + trusted URL. +_p1388 = _lint_provision.build_plan(_MANIFEST_1388, 'shellcheck', 'linux', 'x86_64') +assert_eq("#1388 plan: linux/x86_64 shellcheck established", "established", _p1388.status) +# The expected digest is read from the manifest itself (not a hardcoded constant) — this +# assertion's purpose is that build_plan surfaces the manifest's digest verbatim (plumbing), +# so a dynamic expected stays meaningful across future manifest version bumps. +with open(_MANIFEST_1388, encoding='utf-8') as _mf1388: + _manifest1388 = json.load(_mf1388) +_expected_digest_1388 = next( + a['digest'] for a in _manifest1388['tools']['shellcheck']['artifacts'] + if a['os'] == 'linux' and a['arch'] == 'x86_64' +) +assert_eq("#1388 plan: resolves the manifest's pinned digest", + _expected_digest_1388, _p1388.digest) +assert_eq("#1388 plan: trusted URL keyed on version+os+arch (no manifest string)", + "https://github.com/koalaman/shellcheck/releases/download/v0.10.0/shellcheck-v0.10.0.linux.x86_64.tar.xz", + _p1388.url) +# Windows shellcheck uses the single per-release zip form. +assert_eq("#1388 plan: windows shellcheck single-zip URL", + "https://github.com/koalaman/shellcheck/releases/download/v0.10.0/shellcheck-v0.10.0.zip", + _lint_provision.build_plan(_MANIFEST_1388, 'shellcheck', 'windows', 'x86_64').url) +# ruff target-triple mapping. +assert_eq("#1388 plan: ruff macos/arm64 target triple", + "https://github.com/astral-sh/ruff/releases/download/0.6.9/ruff-aarch64-apple-darwin.tar.gz", + _lint_provision.build_plan(_MANIFEST_1388, 'ruff', 'macos', 'arm64').url) + +# unsupported-lint-platform — a VALID manifest declaring no artifact for the tuple. +_u1388 = _lint_provision.build_plan(_MANIFEST_1388, 'shellcheck', 'windows', 'arm64') +assert_eq("#1388 plan: unsupported (os,arch) -> unsupported", "unsupported", _u1388.status) +assert_eq("#1388 plan: unsupported reason literal", "unsupported-lint-platform", _u1388.reason) +# an unknown tool is a DISTINCT no-answer from a platform gap: the shell caller +# degrades only the platform case and fails closed on a tool it cannot handle. +_ut1388 = _lint_provision.build_plan(_MANIFEST_1388, 'gcc', 'linux', 'x86_64') +assert_eq("#1388 plan: unknown tool -> unsupported", + "unsupported", _ut1388.status) +assert_eq("#1388 plan: unknown tool carries the unknown-lint-tool reason (not the platform reason)", + "unknown-lint-tool", _ut1388.reason) +# an unestablished manifest is NOT unsupported — it carries a typed reason. +_bad1388 = _lint_provision.build_plan(SCRIPTS / 'nope-manifest.json', 'ruff', 'linux', 'x86_64') +assert_eq("#1388 plan: missing manifest -> unestablished (not unsupported)", "unestablished", _bad1388.status) +assert_eq("#1388 plan: unestablished carries the manifest reason", + True, _bad1388.reason.startswith("missing:")) + +# cache_key: field-delimited, digest normalized to its 64-hex body, installer version last. +assert_eq("#1388 cache_key: {os,arch,tool,version,digest,installer} composed", + "lintprov-linux-x86_64-ruff-0.6.9-" + "0" * 62 + "c1-v9", + _lint_provision.cache_key('linux', 'x86_64', 'ruff', '0.6.9', + 'sha256:' + '0' * 62 + 'c1', 'v9')) + +# install_state.validate_state — six-shape fail-closed matrix. +def _mk_state(**over): + st = {"schema_version": 1, "installer_version": "v0.1.0", + "components": {"manifest": {"path": ".prflow/lint-manifest.json", + "digest": "sha256:" + "a" * 64}}} + st.update(over) + return st + +assert_eq("#1388 state: well-formed establishes", True, + _install_state.validate_state(_mk_state()).established) +assert_eq("#1388 state: top-level scalar -> wrong-type", True, + _install_state.validate_state(5).reason.startswith("wrong-type:")) +assert_eq("#1388 state: bool top level -> wrong-type (valid-falsy)", True, + _install_state.validate_state(False).reason.startswith("wrong-type:")) +assert_eq("#1388 state: unknown field rejected", True, + _install_state.validate_state(_mk_state(extra=1)).reason.startswith("unknown-field:")) +assert_eq("#1388 state: bad schema_version -> unknown-version", True, + _install_state.validate_state(_mk_state(schema_version=2)).reason.startswith("unknown-version:")) +assert_eq("#1388 state: bool schema_version -> wrong-type (valid-falsy)", True, + _install_state.validate_state(_mk_state(schema_version=True)).reason.startswith("wrong-type:")) +assert_eq("#1388 state: installer_version with shell metachar rejected", True, + _install_state.validate_state(_mk_state(installer_version="v1; rm -rf /")).reason.startswith("invalid-value:")) +assert_eq("#1388 state: empty components rejected", True, + _install_state.validate_state(_mk_state(components={})).reason.startswith("invalid-value:")) +assert_eq("#1388 state: absolute component path rejected", True, + _install_state.validate_state(_mk_state(components={"m": {"path": "/etc/x", "digest": "sha256:" + "a" * 64}})).reason.startswith("invalid-value:")) +assert_eq("#1388 state: traversal component path rejected", True, + _install_state.validate_state(_mk_state(components={"m": {"path": "../x", "digest": "sha256:" + "a" * 64}})).reason.startswith("invalid-value:")) +assert_eq("#1388 state: non-sha256 digest rejected", True, + _install_state.validate_state(_mk_state(components={"m": {"path": "x", "digest": "md5:abc"}})).reason.startswith("invalid-value:")) +# parse_state I/O shapes. +assert_eq("#1388 state: empty bytes -> empty", True, + _install_state.parse_state(b"").reason.startswith("empty:")) +assert_eq("#1388 state: invalid utf-8 -> invalid-utf8", True, + _install_state.parse_state(b"\xff\xfe").reason.startswith("invalid-utf8:")) +assert_eq("#1388 state: malformed json -> malformed-json", True, + _install_state.parse_state(b"{not json").reason.startswith("malformed-json:")) +assert_eq("#1388 state: duplicate key -> duplicate-key", True, + _install_state.parse_state(b'{"schema_version":1,"schema_version":1}').reason.startswith("duplicate-key:")) +assert_eq("#1388 state: load_state missing -> install-state-missing", + "install-state-missing", _install_state.load_state(SCRIPTS / 'nope-state.json').reason) + +# install_state build + check_readiness — the fail-closed provisioning gate. +_d1388 = Path(tempfile.mkdtemp()) +try: + _root = _d1388 / "repo" + (_root / ".prflow").mkdir(parents=True) + (_root / "scripts").mkdir() + _man = _root / ".prflow" / "lint-manifest.json" + _man.write_bytes(_MANIFEST_1388.read_bytes()) + _hlp = _root / "scripts" / "lint_manifest.py" + _hlp.write_text("print('x')\n", encoding="utf-8") + _state = _install_state.build_state("v0.1.0", + {"manifest": ".prflow/lint-manifest.json", "helper": "scripts/lint_manifest.py"}, + repo_root=_root) + _statef = _root / ".prflow" / "install-state.json" + _statef.write_text(json.dumps(_state) + "\n", encoding="utf-8") + # first-install: marker present, all digests match, manifest establishes -> READY. + assert_eq("#1388 readiness: first-install ready", True, + _install_state.check_readiness(_statef, _man, repo_root=_root).ready) + # backfill / interrupted-publication: marker absent while components present -> refuse. + assert_eq("#1388 readiness: absent marker (backfill/interrupted) -> install-state-missing", + "install-state-missing", + _install_state.check_readiness(_root / ".prflow" / "nope.json", _man, repo_root=_root).reason) + # version-skew (either direction) flips a component digest -> digest-mismatch. + _hlp.write_text("print('changed')\n", encoding="utf-8") + _vr = _install_state.check_readiness(_statef, _man, repo_root=_root) + assert_eq("#1388 readiness: version-skew -> not ready", False, _vr.ready) + assert_eq("#1388 readiness: version-skew names the component", "digest-mismatch:helper", _vr.reason) + # component removed on disk -> component-missing (distinct from a skew). + _hlp.unlink() + assert_eq("#1388 readiness: removed component -> component-missing", + "component-missing:helper", + _install_state.check_readiness(_statef, _man, repo_root=_root).reason) + # manifest missing -> manifest-missing. + _man.unlink() + _hlp.write_text("print('x')\n", encoding="utf-8") # restore helper so we isolate the manifest arm + _state2 = _install_state.build_state("v0.1.0", {"helper": "scripts/lint_manifest.py"}, repo_root=_root) + _statef.write_text(json.dumps(_state2) + "\n", encoding="utf-8") + assert_eq("#1388 readiness: manifest gone -> manifest-missing", + "manifest-missing", + _install_state.check_readiness(_statef, _man, repo_root=_root).reason) + # build_state fails BEFORE publishing when a component is unreadable. + assert_raises("#1388 build_state: unreadable component raises (no marker published)", + ValueError, + lambda: _install_state.build_state("v0.1.0", {"gone": "scripts/nope.py"}, repo_root=_root)) +finally: + shutil.rmtree(_d1388, ignore_errors=True) + + +# ── issue #1388: provision-lint-tools.sh fail-closed arms (driven end-to-end) ── +import subprocess as _sp1388 # noqa: E402 +import tarfile as _tar1388 # noqa: E402 + +_HELPER_1388 = SCRIPTS.parent / '.github' / 'actions' / 'setup-project-env' / 'provision-lint-tools.sh' + + +def _mk_archive_1388(root, member, version_report, *, valid=True, archive_type="tar.gz"): + """Build an archive holding a fake `member` executable that reports + `version_report`; return (archive_path, sha256-digest). `valid=False` + writes non-archive bytes (a corrupt download whose digest still pins). + `archive_type` selects the compression — production shellcheck ships tar.xz.""" + arc = root / f"artifact.{archive_type}" + if not valid: + arc.write_bytes(b"this is not a tar archive\n") + else: + tooldir = root / "tool" + tooldir.mkdir(exist_ok=True) + exe = tooldir / member + exe.write_text(f"#!/bin/sh\necho '{member} {version_report}'\n", encoding="utf-8") + exe.chmod(0o755) + with _tar1388.open(arc, "w:" + {"tar.gz": "gz", "tar.xz": "xz"}[archive_type]) as tf: + tf.add(exe, arcname=f"nested-{version_report}/{member}") + return arc, _install_state.digest_bytes(arc.read_bytes()) + + +def _mk_manifest_1388(digest, *, version="9.9.9", archive_type="tar.gz"): + return { + "schema_version": 1, + "tools": { + "shellcheck": {"version": version, "timeout_seconds": 600, + "artifacts": [{"os": "linux", "arch": "x86_64", "digest": digest, + "archive_type": archive_type, "member": "shellcheck", + "strategy": "extract-tar"}]}, + "ruff": {"version": "1.0.0", "timeout_seconds": 600, + "artifacts": [{"os": "linux", "arch": "x86_64", "digest": "sha256:" + "b" * 64, + "archive_type": "tar.gz", "member": "ruff", + "strategy": "extract-tar"}]}, + }, + "selectors": [{"id": "s", "language": "shell", "include_globs": ["**/*.sh"]}], + "full_profiles": [{"id": "p", "tool": "shellcheck", "selector": "s"}], + } + + +def _run_helper_1388(root, *, tools="shellcheck", os_name="linux", arch="x86_64", + archive=None, curl_rc=0, dest_bin=None, extra_env=None, + curl_marker=None, path_tools=None, github_path=None): + """Run provision-lint-tools.sh in fixture `root` with a fake curl that copies + `archive` (or exits `curl_rc`) and, when `curl_marker` is given, touches that + path so a test can assert the downloader was (not) invoked. `path_tools`, a + {name: version_report} dict, materializes fake PATH executables in a + directory prepended to PATH ahead of the real inherited PATH. Returns + (returncode, stderr+stdout).""" + fakecurl = root / "fakecurl.sh" + marker_line = f'touch "{curl_marker}"\n' if curl_marker else "" + if curl_rc != 0: + fakecurl.write_text(f"#!/bin/sh\n{marker_line}exit {curl_rc}\n", encoding="utf-8") + else: + fakecurl.write_text( + '#!/bin/sh\n' + marker_line + + 'out=""\nwhile [ $# -gt 0 ]; do case "$1" in -o) out="$2"; shift 2;; *) shift;; esac; done\n' + f'cp "{archive}" "$out"\n', encoding="utf-8") + fakecurl.chmod(0o755) + env = dict(os.environ) + env.pop("GITHUB_PATH", None) + if github_path is not None: + env["GITHUB_PATH"] = str(github_path) + path_prefix = "" + if path_tools: + pathdir = root / "pathtools" + pathdir.mkdir(exist_ok=True) + for name, version_report in path_tools.items(): + exe = pathdir / name + exe.write_text(f"#!/bin/sh\necho '{name} {version_report}'\n", encoding="utf-8") + exe.chmod(0o755) + path_prefix = str(pathdir) + os.pathsep + env.update({ + "LINT_MANIFEST": ".prflow/lint-manifest.json", + "INSTALL_STATE": ".prflow/install-state.json", + "DEST_BIN": str(dest_bin if dest_bin else (root / "bin")), + "TARGET_OS": os_name, "TARGET_ARCH": arch, + "SCRIPTS_DIR": str(SCRIPTS), + "TOOLS": tools, + "LINTPROV_CURL": str(fakecurl), + "PATH": path_prefix + env.get("PATH", ""), + }) + if extra_env: + env.update(extra_env) + proc = _sp1388.run(["bash", str(_HELPER_1388)], cwd=str(root), env=env, + capture_output=True, text=True) + return proc.returncode, proc.stdout + proc.stderr + + +def _mk_repo_1388(tmp, manifest): + """Materialize a fixture repo with the manifest, a real helper component, and a + valid install-state marker binding both by digest.""" + root = Path(tmp) / "repo" + (root / ".prflow").mkdir(parents=True) + (root / "scripts").mkdir() + (root / ".prflow" / "lint-manifest.json").write_text(json.dumps(manifest) + "\n", encoding="utf-8") + (root / "scripts" / "lint_manifest.py").write_bytes((SCRIPTS / "lint_manifest.py").read_bytes()) + state = _install_state.build_state("v0", + {"manifest": ".prflow/lint-manifest.json", "helper": "scripts/lint_manifest.py"}, + repo_root=root) + (root / ".prflow" / "install-state.json").write_text(json.dumps(state) + "\n", encoding="utf-8") + return root + + +_d1388b = Path(tempfile.mkdtemp()) +try: + # Happy path: valid archive whose digest the manifest pins; fake tool reports 9.9.9. + _arc, _dig = _mk_archive_1388(_d1388b, "shellcheck", "9.9.9") + _repo = _mk_repo_1388(_d1388b / "ok", _mk_manifest_1388(_dig)) + _rc, _out = _run_helper_1388(_repo, archive=_arc) + assert_eq("#1388 helper: happy path installs + version-verifies (rc 0)", 0, _rc) + assert_eq("#1388 helper: reports version-verified install", True, "version-verified" in _out) + assert_eq("#1388 helper: installed the executable run-local", True, (_repo / "bin" / "shellcheck").exists()) + # PR #1963 reception: tar.xz is the archive type every real shellcheck artifact + # uses, and no test extracted one — the whole download+extract path was proven + # only against a compression production never sees. + _xzdir = _d1388b / "xz" + _xzdir.mkdir(exist_ok=True) + _xz_arc, _xz_dig = _mk_archive_1388(_xzdir, "shellcheck", "9.9.9", archive_type="tar.xz") + _repo_xz = _mk_repo_1388(_d1388b / "xz-repo", + _mk_manifest_1388(_xz_dig, archive_type="tar.xz")) + _rc_xz, _out_xz = _run_helper_1388(_repo_xz, archive=_xz_arc) + assert_eq("#1963 helper: tar.xz extracts and version-verifies (rc 0)", 0, _rc_xz) + assert_eq("#1963 helper: tar.xz installed the executable run-local", True, + (_repo_xz / "bin" / "shellcheck").exists()) + + # PR #1963 reception: the GITHUB_PATH append is what makes the provisioned tools + # visible to the model. Every other fixture pops GITHUB_PATH, so deleting the append + # left every fail-closed arm green while the model saw no shellcheck/ruff on PATH. + _gp1963 = _d1388b / "github_path_file" + _gp1963.write_text("", encoding="utf-8") + _repo_gp = _mk_repo_1388(_d1388b / "ok-gp", _mk_manifest_1388(_dig)) + _rc_gp, _ = _run_helper_1388(_repo_gp, archive=_arc, github_path=_gp1963) + assert_eq("#1963 helper: GITHUB_PATH run still succeeds", 0, _rc_gp) + assert_eq("#1963 helper: appends DEST_BIN to GITHUB_PATH so the model sees the tools", + str(_repo_gp / "bin"), _gp1963.read_text(encoding="utf-8").strip()) + + # unsupported-lint-platform: no artifact for the requested (os,arch), and no + # pre-provisioned tool on PATH -> degrade (warn + continue), not fail closed. + _um1 = _d1388b / "unsupported-nopath.marker" + _rc, _out = _run_helper_1388(_repo, archive=_arc, arch="arm64", curl_marker=_um1) + assert_eq("#1388 helper: unsupported tuple degrades (rc 0)", 0, _rc) + assert_eq("#1388 helper: unsupported names the tool + reason", True, + "shellcheck: unsupported-lint-platform" in _out) + assert_eq("#1388 helper: unsupported emits a GitHub warning annotation", True, + "::warning::" in _out) + assert_eq("#1388 helper: unsupported degrade never invoked the downloader", False, _um1.exists()) + + # unsupported-lint-platform + a pre-provisioned tool on PATH at the pinned + # version -> reused, no download. + _um2 = _d1388b / "unsupported-path.marker" + _rc, _out = _run_helper_1388(_repo, archive=_arc, arch="arm64", curl_marker=_um2, + path_tools={"shellcheck": "9.9.9"}) + assert_eq("#1388 helper: unsupported + PATH tool at pinned version reuses (rc 0)", 0, _rc) + assert_eq("#1388 helper: unsupported PATH reuse reports reused pre-provisioned", True, + "reused pre-provisioned" in _out) + assert_eq("#1388 helper: unsupported PATH reuse never invoked the downloader", False, _um2.exists()) + + # not-ready: corrupt a bound component so readiness refuses BEFORE any tool work. + _repo_nr = _mk_repo_1388(_d1388b / "nr", _mk_manifest_1388(_dig)) + (_repo_nr / "scripts" / "lint_manifest.py").write_text("changed\n", encoding="utf-8") + _rc, _out = _run_helper_1388(_repo_nr, archive=_arc) + assert_eq("#1388 helper: readiness refusal fails closed", 1, _rc) + assert_eq("#1388 helper: readiness refusal names digest-mismatch", True, "digest-mismatch:helper" in _out) + + # missing installer primitive: an absent downloader (fresh repo — no cache hit). + _repo_mp = _mk_repo_1388(_d1388b / "mp", _mk_manifest_1388(_dig)) + _rc, _out = _run_helper_1388(_repo_mp, archive=_arc, extra_env={"LINTPROV_CURL": "/nonexistent/curl-xyz"}) + assert_eq("#1388 helper: missing primitive fails closed", 1, _rc) + assert_eq("#1388 helper: missing primitive named", True, "installer primitive not found" in _out) + + # network failure: downloader exits non-zero (fresh repo — no cache hit). + _repo_nf = _mk_repo_1388(_d1388b / "nf", _mk_manifest_1388(_dig)) + _rc, _out = _run_helper_1388(_repo_nf, archive=_arc, curl_rc=7) + assert_eq("#1388 helper: network failure fails closed", 1, _rc) + assert_eq("#1388 helper: network failure names the tool", True, "shellcheck: network failure" in _out) + + # checksum mismatch: manifest pins a digest the downloaded bytes do not match. + _repo_cm = _mk_repo_1388(_d1388b / "cm", _mk_manifest_1388("sha256:" + "e" * 64)) + _rc, _out = _run_helper_1388(_repo_cm, archive=_arc) + assert_eq("#1388 helper: checksum mismatch fails closed", 1, _rc) + assert_eq("#1388 helper: checksum mismatch named", True, "checksum mismatch" in _out) + + # archive mismatch: digest pins corrupt (non-archive) bytes; extraction fails. + _bad_arc, _bad_dig = _mk_archive_1388(_d1388b, "shellcheck", "x", valid=False) + _repo_am = _mk_repo_1388(_d1388b / "am", _mk_manifest_1388(_bad_dig)) + _rc, _out = _run_helper_1388(_repo_am, archive=_bad_arc) + assert_eq("#1388 helper: archive mismatch fails closed", 1, _rc) + assert_eq("#1388 helper: archive mismatch named", True, "archive mismatch" in _out) + + # wrong version: fake tool reports a version the manifest does not declare. + _wv_arc, _wv_dig = _mk_archive_1388(_d1388b, "shellcheck", "1.1.1") + _repo_wv = _mk_repo_1388(_d1388b / "wv", _mk_manifest_1388(_wv_dig, version="9.9.9")) + _rc, _out = _run_helper_1388(_repo_wv, archive=_wv_arc) + assert_eq("#1388 helper: wrong version fails closed", 1, _rc) + assert_eq("#1388 helper: wrong version named", True, "wrong version" in _out) + + # unwritable target: DEST_BIN under a read-only directory. Guarded on uid like the + # two sibling permission fixtures in this file — root ignores the mode bits, so + # unguarded this is an environment-dependent RED that attributes itself to the + # helper rather than to the fixture. + if _os.geteuid() != 0: + _ro = _d1388b / "roparent" + _ro.mkdir() + _ro.chmod(0o555) + try: + _rc, _out = _run_helper_1388(_repo, archive=_arc, dest_bin=_ro / "sub" / "bin") + assert_eq("#1388 helper: unwritable target fails closed", 1, _rc) + assert_eq("#1388 helper: unwritable target named", True, "unwritable target" in _out) + finally: + _ro.chmod(0o755) +finally: + shutil.rmtree(_d1388b, ignore_errors=True) + + +# ── issue #1388 (review fixes): version-anchoring, within-job reuse, zip, guards ── +import zipfile as _zip1388 # noqa: E402 +_d1388d = Path(tempfile.mkdtemp()) +try: + # Whole-token version match: manifest pins 1.2, the tool reports 1.24.1 -> wrong + # version (a substring match would have accepted it). + _va_arc, _va_dig = _mk_archive_1388(_d1388d, "shellcheck", "1.24.1") + _repo_va = _mk_repo_1388(_d1388d / "va", _mk_manifest_1388(_va_dig, version="1.2")) + _rc, _out = _run_helper_1388(_repo_va, archive=_va_arc) + assert_eq("#1388 helper: superset version (1.2 vs 1.24.1) is rejected", 1, _rc) + assert_eq("#1388 helper: superset version named wrong version", True, "wrong version" in _out) + + # Within-job reuse: a second run over the same repo+DEST_BIN reuses the verified + # install (no re-download) instead of failing. + _ok_arc, _ok_dig = _mk_archive_1388(_d1388d, "shellcheck", "9.9.9") + _repo_ru = _mk_repo_1388(_d1388d / "ru", _mk_manifest_1388(_ok_dig)) + _rc1, _o1 = _run_helper_1388(_repo_ru, archive=_ok_arc) + _rc2, _o2 = _run_helper_1388(_repo_ru, archive=_ok_arc) + assert_eq("#1388 helper: first install succeeds", 0, _rc1) + assert_eq("#1388 helper: second run reuses the verified install (no re-download)", 0, _rc2) + assert_eq("#1388 helper: reuse path names the verified reuse", True, "reused verified install" in _o2) + + # extract-zip end-to-end (only where a real unzip is on PATH, else the missing-primitive + # arm is exercised instead — both are valid fail-open-free outcomes). + _zdir = _d1388d / "z" + _zdir.mkdir() + _member = _zdir / "shellcheck" + _member.write_text("#!/bin/sh\necho 'shellcheck 9.9.9'\n", encoding="utf-8") + _member.chmod(0o755) + _zarc = _d1388d / "artifact.zip" + with _zip1388.ZipFile(_zarc, "w") as zf: + zf.write(_member, arcname="nested/shellcheck") + _zdig = _install_state.digest_bytes(_zarc.read_bytes()) + _zman = _mk_manifest_1388(_ok_dig) + _zman["tools"]["shellcheck"]["artifacts"][0].update( + {"digest": _zdig, "archive_type": "zip", "strategy": "extract-zip"}) + _repo_z = _mk_repo_1388(_d1388d / "zr", _zman) + _rc, _out = _run_helper_1388(_repo_z, archive=_zarc) + if _sp1388.run(["sh", "-c", "command -v unzip"], capture_output=True).returncode == 0: + assert_eq("#1388 helper: extract-zip strategy installs + verifies", 0, _rc) + else: + assert_eq("#1388 helper: extract-zip without unzip fails closed on the primitive", 1, _rc) + assert_eq("#1388 helper: missing unzip primitive named", True, "installer primitive not found" in _out) + + # Established plan + a pre-provisioned tool on PATH at the pinned version -> reused, + # downloader never invoked. + _pp_repo = _mk_repo_1388(_d1388d / "pp", _mk_manifest_1388(_ok_dig)) + _pp_marker = _d1388d / "pp.marker" + _rc, _out = _run_helper_1388(_pp_repo, archive=_ok_arc, curl_marker=_pp_marker, + path_tools={"shellcheck": "9.9.9"}) + assert_eq("#1388 helper: established + matching PATH tool reuses (rc 0)", 0, _rc) + assert_eq("#1388 helper: established PATH reuse reports reused pre-provisioned", True, + "reused pre-provisioned" in _out) + assert_eq("#1388 helper: established PATH reuse never invoked the downloader", False, _pp_marker.exists()) + + # Established plan + a PATH tool at the WRONG version -> download path taken. + _wp_repo = _mk_repo_1388(_d1388d / "wp", _mk_manifest_1388(_ok_dig)) + _wp_marker = _d1388d / "wp.marker" + _rc, _out = _run_helper_1388(_wp_repo, archive=_ok_arc, curl_marker=_wp_marker, + path_tools={"shellcheck": "1.1.1"}) + assert_eq("#1388 helper: established + wrong-version PATH tool still installs (rc 0)", 0, _rc) + assert_eq("#1388 helper: wrong-version PATH tool triggers the download path", True, _wp_marker.exists()) + + # LINTPROV_SKIP_PATH_REUSE=1 + a matching PATH tool on an established plan -> the + # rung is skipped and the download path is taken anyway. + _sk_repo = _mk_repo_1388(_d1388d / "sk", _mk_manifest_1388(_ok_dig)) + _sk_marker = _d1388d / "sk.marker" + _rc, _out = _run_helper_1388(_sk_repo, archive=_ok_arc, curl_marker=_sk_marker, + path_tools={"shellcheck": "9.9.9"}, + extra_env={"LINTPROV_SKIP_PATH_REUSE": "1"}) + assert_eq("#1388 helper: LINTPROV_SKIP_PATH_REUSE=1 forces the download path (rc 0)", 0, _rc) + assert_eq("#1388 helper: LINTPROV_SKIP_PATH_REUSE=1 invoked the downloader", True, _sk_marker.exists()) + + # Version-token guard: a PATH tool reporting 0.10.01 must NOT satisfy pinned 0.10.0 + # (whole-token match, not a substring/prefix match). + _vt_man = _mk_manifest_1388(_ok_dig, version="0.10.0") + _vt_repo = _mk_repo_1388(_d1388d / "vt", _vt_man) + _vt_marker = _d1388d / "vt.marker" + _rc, _out = _run_helper_1388(_vt_repo, archive=_ok_arc, curl_marker=_vt_marker, + path_tools={"shellcheck": "0.10.01"}) + assert_eq("#1388 helper: 0.10.01 does not satisfy pinned 0.10.0 (download path taken)", True, + _vt_marker.exists()) +finally: + shutil.rmtree(_d1388d, ignore_errors=True) + +# Type guards make illegal states unrepresentable (review type-design finding). +assert_raises("#1388 Plan: an out-of-vocabulary status raises (no else-is-established fail-open)", + ValueError, lambda: _lint_provision.Plan("bogus")) +assert_raises("#1388 StateResult: established with no state raises", + ValueError, lambda: _install_state.StateResult("established")) +assert_raises("#1388 Readiness: not-ready with no reason raises", + ValueError, lambda: _install_state.Readiness(False)) +# PR #1963 reception: the invariants are enforced at construction, not by convention. +assert_raises("#1388 Plan: established without resolved fields raises", + ValueError, lambda: _lint_provision.Plan("established", tool="shellcheck", + os="linux", arch="x86_64")) +assert_raises("#1388 Plan: a no-answer status without a reason raises", + ValueError, lambda: _lint_provision.Plan("unsupported")) +assert_raises("#1388 Readiness: ready with a (stale) reason raises", + ValueError, lambda: _install_state.Readiness(True, "leftover-reason")) +# Round 2: the XOR is enforced in BOTH directions and the verdicts are frozen. +assert_raises("#1388 Plan: established with a (stale) reason raises", + ValueError, lambda: _lint_provision.Plan( + "established", tool="shellcheck", os="linux", arch="x86_64", + version="1", digest="sha256:" + "a" * 64, archive_type="tar.gz", + member="shellcheck", strategy="extract-tar", url="https://x", + reason="leftover")) +assert_raises("#1388 Plan: a no-answer status smuggling resolved fields raises", + ValueError, lambda: _lint_provision.Plan( + "unsupported", reason="unsupported-lint-platform", url="https://x")) +assert_raises("#1388 StateResult: established with a (stale) reason raises", + ValueError, lambda: _install_state.StateResult( + "established", state={"k": 1}, reason="leftover")) +assert_raises("#1388 StateResult: unestablished smuggling a state raises", + ValueError, lambda: _install_state.StateResult( + "unestablished", reason="r", state={"k": 1})) + + +def _mutate_1388(obj, name): + def _do(): + setattr(obj, name, "tampered") + return _do + + +assert_raises("#1388 Readiness: post-construction assignment raises (frozen)", + AttributeError, _mutate_1388(_install_state.Readiness(True), "ready")) +assert_raises("#1388 StateResult: post-construction assignment raises (frozen)", + AttributeError, + _mutate_1388(_install_state.StateResult("unestablished", reason="r"), "reason")) +assert_raises("#1388 Plan: post-construction assignment raises (frozen)", + AttributeError, + _mutate_1388(_lint_provision.Plan("unsupported", reason="x"), "status")) +# Round 2: a slash-bearing branch-ref installer_version (e.g. a consumer pinning +# `feature/x`) is legal — build and validate stay in lockstep on the shared regex. +assert_eq("#1388 state: slash-bearing installer_version validates (branch-ref pin)", True, + _install_state.validate_state(_mk_state(installer_version="feature/x")).established) +# PR #1963 reception: readiness names an INVALID (present) manifest distinctly, and the +# helper's final summary reports only what actually landed. +_d1388e = Path(tempfile.mkdtemp()) +try: + _e_arc, _e_dig = _mk_archive_1388(_d1388e, "shellcheck", "9.9.9") + _repo_e = _mk_repo_1388(_d1388e / "sum", _mk_manifest_1388(_e_dig)) + # Positive control on the same fixture: the component is readable, so the ONLY + # rejection cause below is the installer_version — never an unreadable component. + assert_eq("#1388 build_state: control — same component builds under a valid installer_version", True, + isinstance(_install_state.build_state( + "v1", {"manifest": ".prflow/lint-manifest.json"}, repo_root=_repo_e), dict)) + assert_raises("#1388 build_state: an installer_version validate_state would reject raises (no marker published)", + ValueError, lambda: _install_state.build_state( + "bad ref;x", {"manifest": ".prflow/lint-manifest.json"}, repo_root=_repo_e)) + (_repo_e / ".prflow" / "bad-manifest.json").write_text("{not json", encoding="utf-8") + _mu = _install_state.check_readiness(_repo_e / ".prflow" / "install-state.json", + _repo_e / ".prflow" / "bad-manifest.json", + repo_root=_repo_e) + assert_eq("#1388 readiness: present-but-invalid manifest -> manifest-unestablished:", True, + (not _mu.ready) and _mu.reason.startswith("manifest-unestablished:")) + # Round 2 (I-1): a PRESENT manifest with a structural `missing:` reason (a missing + # required key) must NOT be mislabeled `manifest-missing` — that label is reserved + # for the file-absent sentinel, or an operator hunts for a file that exists. + (_repo_e / ".prflow" / "keyless-manifest.json").write_text('{"schema_version": 1}\n', + encoding="utf-8") + _mk_r2 = _install_state.check_readiness(_repo_e / ".prflow" / "install-state.json", + _repo_e / ".prflow" / "keyless-manifest.json", + repo_root=_repo_e) + assert_eq("#1388 readiness: present manifest with structural missing-key -> manifest-unestablished, never manifest-missing", True, + (not _mk_r2.ready) + and _mk_r2.reason.startswith("manifest-unestablished:missing:") + and _mk_r2.reason != "manifest-missing") + _rc_e, _out_e = _run_helper_1388(_repo_e, archive=_e_arc, arch="arm64") + assert_eq("#1388 helper: degraded tool listed as unprovisioned, not provisioned", True, + "unprovisioned (degraded): shellcheck" in _out_e + and "provisioned: shellcheck" not in _out_e) + _rc_e2, _out_e2 = _run_helper_1388(_repo_e, archive=_e_arc) + assert_eq("#1388 helper: provisioned summary lists the installed tool", True, + "provisioned: shellcheck" in _out_e2) +finally: + shutil.rmtree(_d1388e, ignore_errors=True) +# Matrix completeness: component sub-object shapes and a missing required top-level key. +assert_eq("#1388 state: components wrong-type (array) rejected", True, + _install_state.validate_state(_mk_state(components=[])).reason.startswith("invalid-value:")) +assert_eq("#1388 state: a missing required top-level key rejected", True, + _install_state.parse_state(b'{"schema_version":1,"installer_version":"v"}').reason.startswith("missing:")) +assert_eq("#1388 state: component missing digest rejected", True, + _install_state.validate_state(_mk_state(components={"m": {"path": "x"}})).reason.startswith("missing:")) + +# Round 3: ManifestResult enforces the same both-direction XOR + freeze as its +# three siblings (Plan/StateResult/Readiness), and Readiness types its verdict. +assert_raises("#1388 ManifestResult: established without a manifest raises", + ValueError, lambda: lint_manifest.ManifestResult("established")) +assert_raises("#1388 ManifestResult: established with a (stale) reason raises", + ValueError, lambda: lint_manifest.ManifestResult( + "established", manifest={"k": 1}, reason="leftover")) +assert_raises("#1388 ManifestResult: unestablished without a reason raises", + ValueError, lambda: lint_manifest.ManifestResult("unestablished")) +assert_raises("#1388 ManifestResult: unestablished smuggling a manifest raises", + ValueError, lambda: lint_manifest.ManifestResult( + "unestablished", reason="r", manifest={"k": 1})) +assert_raises("#1388 ManifestResult: post-construction assignment raises (frozen)", + AttributeError, + _mutate_1388(lint_manifest.ManifestResult("unestablished", reason="r"), "reason")) +assert_raises("#1388 Readiness: a truthy non-bool ready raises (typed verdict)", + ValueError, lambda: _install_state.Readiness(1)) + +# Round 3: unknown-lint-tool is fail-closed end-to-end — distinct exit code from +# the resolver (4, never the degradable 3) and a refusal from the shell helper. +_d1388f = Path(tempfile.mkdtemp()) +try: + _f_arc, _f_dig = _mk_archive_1388(_d1388f, "shellcheck", "9.9.9") + _repo_f = _mk_repo_1388(_d1388f / "ut", _mk_manifest_1388(_f_dig)) + _cli_f = _sp1388.run( + [sys.executable, str(SCRIPTS / "lint_provision.py"), "plan", + "--manifest", str(_repo_f / ".prflow" / "lint-manifest.json"), + "--tool", "gcc", "--os", "linux", "--arch", "x86_64"], + capture_output=True, text=True) + assert_eq("#1388 CLI: unknown tool exits 4 (distinct from unsupported platform's 3)", + (4, "unknown-lint-tool"), (_cli_f.returncode, _cli_f.stdout.strip())) + _cli_f3 = _sp1388.run( + [sys.executable, str(SCRIPTS / "lint_provision.py"), "plan", + "--manifest", str(_repo_f / ".prflow" / "lint-manifest.json"), + "--tool", "shellcheck", "--os", "linux", "--arch", "arm64"], + capture_output=True, text=True) + assert_eq("#1388 CLI: unsupported platform still exits 3 with its own reason", + (3, "unsupported-lint-platform"), (_cli_f3.returncode, _cli_f3.stdout.strip())) + _ut_marker = _d1388f / "ut.marker" + _rc_f, _out_f = _run_helper_1388(_repo_f, archive=_f_arc, tools="gcc", + curl_marker=_ut_marker, + path_tools={"gcc": "9.9.9"}) + assert_eq("#1388 helper: unknown tool fails closed even with a PATH candidate (rc 1)", 1, _rc_f) + assert_eq("#1388 helper: unknown tool named", True, "gcc: unknown-lint-tool" in _out_f) + assert_eq("#1388 helper: unknown tool never invoked the downloader", False, _ut_marker.exists()) + # Round 3 (S-5): a readiness refusal names the operator remedy, not just the cause. + (_repo_f / "scripts" / "lint_manifest.py").write_bytes(b"# drifted component\n") + _rc_r, _out_r = _run_helper_1388(_repo_f, archive=_f_arc) + assert_eq("#1388 helper: readiness refusal names the re-run-installer remedy", True, + _rc_r == 1 and "remedy: re-run the PRFlow installer" in _out_r) +finally: + shutil.rmtree(_d1388f, ignore_errors=True) + + +# ── issue #1388: devflow-runner.yml hardensetup — the SHIPPED step body, end-to-end ── +# The security control (base-ref materialization + PR-head prune) is executed as the +# exact bytes the workflow ships: the `run` block is extracted from the YAML, so an +# edit to the step is exercised here with no mirror script to drift. +import yaml as _yaml1388 # noqa: E402 + +_runner_yaml_1388 = _yaml1388.safe_load( + (SCRIPTS.parent / ".github" / "workflows" / "devflow-runner.yml").read_text(encoding="utf-8")) +_harden_run_1388 = None +for _job1388 in _runner_yaml_1388.get("jobs", {}).values(): + for _step1388 in _job1388.get("steps", []) or []: + if isinstance(_step1388, dict) and _step1388.get("id") == "hardensetup": + _harden_run_1388 = _step1388.get("run") +assert_eq("#1388 hardensetup: the step exists and carries a run block", True, + isinstance(_harden_run_1388, str) and "set -euo pipefail" in _harden_run_1388) + + +def _git_1388(cwd, *args): + return _sp1388.run(["git", "-c", "user.email=t@t", "-c", "user.name=t", + "-c", "commit.gpgsign=false", *args], + cwd=str(cwd), capture_output=True, text=True, check=True) + + +def _run_harden_1388(head_repo, base_ref, github_output=None): + env = dict(os.environ) + env["BASE_REF"] = base_ref + env.pop("GITHUB_OUTPUT", None) + if github_output is not None: + env["GITHUB_OUTPUT"] = str(github_output) + proc = _sp1388.run(["bash", "-c", _harden_run_1388], cwd=str(head_repo), + env=env, capture_output=True, text=True) + return proc.returncode, proc.stdout + proc.stderr + + +_d1388h = Path(tempfile.mkdtemp()) +try: + _adir = Path(".github/actions/setup-project-env") + # Trusted origin: main carries the action dir with known-good bytes. + _origin_h = _d1388h / "origin" + (_origin_h / _adir).mkdir(parents=True) + (_origin_h / _adir / "action.yml").write_text("trusted-action\n", encoding="utf-8") + (_origin_h / _adir / "trusted.sh").write_text("trusted-helper\n", encoding="utf-8") + _git_1388(_origin_h, "init", "-b", "main", ".") + _git_1388(_origin_h, "add", "-A") + _git_1388(_origin_h, "commit", "-m", "base") + # PR head: a clone whose head EDITS action.yml and ADDS a helper. + _head_h = _d1388h / "head" + _git_1388(_d1388h, "clone", "file://" + str(_origin_h), str(_head_h)) + (_head_h / _adir / "action.yml").write_text("evil-edit\n", encoding="utf-8") + (_head_h / _adir / "evil.sh").write_text("evil-addition\n", encoding="utf-8") + _git_1388(_head_h, "add", "-A") + _git_1388(_head_h, "commit", "-m", "pr head") + _go_h = _d1388h / "harden_output" + _go_h.write_text("", encoding="utf-8") + _rc_h, _out_h = _run_harden_1388(_head_h, "main", github_output=_go_h) + assert_eq("#1388 hardensetup: succeeds against a trusted base ref (rc 0)", 0, _rc_h) + assert_eq("#1388 hardensetup: a PR-head EDIT is overwritten by the base bytes", + "trusted-action\n", (_head_h / _adir / "action.yml").read_text(encoding="utf-8")) + assert_eq("#1388 hardensetup: a base file the PR left alone survives with base bytes", + "trusted-helper\n", (_head_h / _adir / "trusted.sh").read_text(encoding="utf-8")) + assert_eq("#1388 hardensetup: a PR-head ADDED file is pruned", False, + (_head_h / _adir / "evil.sh").exists()) + # PR #1963 reception: the step DISPLACES PR-head bytes, so it must disclose them. + # Undisclosed, the reviewing agent reads these base-ref bytes as untouched PR-head + # content — on exactly the file a PR editing this action is under review for. + _disc_1963 = _go_h.read_text(encoding="utf-8") + assert_eq("#1963 hardensetup: publishes a displaced_setup_paths output at all", True, + "displaced_setup_paths<<" in _disc_1963) + for _want_1963 in (str(_adir / "action.yml"), str(_adir / "trusted.sh"), + str(_adir / "evil.sh")): + assert_eq(f"#1963 hardensetup: discloses {_want_1963}", True, + _want_1963 in _disc_1963) + # And the join must actually carry it, or the disclosure never reaches the prompt. + assert_eq("#1963 hardensetup: displaced_join consumes the hardensetup producer", True, # structural-pin-ok: cross-file-phase-contract -- the producer->join wiring is the disclosure path + "steps.hardensetup.outputs.displaced_setup_paths" in (SCRIPTS.parent / ".github" / "workflows" / "devflow-runner.yml").read_text(encoding="utf-8")) + # Outside Actions (no GITHUB_OUTPUT) the security control still runs and succeeds. + _head_h2 = _d1388h / "head2" + _git_1388(_d1388h, "clone", "file://" + str(_origin_h), str(_head_h2)) + assert_eq("#1963 hardensetup: runs with no GITHUB_OUTPUT set (rc 0)", 0, + _run_harden_1388(_head_h2, "main")[0]) + # Fail-closed: a base ref with NO action dir refuses (never falls back to PR-head bytes). + _origin_n = _d1388h / "origin-none" + _origin_n.mkdir() + (_origin_n / "README.md").write_text("no action dir\n", encoding="utf-8") + _git_1388(_origin_n, "init", "-b", "main", ".") + _git_1388(_origin_n, "add", "-A") + _git_1388(_origin_n, "commit", "-m", "base without action dir") + _head_n = _d1388h / "head-none" + _git_1388(_d1388h, "clone", "file://" + str(_origin_n), str(_head_n)) + (_head_n / _adir).mkdir(parents=True) + (_head_n / _adir / "action.yml").write_text("pr-injected-action\n", encoding="utf-8") + _git_1388(_head_n, "add", "-A") + _git_1388(_head_n, "commit", "-m", "pr adds the action dir") + _rc_n, _out_n = _run_harden_1388(_head_n, "main") + assert_eq("#1388 hardensetup: base ref without the action dir fails closed", True, + _rc_n != 0 and "carries no" in _out_n) + assert_eq("#1388 hardensetup: the refusal leaves no PR-injected action body blessed", True, + (_head_n / _adir / "action.yml").read_text(encoding="utf-8") == "pr-injected-action\n") + # Fail-closed: an unfetchable base ref refuses. + _rc_u, _out_u = _run_harden_1388(_head_h, "no-such-ref") + assert_eq("#1388 hardensetup: an unfetchable base ref fails closed", True, + _rc_u != 0 and "could not fetch base ref" in _out_u) +finally: + shutil.rmtree(_d1388h, ignore_errors=True) + + +# ── issue #1388: workflow + composite-action wiring pins (cross-file contract) ── +_WF_1388 = SCRIPTS.parent / '.github' / 'workflows' +_ACTION_1388 = (SCRIPTS.parent / '.github' / 'actions' / 'setup-project-env' / 'action.yml').read_text(encoding='utf-8') +_dv1388 = (_WF_1388 / 'devflow.yml').read_text(encoding='utf-8') +_di1388 = (_WF_1388 / 'devflow-implement.yml').read_text(encoding='utf-8') +_dr1388 = (_WF_1388 / 'devflow-runner.yml').read_text(encoding='utf-8') + +# AC4/AC6: the composite action declares a closed lint_mode input and refuses an unknown value. +assert_eq("#1388 action: declares a lint_mode input", True, # structural-pin-ok: schema-config-vocabulary -- the closed lint_mode input is the action's typed contract + "lint_mode:" in _ACTION_1388) +assert_eq("#1388 action: refuses an unknown lint_mode (closed set)", True, # structural-pin-ok: schema-config-vocabulary -- fail-closed refusal of an out-of-set value + "unknown lint_mode" in _ACTION_1388) +assert_eq("#1388 action: none mode returns from the step without dispatching", True, # structural-pin-ok: routing-dispatch-contract -- the none-mode arm returns before the helper dispatch + re.search(r"^\s*none\)\n(?:.*\n)*?\s*exit 0\n", _ACTION_1388, re.M) is not None) +assert_eq("#1388 action: provision invokes the provisioning helper", True, # structural-pin-ok: routing-dispatch-contract -- provision dispatches the bundled helper + "provision-lint-tools.sh" in _ACTION_1388) +assert_eq("#1388 action: caches the toolchain keyed on the AC5 tuple (OS/arch + manifest+marker hash)", True, # structural-pin-ok: routing-dispatch-contract -- cross-run cache restore keyed on {OS,arch,tool,version,digest,installer} + "uses: actions/cache@v5" in _ACTION_1388 + and "lintprov-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.prflow/lint-manifest.json', '.prflow/install-state.json') }}" in _ACTION_1388) + +# AC7: the three callers pass tested lint modes none / provision / none. +assert_eq("#1388 wiring: devflow.yml passes lint_mode: none", 1, # structural-pin-ok: routing-dispatch-contract -- command tier lint mode + _dv1388.count("lint_mode: none")) +assert_eq("#1388 wiring: devflow-implement.yml passes lint_mode: provision", 1, # structural-pin-ok: routing-dispatch-contract -- implement tier lint mode + _di1388.count("lint_mode: provision")) +assert_eq("#1388 wiring: devflow-runner.yml passes lint_mode: none", 1, # structural-pin-ok: routing-dispatch-contract -- review tier lint mode + _dr1388.count("lint_mode: none")) +# Pinned below: the review tier passes lint_mode: none, never provision (count == 0). +assert_eq("#1388 wiring: only implement provisions (review never does)", 0, # structural-pin-ok: security-credential-boundary -- no manifest-derived bytes in the review job + _dr1388.count("lint_mode: provision")) + +# AC8: the review runner hardens the setup action onto trusted base-ref bytes and +# never executes the PR-head action body. The hardening step must precede the use. +assert_eq("#1388 review-isolation: runner hardens setup-project-env onto base-ref bytes", True, # structural-pin-ok: security-credential-boundary -- trusted-base materialization of the action body + "Harden setup-project-env onto trusted base-ref bytes" in _dr1388) +_hard_idx = _dr1388.find("Harden setup-project-env onto trusted base-ref bytes") +_prov_idx = _dr1388.find("Provision project environment (opt-in)") +assert_eq("#1388 review-isolation: hardening precedes the provision step", True, # structural-pin-ok: cross-file-phase-contract -- ordering: trusted bytes materialized before use + 0 <= _hard_idx < _prov_idx) +assert_eq("#1388 review-isolation: hardening materializes every base-ref action file from FETCH_HEAD", True, # structural-pin-ok: security-credential-boundary -- whole-dir base-ref materialization + 'git ls-tree -r --name-only FETCH_HEAD -- "$dir"' in _dr1388 and 'git show "FETCH_HEAD:$f"' in _dr1388) +# Addendum (issue #1388, 2026-08-25): AC8's narrowed scope pinned in BOTH directions — +# the hardened set is exactly {setup-project-env}; read-project-config and +# vendor-plugin stay PR-head-resolved (the recorded residual, predating this issue). +assert_eq("#1388 review-isolation: hardened set is exactly setup-project-env", 1, # structural-pin-ok: security-credential-boundary -- widening or shrinking the hardened set must restate the recorded residual + _dr1388.count('dir=".github/actions/setup-project-env"')) +assert_eq("#1388 review-isolation: read-project-config stays PR-head-resolved (recorded residual)", True, # structural-pin-ok: security-credential-boundary -- the residual set, pinned so it cannot silently grow or vanish + "uses: ./.github/actions/read-project-config" in _dr1388) +assert_eq("#1388 review-isolation: vendor-plugin stays PR-head-resolved (recorded residual)", True, # structural-pin-ok: security-credential-boundary -- the residual set, pinned so it cannot silently grow or vanish + "uses: ./.github/actions/vendor-plugin" in _dr1388) + + +# ── issue #1388: the tracked marker ships, validates, and stays in sync ── +_REPO_1388 = SCRIPTS.parent +# AC3: git ls-files proves the manifest AND the marker both ship. +_tracked_1388 = _sp1388.run( + ["git", "ls-files", ".prflow/lint-manifest.json", ".prflow/install-state.json"], + cwd=str(_REPO_1388), capture_output=True, text=True).stdout.split() +assert_eq("#1388 ships: lint-manifest.json is tracked", True, ".prflow/lint-manifest.json" in _tracked_1388) +assert_eq("#1388 ships: install-state.json marker is tracked", True, ".prflow/install-state.json" in _tracked_1388) + +# The committed marker validates and is READY against the repo tree (self-consistent). +_marker_1388 = _install_state.load_state(_REPO_1388 / ".prflow" / "install-state.json") +assert_eq("#1388 marker: committed marker validates (establishes)", True, _marker_1388.established) +assert_eq("#1388 marker: committed marker is READY against the repo tree", True, + _install_state.check_readiness(_REPO_1388 / ".prflow" / "install-state.json", + _REPO_1388 / ".prflow" / "lint-manifest.json", + repo_root=_REPO_1388).ready) + +# Drift gate: the generator's --check must pass, or a bound component changed +# without the marker being regenerated (RED names the regeneration command). +_drift_1388 = _sp1388.run( + ["python3", str(_REPO_1388 / "lib" / "generate-install-state.py"), "--check"], + cwd=str(_REPO_1388), capture_output=True, text=True) +assert_eq("#1388 marker: install-state marker is in sync with its bound components", 0, _drift_1388.returncode) + +# PR #1963 reception: the drift gate needs a RED direction. Asserting only that +# --check exits 0 on the current tree leaves an inverted comparison inert with nothing +# to say so. Drive it over a COPY of the repo — never the working tree — so an +# interrupted check cannot leave a real component mutated. +_d1963d = Path(tempfile.mkdtemp()) +try: + _rc1963 = _d1963d / "repo" + shutil.copytree(_REPO_1388, _rc1963, symlinks=True, + ignore=shutil.ignore_patterns(".git", ".claude", "node_modules")) + assert_eq("#1963 drift gate: control — the copied tree is in sync (exit 0)", 0, + _sp1388.run(["python3", str(_rc1963 / "lib" / "generate-install-state.py"), "--check"], + cwd=str(_rc1963), capture_output=True, text=True).returncode) + _bound_1963 = _rc1963 / ".github" / "actions" / "setup-project-env" / "provision-lint-tools.sh" + _bound_1963.write_text(_bound_1963.read_text(encoding="utf-8") + "\n# drift\n", encoding="utf-8") + _red_1963 = _sp1388.run( + ["python3", str(_rc1963 / "lib" / "generate-install-state.py"), "--check"], + cwd=str(_rc1963), capture_output=True, text=True) + assert_eq("#1963 drift gate: a mutated bound component goes RED (exit 1)", 1, _red_1963.returncode) + assert_eq("#1963 drift gate: the RED breadcrumb names the regeneration command", True, + "lib/generate-install-state.py" in _red_1963.stderr) + assert_eq("#1963 drift gate: an unrecognized argument refuses instead of writing", 2, + _sp1388.run(["python3", str(_rc1963 / "lib" / "generate-install-state.py"), "--chek"], + cwd=str(_rc1963), capture_output=True, text=True).returncode) +finally: + shutil.rmtree(_d1963d, ignore_errors=True) + + +# ── issue #1388: install.sh publish path — digest SOURCE, record RUNTIME (skew) ── +_d1388c = Path(tempfile.mkdtemp()) +try: + _src = _d1388c / "src" + (_src / ".prflow").mkdir(parents=True) + (_src / "scripts").mkdir() + (_src / ".prflow" / "lint-manifest.json").write_bytes(_MANIFEST_1388.read_bytes()) + (_src / "scripts" / "lint_manifest.py").write_text("READER-BYTES\n", encoding="utf-8") + _sk = _install_state.build_state( + "abc123", + {"manifest": ".prflow/lint-manifest.json", "reader": "scripts/lint_manifest.py"}, + repo_root=_src, + record_paths={"reader": ".prflow/vendor/prflow/scripts/lint_manifest.py"}) + assert_eq("#1388 publish: records the RUNTIME path, not the source path", + ".prflow/vendor/prflow/scripts/lint_manifest.py", _sk["components"]["reader"]["path"]) + # A consumer tree laid out at the runtime paths with IDENTICAL bytes verifies READY. + _con = _d1388c / "consumer" + (_con / ".prflow" / "vendor" / "prflow" / "scripts").mkdir(parents=True) + (_con / ".prflow" / "lint-manifest.json").write_bytes(_MANIFEST_1388.read_bytes()) + (_con / ".prflow" / "vendor" / "prflow" / "scripts" / "lint_manifest.py").write_text("READER-BYTES\n", encoding="utf-8") + _mk = _con / ".prflow" / "install-state.json" + _mk.write_text(json.dumps(_sk) + "\n", encoding="utf-8") + assert_eq("#1388 publish: runtime tree with identical bytes is READY", True, + _install_state.check_readiness(_mk, _con / ".prflow" / "lint-manifest.json", repo_root=_con).ready) + # A runtime helper whose bytes drifted from the pinned source -> digest-mismatch. + (_con / ".prflow" / "vendor" / "prflow" / "scripts" / "lint_manifest.py").write_text("DRIFTED\n", encoding="utf-8") + assert_eq("#1388 publish: drifted runtime helper -> digest-mismatch names it", + "digest-mismatch:reader", + _install_state.check_readiness(_mk, _con / ".prflow" / "lint-manifest.json", repo_root=_con).reason) +finally: + shutil.rmtree(_d1388c, ignore_errors=True) + +# ── PR #1963 reception: the marker describes the CONSUMER tree, not the source ── +# A component install.sh PRESERVES (install_managed's modified/unverified/unreadable +# arms) or SKIPS (the tier1_rc != 0 workflow arm) keeps its OLD consumer bytes. Binding +# such a component to SOURCE bytes publishes a marker no consumer tree can satisfy, so +# check_readiness returns digest-mismatch forever and the provisioning helper _die's the +# whole implement job — with a re-run-the-installer remedy that reproduces it exactly. +def _marker_1963(root, state): + p = root / ".prflow" / "install-state.json" + p.write_text(json.dumps(state) + "\n", encoding="utf-8") + return p + + +_d1963 = Path(tempfile.mkdtemp()) +try: + _s1963 = _d1963 / "src" + _c1963 = _d1963 / "consumer" + for _r1963 in (_s1963, _c1963): + (_r1963 / ".prflow").mkdir(parents=True) + (_r1963 / ".github" / "actions" / "setup-project-env").mkdir(parents=True) + (_r1963 / ".prflow" / "lint-manifest.json").write_bytes(_MANIFEST_1388.read_bytes()) + (_s1963 / "scripts").mkdir() + (_s1963 / "scripts" / "lint_manifest.py").write_text("READER\n", encoding="utf-8") + (_c1963 / ".prflow" / "vendor" / "prflow" / "scripts").mkdir(parents=True) + (_c1963 / ".prflow" / "vendor" / "prflow" / "scripts" / "lint_manifest.py").write_text( + "READER\n", encoding="utf-8") + (_s1963 / ".github" / "actions" / "setup-project-env" / "action.yml").write_text( + "NEW-ACTION\n", encoding="utf-8") + # The consumer edited theirs, so install_managed PRESERVED it (.prflow-new sidecar). + (_c1963 / ".github" / "actions" / "setup-project-env" / "action.yml").write_text( + "LOCALLY-EDITED\n", encoding="utf-8") + _comps1963 = {"manifest": ".prflow/lint-manifest.json", + "setup-action": ".github/actions/setup-project-env/action.yml", + "manifest-reader": "scripts/lint_manifest.py"} + _recp1963 = {"manifest-reader": ".prflow/vendor/prflow/scripts/lint_manifest.py"} + _man1963 = _c1963 / ".prflow" / "lint-manifest.json" + # RED direction: digesting every component from the SOURCE tree binds bytes the + # consumer never received, and no consumer action can ever converge it. + _old1963 = _install_state.build_state("abc123", _comps1963, repo_root=_s1963, + record_paths=_recp1963) + assert_eq("#1963 marker: source-digested preserved artifact is permanently unready", + "digest-mismatch:setup-action", + _install_state.check_readiness(_marker_1963(_c1963, _old1963), _man1963, + repo_root=_c1963).reason) + # GREEN: digest the CONSUMER tree by default; only the vendor-fetched reader is + # digested from the source, because it is not in the consumer tree at install time. + _new1963 = _install_state.build_state("abc123", _comps1963, repo_root=_c1963, + record_paths=_recp1963, + digest_roots={"manifest-reader": _s1963}) + assert_eq("#1963 marker: consumer-digested marker is READY over a preserved artifact", + True, + _install_state.check_readiness(_marker_1963(_c1963, _new1963), _man1963, + repo_root=_c1963).ready) + assert_eq("#1963 marker: the vendor-fetched reader still records its RUNTIME path", + ".prflow/vendor/prflow/scripts/lint_manifest.py", + _new1963["components"]["manifest-reader"]["path"]) + assert_eq("#1963 marker: the vendor-fetched reader is digested from the SOURCE tree", + _install_state.digest_file(_s1963 / "scripts" / "lint_manifest.py"), + _new1963["components"]["manifest-reader"]["digest"]) + # Post-install drift on a consumer-digested component is still caught — the fix + # re-anchors the comparand, it does not disarm the gate. + (_c1963 / ".github" / "actions" / "setup-project-env" / "action.yml").write_text( + "DRIFTED-LATER\n", encoding="utf-8") + assert_eq("#1963 marker: post-install drift on a consumer-digested component refuses", + "digest-mismatch:setup-action", + _install_state.check_readiness(_c1963 / ".prflow" / "install-state.json", + _man1963, repo_root=_c1963).reason) + # An unreadable digest_roots component still raises BEFORE any marker is published. + assert_raises("#1963 marker: unreadable digest_roots component raises (no marker)", + ValueError, + lambda: _install_state.build_state( + "abc123", {"reader": "scripts/gone.py"}, repo_root=_c1963, + digest_roots={"reader": _s1963})) +finally: + shutil.rmtree(_d1963, ignore_errors=True) + +# AC1: install.sh ships the manifest and publishes the marker after validating it. +_INSTALL_1388 = (SCRIPTS.parent / "install.sh").read_text(encoding="utf-8") + +# ── PR #1963 reception: reconcile the compatibility tuple's two transcriptions ── +# The population is written twice — generate-install-state.py's COMPONENTS (the primary +# repo's committed marker) and install.sh section 4b's --component operands (every +# consumer's marker). Nothing linked them, so a component added to one side silently +# narrowed the other's marker while the drift gate and the 4b end-to-end tests stayed +# green. Compare name→path both ways round, so either side's omission fails here. +_gis_1963 = importlib.util.spec_from_file_location( + "generate_install_state_1963", SCRIPTS.parent / "lib" / "generate-install-state.py") +_gis_mod_1963 = importlib.util.module_from_spec(_gis_1963) +_gis_1963.loader.exec_module(_gis_mod_1963) +_sh_components_1963 = dict( + m.split("=", 1) for m in re.findall( + r'--component\s+"([^"]+)"', _INSTALL_1388)) +assert_eq("#1963 tuple: install.sh section 4b declares components at all", True, + len(_sh_components_1963) > 0) +assert_eq("#1963 tuple: install.sh's --component operands match COMPONENTS exactly", + _gis_mod_1963.COMPONENTS, _sh_components_1963) +# Every component the installer digests from the SOURCE tree must also record a runtime +# path, and vice versa: a --digest-root with no --record-path binds source bytes to a +# consumer path that will never carry them (the permanently-unready marker above), and a +# --record-path with no --digest-root digests a path absent from the consumer tree. +_dg_1963 = set(m.split("=", 1)[0] for m in re.findall(r'--digest-root\s+"([^"]+)"', _INSTALL_1388)) +_rp_1963 = set(m.split("=", 1)[0] for m in re.findall(r'--record-path\s+"([^"]+)"', _INSTALL_1388)) +assert_eq("#1963 tuple: source-digested components are exactly the runtime-path ones", + _dg_1963, _rp_1963) +assert_eq("#1963 tuple: every source-digested component is in the tuple", set(), + _dg_1963 - set(_sh_components_1963)) +assert_eq("#1388 installer: ships the lint manifest", True, # structural-pin-ok: routing-dispatch-contract -- installer copy of the manifest + 'install_managed ".prflow/lint-manifest.json"' in _INSTALL_1388) +assert_eq("#1388 installer: publishes the install-state marker via install_state.py build", True, # structural-pin-ok: routing-dispatch-contract -- marker publication call + 'scripts/install_state.py" build' in _INSTALL_1388) +assert_eq("#1388 installer: validates the manifest before publishing (fail-closed order)", True, # structural-pin-ok: security-credential-boundary -- publish gated on validation + 'lint-manifest.json did not validate' in _INSTALL_1388) + # ── issue #1811: cleanup-create-issue-run.sh — per-run create-issue scratch reaper ── print() print("cleanup-create-issue-run.sh: per-run create-issue scratch cleanup (issue #1811)") diff --git a/scripts/install_state.py b/scripts/install_state.py new file mode 100755 index 0000000000..1037988c4b --- /dev/null +++ b/scripts/install_state.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Daniel Radman +# SPDX-License-Identifier: MIT +"""Build, validate, and gate on `.prflow/install-state.json` (issue #1388). + +`.prflow/install-state.json` is the digest-bound compatibility-tuple marker the +installer publishes **last**, only after staging and validating the set of +components that must ship together — the lint manifest, its readers, the +`setup-project-env` composite action and its provisioning helper, and the shipped +implement workflow. That set is written twice — `COMPONENTS` in +`lib/generate-install-state.py` governs this repository's committed marker and +`install.sh` section 4b's `--component` operands govern a consumer's; the suite +reconciles the two, so neither is authoritative for the other's path. +The composite action's provisioning phase consults this marker *before model +execution* and refuses to provision when it is absent, a recorded component's +on-disk digest disagrees (a version-skew in either direction, or an +interrupted/partial publication), or the manifest is missing or invalid. + +The marker carries the installer version (the missing sixth field of the +provisioning cache key `{OS, arch, tool, version, digest, installer version}`) +so a re-install under a newer installer invalidates a cache built by the old one. + +This module is the single source of truth for the marker's shape. Like +`scripts/lint_manifest.py` it is a **best-effort reader** over agent- and +human-mutable JSON: every degraded shape resolves to a typed **unestablished** +result carrying a specific reason, never a plausible-but-unobserved clean pass. +*Unknown is not zero.* +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import re +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent + +SUPPORTED_SCHEMA_VERSIONS = frozenset({1}) +_DIGEST_RE = re.compile(r"\Asha256:[0-9a-f]{64}\Z") +# An installer version is a git ref / semver-ish token: no shell metacharacters, +# no whitespace, so it can never be a command string spliced into the cache key. +# `/` is accepted (a branch ref like `feature/x` is a legal consumer pin and is not +# a shell metacharacter); it is never used as a filesystem path component. +_INSTALLER_VERSION_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._/-]*\Z") +# A component name is a closed-vocabulary identifier the trusted installer sets. +_NAME_RE = re.compile(r"\A[a-z0-9][a-z0-9-]*\Z") + + +def _load_lint_manifest(): + path = _HERE / "lint_manifest.py" + spec = importlib.util.spec_from_file_location("lint_manifest", path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load lint_manifest from {path}") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +lint_manifest = _load_lint_manifest() + + +class StateResult: + """Typed outcome of a marker read: `established` XOR `unestablished`.""" + + __slots__ = ("state", "reason", "status") + + def __init__(self, status: str, *, state=None, reason: str | None = None): + if status not in ("established", "unestablished"): + raise ValueError(f"invalid state-result status: {status!r}") + # Make the documented XOR unrepresentable in BOTH directions, not merely + # conventional: established carries state and no reason; unestablished + # carries a reason and no state. + if status == "established": + if state is None: + raise ValueError("established StateResult requires a state") + if reason is not None: + raise ValueError("established StateResult must not carry a reason") + else: + if not reason: + raise ValueError("unestablished StateResult requires a reason") + if state is not None: + raise ValueError("unestablished StateResult must not carry a state") + object.__setattr__(self, "status", status) + object.__setattr__(self, "state", state) + object.__setattr__(self, "reason", reason) + + def __setattr__(self, name, value): + # Frozen after construction: a post-init write would defeat the XOR above. + raise AttributeError(f"StateResult is immutable (attempted to set {name!r})") + + def __delattr__(self, name): + # Deleting a field is the same defeat as writing one: `del r.state` left + # `established` True with the payload gone — the shape the constructor + # works hardest to make unrepresentable. + raise AttributeError(f"{type(self).__name__} is immutable (attempted to delete {name!r})") + + @property + def established(self) -> bool: + return self.status == "established" + + def __repr__(self) -> str: # pragma: no cover - debug aid + if self.established: + return "StateResult(established)" + return f"StateResult(unestablished: {self.reason})" + + +class Readiness: + """Typed provisioning-readiness verdict: `ready` XOR not. + + `reason` names the specific fail-closed condition (`install-state-missing`, + `manifest-missing`, `manifest-unestablished:`, `component-missing:`, + `digest-mismatch:`, or a marker-validation reason) so the workflow step + can fail *before model execution* naming exactly what was wrong. + """ + + __slots__ = ("ready", "reason") + + def __init__(self, ready: bool, reason: str | None = None): + # A not-ready verdict must name the specific fail-closed cause, never None — + # and a ready one must carry NO reason, so a stale cause cannot ride along. + if not isinstance(ready, bool): + raise ValueError(f"Readiness.ready must be a bool, got {type(ready).__name__}") + if not ready and not reason: + raise ValueError("a not-ready Readiness requires a reason") + if ready and reason is not None: + raise ValueError("a ready Readiness must not carry a reason") + object.__setattr__(self, "ready", ready) + object.__setattr__(self, "reason", reason) + + def __setattr__(self, name, value): + # Frozen after construction: a post-init write would defeat the XOR above. + raise AttributeError(f"Readiness is immutable (attempted to set {name!r})") + + def __delattr__(self, name): + raise AttributeError(f"{type(self).__name__} is immutable (attempted to delete {name!r})") + + +def _unestablished(reason: str) -> StateResult: + return StateResult("unestablished", reason=reason) + + +def _json_kind(value) -> str: + if isinstance(value, bool): + return "boolean" + if isinstance(value, list): + return "array" + if isinstance(value, str): + return "string" + if isinstance(value, (int, float)): + return "number" + if value is None: + return "null" + return type(value).__name__ + + +def digest_bytes(raw: bytes) -> str: + """`sha256:<64-hex>` of `raw`, the digest spelling the marker and the manifest + both use (the same sha256 hashing install.sh uses, prefixed `sha256:`).""" + return "sha256:" + hashlib.sha256(raw).hexdigest() + + +def digest_file(path) -> str | None: + """`sha256:` digest of the file at `path`, or `None` when it cannot be read — + each caller maps `None` to its own typed failure (`check_readiness` to + `component-missing`, `build_state` to a `ValueError`), never to a clean digest.""" + try: + return digest_bytes(Path(path).read_bytes()) + except (FileNotFoundError, IsADirectoryError, PermissionError, OSError): + return None + + +class _DuplicateKey(ValueError): + pass + + +def _reject_duplicate_keys(pairs): + seen = {} + for key, value in pairs: + if key in seen: + raise _DuplicateKey(key) + seen[key] = value + return seen + + +def load_state(path) -> StateResult: + """Read and validate a marker from `path`, fail-closed on every I/O and decode + failure (missing, unreadable, non-UTF-8, empty each a distinct reason).""" + p = Path(path) + try: + raw = p.read_bytes() + except FileNotFoundError: + return _unestablished("install-state-missing") + except (IsADirectoryError, PermissionError, OSError) as exc: + return _unestablished(f"unreadable: {exc.__class__.__name__}") + return parse_state(raw) + + +def parse_state(raw: bytes) -> StateResult: + if not isinstance(raw, (bytes, bytearray)): + return _unestablished("wrong-type: marker bytes must be a byte string") + if len(raw) == 0: + return _unestablished("empty: marker file is empty") + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + return _unestablished("invalid-utf8: marker is not valid UTF-8") + try: + data = json.loads(text, object_pairs_hook=_reject_duplicate_keys) + except _DuplicateKey as exc: + return _unestablished(f"duplicate-key: repeated object key {exc.args[0]!r}") + except json.JSONDecodeError as exc: + return _unestablished(f"malformed-json: {exc.msg}") + except RecursionError: + return _unestablished("malformed-json: input nesting too deep") + return validate_state(data) + + +def validate_state(data) -> StateResult: + """Validate an already-parsed marker object.""" + if isinstance(data, bool) or not isinstance(data, dict): + return _unestablished(f"wrong-type: top level is a {_json_kind(data)}, expected object") + + required = ("schema_version", "installer_version", "components") + for key in data: + if key not in required: + return _unestablished(f"unknown-field: unknown top-level key {key!r}") + for key in required: + if key not in data: + return _unestablished(f"missing: required top-level key {key!r}") + + version = data["schema_version"] + if isinstance(version, bool) or not isinstance(version, int): + return _unestablished("wrong-type: schema_version must be an integer") + if version not in SUPPORTED_SCHEMA_VERSIONS: + return _unestablished(f"unknown-version: schema_version {version} unsupported") + + iv = data["installer_version"] + if not isinstance(iv, str) or not _INSTALLER_VERSION_RE.match(iv): + return _unestablished(f"invalid-value: installer_version {iv!r}") + + components = data["components"] + if not isinstance(components, dict) or not components: + return _unestablished("invalid-value: components must be a non-empty object") + for name, comp in components.items(): + if not _NAME_RE.match(name): + return _unestablished(f"invalid-value: component name {name!r}") + if not isinstance(comp, dict): + return _unestablished(f"wrong-type: component {name!r} is a {_json_kind(comp)}") + for key in comp: + if key not in ("path", "digest"): + return _unestablished(f"unknown-field: component {name!r} key {key!r}") + for key in ("path", "digest"): + if key not in comp: + return _unestablished(f"missing: component {name!r} key {key!r}") + path = comp["path"] + if not isinstance(path, str) or not path or path.startswith("/") \ + or any(seg == ".." for seg in path.split("/")): + return _unestablished(f"invalid-value: component {name!r} path {path!r}") + digest = comp["digest"] + if not isinstance(digest, str) or not _DIGEST_RE.match(digest): + return _unestablished(f"invalid-value: component {name!r} digest {digest!r}") + + return StateResult("established", state=data) + + +def build_state(installer_version: str, components: dict, repo_root=".", + record_paths=None, digest_roots=None) -> dict: + """Build a marker dict from `components` (name → the path DIGESTED, resolved + under `repo_root`). A component's recorded path defaults to the path digested; + `record_paths` (name → recorded path) overrides it, and `digest_roots` + (name → root) overrides the root that component is digested under. + + The marker describes the CONSUMER tree, so `repo_root` is that tree: a component + the installer preserved (install_managed's modified/unverified/unreadable arms) or + skipped keeps its old bytes, and binding it to source bytes it never received + publishes a marker no consumer action can converge. `digest_roots` carries the one + real exception — the readers that ship via the runtime vendor fetch rather than the + copy loop, and so are absent from the consumer tree at install time; those are + digested from the source at the pinned ref and recorded at their runtime path. + + Raises `ValueError` for an unreadable component, or an `installer_version` that + `validate_state` would later reject, so the installer fails BEFORE publishing a + marker that binds a file it cannot read or that provisioning will refuse.""" + if not isinstance(installer_version, str) or not _INSTALLER_VERSION_RE.match(installer_version): + raise ValueError(f"invalid installer_version {installer_version!r}") + root = Path(repo_root) + record_paths = record_paths or {} + digest_roots = digest_roots or {} + out = {} + for name, rel in components.items(): + dig = digest_file(Path(digest_roots.get(name, root)) / rel) + if dig is None: + raise ValueError(f"cannot digest component {name!r} at {rel!r}") + out[name] = {"path": record_paths.get(name, rel), "digest": dig} + state = { + "schema_version": 1, + "installer_version": installer_version, + "components": out, + } + # Close the round trip: refuse here what validate_state would refuse at read time + # (an absolute or traversing recorded path, an empty component set). Publishing it + # instead moves the failure onto the consumer's provisioning run, where the marker + # is already committed and the diagnostic names a file they did not write. + vr = validate_state(state) + if not vr.established: + raise ValueError(f"refusing to publish a marker validate_state rejects: {vr.reason}") + return state + + +def check_readiness(state_path, manifest_path, repo_root=".") -> Readiness: + """Gate provisioning on the marker. Fail-closed: any absent/invalid/mismatched + input returns `ready=False` with a specific reason, never a clean pass. + + Order matters — the marker is checked first (a `backfill`/`missing-marker` + install has components on disk but no marker and must refuse), then the + manifest, then every recorded component's on-disk digest (a `version-skew` in + either direction or an interrupted publication flips a digest).""" + root = Path(repo_root) + sr = load_state(state_path) + if not sr.established: + return Readiness(False, sr.reason) + + mr = lint_manifest.load_manifest(manifest_path) + if not mr.established: + # An ABSENT manifest file is the AC's dedicated `manifest-missing` — matched by + # equality with the reader's file-absent sentinel, never the `missing:` prefix, + # which also matches structural missing-key reasons for a PRESENT manifest. + if mr.reason == lint_manifest.MISSING_FILE_REASON: + return Readiness(False, "manifest-missing") + return Readiness(False, f"manifest-unestablished:{mr.reason}") + + for name, comp in sr.state["components"].items(): + on_disk = digest_file(root / comp["path"]) + if on_disk is None: + return Readiness(False, f"component-missing:{name}") + if on_disk != comp["digest"]: + return Readiness(False, f"digest-mismatch:{name}") + + return Readiness(True) + + +def _force_utf8_streams(): + for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8") + except (AttributeError, ValueError, OSError): + pass + + +def main(argv=None) -> int: + """CLI. Subcommands: + + `build --out P --installer-version V --component name=relpath ...` + Stage-and-write the marker (digests computed from disk). Exit 0, or 1 + on a usage / unreadable-component error. + `verify --state P --manifest P [--repo-root R]` + Print `READY` (exit 0) or `NOT-READY ` (exit 2); usage error + exit 1 — the branch-on-exit-status contract the other helpers use. + """ + _force_utf8_streams() + import argparse + + parser = argparse.ArgumentParser(description="Build/verify the install-state compatibility marker.") + sub = parser.add_subparsers(dest="cmd", required=True) + + pb = sub.add_parser("build") + pb.add_argument("--out", required=True) + pb.add_argument("--installer-version", required=True) + pb.add_argument("--component", action="append", default=[], metavar="NAME=PATH") + pb.add_argument("--record-path", action="append", default=[], metavar="NAME=PATH") + pb.add_argument("--digest-root", action="append", default=[], metavar="NAME=ROOT") + pb.add_argument("--repo-root", default=".") + + pv = sub.add_parser("verify") + pv.add_argument("--state", required=True) + pv.add_argument("--manifest", required=True) + pv.add_argument("--repo-root", default=".") + + try: + args = parser.parse_args(argv) + except SystemExit as exc: + raise SystemExit(1 if exc.code else 0) from None + + if args.cmd == "build": + components = {} + for spec in args.component: + if "=" not in spec: + print(f"usage: --component expects NAME=PATH, got {spec!r}", file=sys.stderr) + return 1 + name, rel = spec.split("=", 1) + components[name] = rel + if not components: + print("usage: build needs at least one --component", file=sys.stderr) + return 1 + record_paths = {} + for spec in args.record_path: + if "=" not in spec: + print(f"usage: --record-path expects NAME=PATH, got {spec!r}", file=sys.stderr) + return 1 + name, rel = spec.split("=", 1) + record_paths[name] = rel + digest_roots = {} + for spec in args.digest_root: + if "=" not in spec: + print(f"usage: --digest-root expects NAME=ROOT, got {spec!r}", file=sys.stderr) + return 1 + name, rel = spec.split("=", 1) + digest_roots[name] = rel + try: + state = build_state(args.installer_version, components, args.repo_root, + record_paths, digest_roots) + except ValueError as exc: + print(f"build failed: {exc}", file=sys.stderr) + return 1 + Path(args.out).write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8") + return 0 + + verdict = check_readiness(args.state, args.manifest, args.repo_root) + if verdict.ready: + print("READY") + return 0 + print(f"NOT-READY {verdict.reason}") + return 2 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/lint_manifest.py b/scripts/lint_manifest.py index 8ccf156a82..bc1e404dd5 100755 --- a/scripts/lint_manifest.py +++ b/scripts/lint_manifest.py @@ -35,6 +35,10 @@ # ── Closed vocabularies. A value outside any of these is `unknown-enum`. ────── SUPPORTED_SCHEMA_VERSIONS = frozenset({1}) +# The FILE-ABSENT sentinel, exactly as load_manifest emits it. Consumers that must +# tell an absent file from a present-but-invalid manifest compare EQUALITY with this +# constant — a `missing:` prefix match also catches structural missing-key reasons. +MISSING_FILE_REASON = "missing: manifest file does not exist" KNOWN_TOOLS = ("shellcheck", "ruff") KNOWN_OS = frozenset({"linux", "macos", "windows"}) KNOWN_ARCH = frozenset({"x86_64", "arm64"}) @@ -79,9 +83,29 @@ class ManifestResult: def __init__(self, status: str, *, manifest=None, reason: str | None = None): if status not in ("established", "unestablished"): raise ValueError(f"invalid manifest-result status: {status!r}") - self.status = status - self.manifest = manifest - self.reason = reason + # Enforce the XOR in BOTH directions at construction (like Plan/StateResult/ + # Readiness): an established result smuggling a reason, or an unestablished + # one carrying a manifest or losing its reason, must be unrepresentable. + if status == "established": + if manifest is None: + raise ValueError("established ManifestResult requires a manifest") + if reason is not None: + raise ValueError("established ManifestResult must not carry a reason") + else: + if not reason: + raise ValueError("unestablished ManifestResult requires a reason") + if manifest is not None: + raise ValueError("unestablished ManifestResult must not carry a manifest") + object.__setattr__(self, "status", status) + object.__setattr__(self, "manifest", manifest) + object.__setattr__(self, "reason", reason) + + def __setattr__(self, name, value): + # Frozen after construction: a post-init write would defeat the XOR above. + raise AttributeError(f"ManifestResult is immutable (attempted to set {name!r})") + + def __delattr__(self, name): + raise AttributeError(f"{type(self).__name__} is immutable (attempted to delete {name!r})") @property def established(self) -> bool: @@ -145,7 +169,7 @@ def load_manifest(path) -> ManifestResult: try: raw = p.read_bytes() except FileNotFoundError: - return _unestablished("missing: manifest file does not exist") + return _unestablished(MISSING_FILE_REASON) except (IsADirectoryError, PermissionError, OSError) as exc: return _unestablished(f"unreadable: {exc.__class__.__name__}") return parse_manifest(raw) diff --git a/scripts/lint_provision.py b/scripts/lint_provision.py new file mode 100755 index 0000000000..2bb4b04629 --- /dev/null +++ b/scripts/lint_provision.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Daniel Radman +# SPDX-License-Identifier: MIT +"""Platform-resolution query over the declarative lint manifest (issue #1388). + +The provisioning layer (the `setup-project-env` composite action's +`provision-lint-tools.sh`) needs, for the runner it is on, the one artifact +record the manifest declares for `(tool, os, arch)` — its pinned digest, the +archive type, the member to extract, and the strategy — plus the trusted +download URL and the run-local cache key. This module answers exactly that. + +It assembles NOTHING executable from the manifest: the manifest never carries a +command string or URL template (`scripts/lint_manifest.py` rejects those field +shapes), so the URL templates below are fixed, trusted code keyed on the closed +`(tool, os, arch)` vocabulary +and the manifest's typed `version` field — never a manifest-supplied string +(issue #1276's trust model). The manifest is read and validated through +`scripts/lint_manifest.py`; this module reimplements none of that. + +`unsupported-lint-platform` is the non-error "no answer" outcome: a fully +valid manifest that simply declares no artifact for the requested `(os, arch)` +under the requested tool. `unknown-lint-tool` is its fail-closed sibling — a +tool this module has no templates for at all, which a caller must refuse rather +than skip. Both are distinct from an *unestablished* manifest (a missing, +malformed, or invalid file), which carries a typed reason. *Unknown is not zero.* +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent + + +def _load_lint_manifest(): + """Import the sibling `lint_manifest.py` by path (its filename is not a + module name). Fail closed if it cannot be loaded — the provisioner must not + proceed against an unreadable validator.""" + path = _HERE / "lint_manifest.py" + spec = importlib.util.spec_from_file_location("lint_manifest", path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load lint_manifest from {path}") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +lint_manifest = _load_lint_manifest() + +# ── Closed platform vocabulary (mirrors the manifest's own). A tuple outside +# this set is `unsupported-lint-platform`, never an error. ────────────────── +KNOWN_TOOLS = ("shellcheck", "ruff") +KNOWN_OS = ("linux", "macos", "windows") +KNOWN_ARCH = ("x86_64", "arm64") + +# ── Trusted download-URL templates. Keyed on the closed (tool, os, arch) +# vocabulary and the manifest's typed `version`; NEVER a manifest-supplied +# string. `{version}` is the manifest's `version` field (typed by +# `lint_manifest._VERSION_RE` — do not transcribe the pattern here, a copy drifts), +# so no shell metacharacter can reach the URL. These map the closed strategy +# IDs to fixed upstream release layouts (issue #1276). ─────────────────────── +_SHELLCHECK_OS = {"linux": "linux", "macos": "darwin", "windows": "windows"} +_SHELLCHECK_ARCH = {"x86_64": "x86_64", "arm64": "aarch64"} +_RUFF_TARGET = { + ("linux", "x86_64"): "x86_64-unknown-linux-gnu", + ("linux", "arm64"): "aarch64-unknown-linux-gnu", + ("macos", "x86_64"): "x86_64-apple-darwin", + ("macos", "arm64"): "aarch64-apple-darwin", + ("windows", "x86_64"): "x86_64-pc-windows-msvc", +} + + +def artifact_url(tool: str, version: str, os_name: str, arch: str, archive_type: str) -> str | None: + """Build the trusted upstream download URL for one artifact, or `None` when + no template covers `(tool, os, arch)` — the caller maps that to + `unsupported-lint-platform`.""" + if tool == "shellcheck": + so = _SHELLCHECK_OS.get(os_name) + sa = _SHELLCHECK_ARCH.get(arch) + if so is None or sa is None: + return None + if os_name == "windows": + # Upstream ships a single per-release zip for Windows. + return (f"https://github.com/koalaman/shellcheck/releases/download/" + f"v{version}/shellcheck-v{version}.zip") + return (f"https://github.com/koalaman/shellcheck/releases/download/" + f"v{version}/shellcheck-v{version}.{so}.{sa}.{archive_type}") + if tool == "ruff": + target = _RUFF_TARGET.get((os_name, arch)) + if target is None: + return None + return (f"https://github.com/astral-sh/ruff/releases/download/" + f"{version}/ruff-{target}.{archive_type}") + return None + + +def resolve_artifact(manifest: dict, tool: str, os_name: str, arch: str) -> dict | None: + """Return the validated artifact record for `(tool, os, arch)`, or `None` + when the manifest declares none (`unsupported-lint-platform`). `manifest` + must be an already-validated manifest dict.""" + tool_obj = manifest.get("tools", {}).get(tool) + if not isinstance(tool_obj, dict): + return None + for art in tool_obj.get("artifacts", []): + if art.get("os") == os_name and art.get("arch") == arch: + return art + return None + + +def cache_key(os_name: str, arch: str, tool: str, version: str, digest: str, + installer_version: str) -> str: + """The run-local cache key `{OS, arch, tool, version, digest, installer version}`. + A change to any component invalidates the cache, so a stale + binary can never satisfy a changed tuple. The digest is normalized to its + 64-hex body (the `sha256:` prefix dropped) so the key stays field-delimited.""" + dig = digest[len("sha256:"):] if digest.startswith("sha256:") else digest + return f"lintprov-{os_name}-{arch}-{tool}-{version}-{dig}-{installer_version}" + + +class Plan: + """The resolved provisioning plan for one `(tool, os, arch)` — everything the + shell provisioner needs, or a typed no-answer.""" + + __slots__ = ("status", "reason", "tool", "os", "arch", "version", + "digest", "archive_type", "member", "strategy", "url") + + _RESOLVED_FIELDS = ("version", "digest", "archive_type", "member", "strategy", "url") + + _ACCEPTED_KW = ("reason", "tool", "os", "arch", "version", "digest", + "archive_type", "member", "strategy", "url") + + def __init__(self, status, **kw): + if status not in ("established", "unsupported", "unestablished"): + raise ValueError(f"invalid plan status: {status!r}") + # Reject an unknown keyword instead of absorbing it: a misspelled field left + # its real attribute None, and cache_key then composed the literal "None" — + # the stale-binary collision cache_key exists to prevent. + unknown = sorted(k for k in kw if k not in self._ACCEPTED_KW) + if unknown: + raise ValueError(f"unknown Plan field(s): {unknown}") + # Enforce the established<->fields / no-answer<->reason invariant at + # construction in BOTH directions (like StateResult/Readiness), not merely by + # build_plan convention: a partially-populated "established" plan and a + # no-answer plan smuggling resolved fields or losing its reason are both + # unrepresentable. + if status == "established": + missing = [k for k in self._RESOLVED_FIELDS if kw.get(k) is None] + if missing: + raise ValueError(f"established Plan missing resolved fields: {missing}") + if kw.get("reason") is not None: + raise ValueError("established Plan must not carry a reason") + else: + if not kw.get("reason"): + raise ValueError(f"a {status!r} Plan requires a reason") + populated = [k for k in self._RESOLVED_FIELDS if kw.get(k) is not None] + if populated: + raise ValueError( + f"a {status!r} Plan must not carry resolved fields: {populated}") + object.__setattr__(self, "status", status) + object.__setattr__(self, "reason", kw.get("reason")) + for k in ("tool", "os", "arch", "version", "digest", "archive_type", + "member", "strategy", "url"): + object.__setattr__(self, k, kw.get(k)) + + def __setattr__(self, name, value): + # Frozen after construction: a post-init write would defeat the XOR above. + raise AttributeError(f"Plan is immutable (attempted to set {name!r})") + + def __delattr__(self, name): + raise AttributeError(f"{type(self).__name__} is immutable (attempted to delete {name!r})") + + +def build_plan(manifest_path, tool: str, os_name: str, arch: str) -> Plan: + """Resolve the provisioning plan for one tuple. Outcomes: + + * `established` — a validated manifest declares the artifact and a trusted + URL template covers the tuple. + * `unsupported` — a *valid* manifest declares no artifact for the tuple, or + no URL template covers it (`reason='unsupported-lint-platform'`) — or the + TOOL itself is outside `KNOWN_TOOLS` (`reason='unknown-lint-tool'`). The two + reasons stay distinct so the shell caller can degrade only the platform + case and fail closed on a tool it cannot handle. + * `unestablished` — the manifest could not be read/validated (typed reason). + """ + if tool not in KNOWN_TOOLS: + return Plan("unsupported", reason="unknown-lint-tool", + tool=tool, os=os_name, arch=arch) + result = lint_manifest.load_manifest(manifest_path) + if not result.established: + return Plan("unestablished", reason=result.reason, + tool=tool, os=os_name, arch=arch) + manifest = result.manifest + art = resolve_artifact(manifest, tool, os_name, arch) + if art is None: + return Plan("unsupported", reason="unsupported-lint-platform", + tool=tool, os=os_name, arch=arch) + version = manifest["tools"][tool]["version"] + url = artifact_url(tool, version, os_name, arch, art["archive_type"]) + if url is None: + return Plan("unsupported", reason="unsupported-lint-platform", + tool=tool, os=os_name, arch=arch) + return Plan("established", tool=tool, os=os_name, arch=arch, version=version, + digest=art["digest"], archive_type=art["archive_type"], + member=art["member"], strategy=art["strategy"], url=url) + + +def _force_utf8_streams(): + for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8") + except (AttributeError, ValueError, OSError): + pass + + +def main(argv=None) -> int: + """CLI. Subcommands `plan` and `cache-key`, each printing one machine-readable + line with a branch-on-exit-status contract: + + exit 0 — established (the resolved fields / the cache key) + exit 2 — unestablished manifest (`UNESTABLISHED `) + exit 3 — `unsupported-lint-platform` + exit 4 — `unknown-lint-tool` (a tool outside KNOWN_TOOLS: the caller cannot + provision it and must fail closed, never skip it as a platform gap) + exit 1 — usage error + """ + _force_utf8_streams() + import argparse + + parser = argparse.ArgumentParser(description="Resolve lint provisioning plans from the manifest.") + sub = parser.add_subparsers(dest="cmd", required=True) + for name in ("plan", "cache-key"): + p = sub.add_parser(name) + p.add_argument("--manifest", required=True) + p.add_argument("--tool", required=True) + p.add_argument("--os", required=True, dest="os_name") + p.add_argument("--arch", required=True) + if name == "cache-key": + p.add_argument("--installer-version", required=True) + try: + args = parser.parse_args(argv) + except SystemExit as exc: + raise SystemExit(1 if exc.code else 0) from None + + plan = build_plan(args.manifest, args.tool, args.os_name, args.arch) + if plan.status == "unestablished": + print(f"UNESTABLISHED {plan.reason}") + return 2 + if plan.status == "unsupported": + print(plan.reason) + return 4 if plan.reason == "unknown-lint-tool" else 3 + if args.cmd == "cache-key": + print(cache_key(plan.os, plan.arch, plan.tool, plan.version, plan.digest, + args.installer_version)) + return 0 + # plan: tab-separated so a shell `read` can split it field-by-field. + print("\t".join([plan.digest, plan.archive_type, plan.member, plan.strategy, + plan.version, plan.url])) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/workflow-flight-recorder-registry.json b/scripts/workflow-flight-recorder-registry.json index aa316675b4..4c4aab31d1 100644 --- a/scripts/workflow-flight-recorder-registry.json +++ b/scripts/workflow-flight-recorder-registry.json @@ -51,7 +51,7 @@ }, "installer-wiring": { "path": "lib/test/modules/installer-wiring.sh", - "minimum_assertions": 297, + "minimum_assertions": 304, "assertion_floor_policy": "exact", "description": "Focused installer and workflow-wiring coverage: the #487/#491 credential-refresher and fresh-gh wrapper workflow wiring, the #533 seven-output install-gh-wrapper.sh validation with its planted-defect controls, the #544 fingerprint-comparison symmetry, the #599 workflow-token and secret-file permission pins, and the #690 Windows mode-probe arms, and the consumer UPGRADE path driven end-to-end over fixture consumer repositories (provenance manifest, non-clobbering preservation, the dry-run preview, the withheld review tier, name-agnostic identifier migration, the stale-config detect-and-route report with its adversarial input-shape matrix, the #970 preserved-artifact sidecar ignore rule driven over a real git index, and the #971 preview language-detection scan root)" },