diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 0d78db868..da609a9c0 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -147,7 +147,16 @@ jobs: # proof scripts and Verilog sources, so globbing `*.v` would install # a V toolchain to lint a Coq development. N_V=$(count 'v.mod' '*/v.mod') - N_HASKELL=$(count '*.cabal' 'stack.yaml' '*/stack.yaml' '*.hs') + # Haskell is counted ONLY where its consuming job can run: the `haskell` + # job runs `cabal build all` at the REPOSITORY ROOT, which needs a root + # `cabal.project` or a root `*.cabal`. `:(glob)` stops `*` crossing `/`. + # The old marker set (`*.cabal`, `*.hs`, `stack.yaml` at any depth) + # went true on a nested satellite (standards: sanctify-php, + # palimpsest-license/TOOLS/validation/haskell) and then failed with + # Cabal-7136 "There is no .cabal package file or cabal.project" — + # a guard counting a different set than its consumer checks + # (standards#1161). Nested Haskell is reported below as unsupported. + N_HASKELL=$(count 'cabal.project' ':(glob)*.cabal') # ⚠ DO NOT REMOVE THE DENO PROBE. It does not exist to lint Deno — # it is the trigger for the REFUSAL job `deno` below, i.e. the teeth # of the estate's standing no-Deno ruling, with slugs still live in @@ -220,6 +229,14 @@ jobs: # which repositories get linted. probe 'Bun' 'bun.lock' '*/bun.lock' 'bun.lockb' '*/bun.lockb' 'bunfig.toml' '*/bunfig.toml' 'package.json' '*/package.json' + # Haskell the `haskell` job cannot build (no root cabal.project or + # `*.cabal`): nested projects, stack-only projects, loose `*.hs`. + # Probed only when N_HASKELL is 0 — under a root project, nested + # sources belong to it and ARE built. + if [ "$N_HASKELL" -eq 0 ]; then + probe 'Haskell (no root cabal.project/*.cabal)' '*.cabal' 'cabal.project' '*/cabal.project' 'stack.yaml' '*/stack.yaml' '*.hs' + fi + bool() { [ "$1" -gt 0 ] && echo true || echo false; } { @@ -246,7 +263,7 @@ jobs: echo "| Nickel | \`*.ncl\` excluding \`*.k9.ncl\` | ${N_NICKEL} |" echo "| ReScript | \`rescript.json\`, \`bsconfig.json\`, \`*.res\` | ${N_RESCRIPT} |" echo "| V | \`v.mod\` only | ${N_V} |" - echo "| Haskell | \`*.cabal\`, \`stack.yaml\`, \`*.hs\` | ${N_HASKELL} |" + echo "| Haskell | root \`cabal.project\` or root \`*.cabal\` | ${N_HASKELL} |" echo "| Deno | \`deno.json(c)\` | ${N_DENO} |" echo "| **Total checkable** | | **${TOTAL}** |" echo "" @@ -983,7 +1000,7 @@ jobs: row "Deno" "$R_DENO" "BANNED — refusal gate; skipped means no deno.json(c) tracked (has_deno=${H_DENO})" row "ReScript" "$R_RESCRIPT" "no rescript.json/bsconfig.json/*.res tracked (has_rescript=${H_RESCRIPT})" row "V" "$R_V" "no v.mod tracked (has_v=${H_V})" - row "Haskell" "$R_HASKELL" "no *.cabal/stack.yaml/*.hs tracked (has_haskell=${H_HASKELL})" + row "Haskell" "$R_HASKELL" "no root cabal.project/*.cabal tracked (has_haskell=${H_HASKELL})" echo "" echo "_A skipped gate examined nothing. It is not a pass._" echo "" diff --git a/.hypatia-baseline.json b/.hypatia-baseline.json index a7cd98ea3..df6166ee4 100644 --- a/.hypatia-baseline.json +++ b/.hypatia-baseline.json @@ -1663,5 +1663,21 @@ "note": "TRIAGED (round-2 fix-forward 2026-09-22; collapsed 2026-09-22 per #966): Hypatia emits invalid_actions_lock from TWO rule modules for ONE defect -- workflow_audit/parse_actions_lock (file `actions.lock`) and WH004 standalone (workflow_hardening, full path `.github/workflows/actions.lock`). Both verified by running the real scanner locally. The defect: asana/push-signed-commits@d615 (immutable pin, ref v1.3) declares transitive actions/setup-python@v2, which floats upstream and can never be a lock key. gh-actions-lock v0.1.6 verify says valid:true on the same file; Hypatia is stricter than the authoritative tool. Exposure is upstream float only. Exit: Asana pins setup-python, we replace the action, or Hypatia downgrades float-transitives. #947 deleted the metadata to silence the scanner; round 2 restored it verbatim and acked honestly. Tracking moved from #951 to #963 on 2026-09-22: #951 was closed COMPLETED with zero comments while the defect is demonstrably live (actions.lock:210 still declares the floating transitive under the asana pin, and setup-python is never a top-level lock key), because #963 superseded it. An acknowledgement expiring 2026-12-22 against a CLOSED issue has nobody owning its exit criteria -- the ack keeps working and the cure stops being anyone job. This was two entries until list-valued rule_module landed (#971) -- two entries meant two expiry dates for one decision, which is how a half-expired acknowledgement silently reopens a gate. DELIBERATE SCOPE CHANGE, recorded because it is a widening and not a refactor: the workflow_hardening half previously matched the exact path `.github/workflows/actions.lock`, and one entry carries one matcher, so it now matches `**actions.lock` -- the breadth the workflow_audit half already had at the same severity and type. Measured consequence: a workflow_hardening invalid_actions_lock at e.g. `vendor/actions.lock` was KEPT before and is SUPPRESSED now. Accepted because both halves are the same defect with the same exit criteria; if a second actions.lock is ever vendored, narrow this entry rather than trusting it.", "expires_at": "2026-12-22", "tracking_issue": "hyperpolymath/standards#963" + }, + { + "severity": "warn", + "rule_module": "research_extensions", + "type": "RE001", + "file": ".github/workflows/k9-contractile.yml", + "note": "ESTATE DECISION PENDING (hypatia triage 2026-09-22): adopt step-security/harden-runner or record a principled WONTFIX; the estate uses it nowhere today. Same decision as the other RE001 entries; this workflow's 'Publish the K9 verdict' job (GITHUB_TOKEN only) joined the set on 2026-10-06.", + "expires_at": "2026-12-22", + "tracking_issue": "hyperpolymath/standards#941" + }, + { + "severity": "high", + "rule_module": "code_safety", + "type": "ncl_k9_missing_pedigree", + "file": "1-formats/k9/tools/fixtures/invalid/L1-K9-S001-no-pedigree.k9.ncl", + "note": "FALSE POSITIVE BY DESIGN: this is the NEGATIVE CONTROL for K9-S001 (no pedigree). k9-validate.sh --fixtures requires it to be rejected at L1 for exactly this reason; adding a pedigree would destroy the control. Exact path, not a pattern, so a real component that loses its pedigree is still reported." } ] diff --git a/.machine_readable/REGISTRY.a2ml b/.machine_readable/REGISTRY.a2ml index 6943c2c47..d5c96f198 100644 --- a/.machine_readable/REGISTRY.a2ml +++ b/.machine_readable/REGISTRY.a2ml @@ -45,7 +45,7 @@ name = "K9 Self-Validating Components" stream = "foundation" home = "1-formats/k9/" canonical_doc = "1-formats/k9/README.adoc" -source_hash = "sha256:d10e71f64586a5c6faac5833d6a52225d22042170e63df0ad2076ee610be8831" +source_hash = "sha256:dbf336e383f49c098a9cc7a3126fe817b824f41c054a7d2977ff47150db59281" route = "the K9 specification, security analysis and adoption guidance (implementations live in hyperpolymath/k9-ecosystem)" [[spec]] @@ -153,7 +153,7 @@ name = "AXEL Protocol" stream = "protocol" home = "2-protocols/axel/" canonical_doc = "2-protocols/axel/README.adoc" -source_hash = "sha256:c67b62c6dcbb730664318eb235b25c687493bf74cabbc20af5a71c0d7849acea" +source_hash = "sha256:dbfe6d40af030e4dd23575d7f01dc86d1557b1d395534af73aa1ab0ac133c59c" route = "age-gating + explicit-content enforcement" [[spec]] @@ -207,7 +207,7 @@ name = "RSR — Rhodium Standard Repositories" stream = "governance" home = "rhodium-standard-repositories/" canonical_doc = "rhodium-standard-repositories/README.adoc" -source_hash = "sha256:8bcf1e52445efa2c7f9c83850ca98f7a92190230b76f897d8442c5a377e74c39" +source_hash = "sha256:daeb002c345125c6a5132a71ae07a120691840ca0048b49674a7aab02e40acad" route = "the repository-compliance standard every repo is graded against" [[spec]] @@ -216,7 +216,7 @@ name = "Session Management Standards" stream = "governance" home = "3-practice/session-management-standards/" canonical_doc = "3-practice/session-management-standards/README.adoc" -source_hash = "sha256:beea95b19ff9565abf30d34b758bfb669c5140fc5eef5046a494eafe20a7a91f" +source_hash = "sha256:9e3e5f7bc1469e0736359e3e85cba5311b8ed87056d137d86a98e81c4e3c4b5a" route = "continuity / verify / handover protocols" [[spec]] diff --git a/rhodium-standard-repositories/actions-allowlist/allowed-actions.json b/rhodium-standard-repositories/actions-allowlist/allowed-actions.json index 777a2ee7e..e2f08d069 100644 --- a/rhodium-standard-repositories/actions-allowlist/allowed-actions.json +++ b/rhodium-standard-repositories/actions-allowlist/allowed-actions.json @@ -88,6 +88,7 @@ "SonarSource/sonarqube-scan-action@*", "step-security/harden-runner@*", "Swatinem/rust-cache@*", + "swatinem/rust-cache@*", "taiki-e/install-action@*", "trufflesecurity/trufflehog@*", "webfactory/ssh-agent@*" diff --git a/scripts/apply-baseline.sh b/scripts/apply-baseline.sh index b0f7f9f62..85ad9c83c 100755 --- a/scripts/apply-baseline.sh +++ b/scripts/apply-baseline.sh @@ -293,9 +293,11 @@ rank() { THRESHOLD_RANK="$(rank "$BLOCKING_THRESHOLD")" MAX_KEPT_RANK=0 +BLOCKING_COUNT=0 # kept findings at or above the threshold (the gate message names these, not all kept) while IFS= read -r sev; do r="$(rank "$sev")" if (( r > MAX_KEPT_RANK )); then MAX_KEPT_RANK=$r; fi + if (( r >= THRESHOLD_RANK )); then BLOCKING_COUNT=$((BLOCKING_COUNT + 1)); fi done < <(jq -r '.[].severity' <<<"$KEPT") KEPT_COUNT="$(jq 'length' <<<"$KEPT")" @@ -334,7 +336,7 @@ if [[ "$MODE" == "advisory" ]]; then fi if (( MAX_KEPT_RANK >= THRESHOLD_RANK )); then - echo "::error::Gate failed: $KEPT_COUNT unfiltered finding(s) at or above '$BLOCKING_THRESHOLD'." >&2 + echo "::error::Gate failed: $BLOCKING_COUNT unfiltered finding(s) at or above '$BLOCKING_THRESHOLD' ($KEPT_COUNT kept in total)." >&2 exit 1 fi diff --git a/scripts/kyaml-format.sh b/scripts/kyaml-format.sh index 328aa18bc..145b157d7 100755 --- a/scripts/kyaml-format.sh +++ b/scripts/kyaml-format.sh @@ -1,105 +1,112 @@ #!/usr/bin/env bash -# SPDX-License-Identifier: CC-BY-SA-4.0 -# KYAML formatter/linter for arbitrary YAML files -# Part of standards#1022: formatter/linter for arbitrary YAML +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) # -# Usage: -# kyaml-format.sh [--check] FILE... +# kyaml-format.sh — format arbitrary YAML as KYAML, or check that it already is +# (YAML-POLICY Y-3; KYAML adoption step 3, standards#1022). # -# Flags: -# --check Check only, exit 2 if file would be changed +# The rewriter is `yq -p yaml -o kyaml '.'`, verbatim — the one rewriter the +# step-2 comment-preservation proof (tools/yaml-comment-proof, standards#1021) +# covers. That proof does not generalise to any other rewriter, so this script +# deliberately adds none: no post-processing, no re-indenting, no sed. # -# Exit codes: -# 0 All files OK (no changes needed in --check mode, or all formatted successfully) -# 1 Error (parse failure, missing file, etc.) -# 2 File would be changed (--check mode only) -# -# Preconditions (per YAML-POLICY.adoc §3.3): -# - GitHub Actions must parse KYAML workflows (proven in standards#1020) -# - Comment preservation must be proven (standards#1021) +# Usage: +# kyaml-format.sh [--check] FILE... # -# This script runs `yq -o kyaml` verbatim and: -# - Refuses (exit 1) a file that does not parse -# - Refuses (exit 1) a file that has no final newline -# - Refuses (exit 2 in --check mode) a file that would change its data -# - Is idempotent: yq -o kyaml round-trips cleanly +# (default) rewrite each FILE in place as KYAML when it is not already. +# --check rewrite nothing; exit 1 if any FILE is not already KYAML. # -# KYAML definition (KEP-5295): -# - {} for every map and [] for every list (flow style) -# - every string value double-quoted; keys unquoted where unambiguous -# - trailing commas permitted -# - two-space indentation -# - --- document header +# Exit contract: +# 0 = every FILE is KYAML (after the rewrite, in format mode) +# 1 = --check: at least one FILE is not KYAML (each one is named) +# 2 = refused: no FILE given, a FILE is missing or unparseable, a FILE has no +# final newline, or the rewrite would change the parsed data. A refusal +# never rewrites that FILE and never counts as clean. # -# Note: KYAML is a subset of YAML, not a new format. Every existing YAML -# parser already handles KYAML syntax. - -set -euo pipefail - -CHECK_MODE=false -FILES=() +# Why the final-newline refusal: a `|` block scalar that ends at EOF with no +# line break is read differently by go-yaml (yq) and eemeli/yaml — measured on +# k9-contractile.yml, the trailing "\n" of its last run: body exists for one +# parser and not the other. Formatting such a file would silently pick one +# reading, so the script refuses it and names the fix instead. +set -uo pipefail -# Parse arguments -while [[ $# -gt 0 ]]; do - case "$1" in - --check) - CHECK_MODE=true - shift - ;; - *) - FILES+=("$1") - shift - ;; - esac -done +MODE=format +if [ "${1:-}" = "--check" ]; then + MODE=check + shift +fi -if [[ ${#FILES[@]} -eq 0 ]]; then - echo "Error: No files specified" >&2 - echo "Usage: $0 [--check] FILE..." >&2 - exit 1 +if [ "$#" -eq 0 ]; then + echo "kyaml-format: no files given (usage: kyaml-format.sh [--check] FILE...)" >&2 + exit 2 fi -RETCODE=0 -CHANGED=false +command -v yq >/dev/null 2>&1 || { echo "kyaml-format: yq is not on PATH" >&2; exit 2; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT -for file in "${FILES[@]}"; do - if [[ ! -f "$file" ]]; then - echo "Error: File not found: $file" >&2 - exit 1 - fi +# canonical_data — prints the file's parsed value as key-sorted JSON; +# fails when the file does not parse. +canonical_data() { + yq -p yaml -o json -I 0 'sort_keys(..)' "$1" +} - # Check for final newline - if [[ -n "$(tail -c 1 "$file" 2>/dev/null)" ]]; then - echo "Error: File has no final newline: $file" >&2 - exit 1 - fi +# kyaml_of — writes the KYAML rendering of to ; +# fails when does not parse. +kyaml_of() { + yq -p yaml -o kyaml '.' "$1" > "$2" +} - # Try to parse with yq (dry run first) - if ! yq -o kyaml "$file" >/dev/null 2>&1; then - echo "Error: File does not parse as YAML: $file" >&2 - exit 1 - fi +# refuse — reports a file this script will not judge. +refuse() { + echo "::error file=$1::kyaml-format: REFUSED $1 — $2" >&2 + refused=$((refused + 1)) +} - if $CHECK_MODE; then - # Check if formatting would change the file - yq -o kyaml "$file" > /tmp/kyaml_check_$$ - if ! cmp -s "$file" /tmp/kyaml_check_$$; then - echo "File would be changed: $file" >&2 - CHANGED=true - fi - rm -f /tmp/kyaml_check_$$ - else - # Format the file in place - yq -i -o kyaml "$file" - if [[ $? -ne 0 ]]; then - echo "Error: Failed to format: $file" >&2 - exit 1 - fi - fi +refused=0 drift=0 clean=0 rewritten=0 +i=0 +for f in "$@"; do + i=$((i + 1)) + out="$TMP/$i.kyaml" + if [ ! -f "$f" ]; then + refuse "$f" "no such file"; continue + fi + if [ -s "$f" ] && [ -n "$(tail -c1 "$f")" ]; then + refuse "$f" "no final newline; add one first (a trailing block scalar is ambiguous without it)"; continue + fi + if ! kyaml_of "$f" "$out" 2>"$TMP/err"; then + refuse "$f" "does not parse: $(head -1 "$TMP/err")"; continue + fi + if cmp -s "$f" "$out"; then + clean=$((clean + 1)); continue + fi + # The rewrite is only acceptable when it preserves the data exactly. + if ! before=$(canonical_data "$f") || ! after=$(canonical_data "$out") || [ "$before" != "$after" ]; then + refuse "$f" "the KYAML rendering does not parse to the same data"; continue + fi + if [ "$MODE" = check ]; then + echo "::error file=$f::kyaml-format: $f is not KYAML — run scripts/kyaml-format.sh $f" >&2 + drift=$((drift + 1)) + else + cat "$out" > "$f" + echo "kyaml-format: rewrote $f" + rewritten=$((rewritten + 1)) + fi done -if $CHANGED; then - exit 2 +total=$# +if [ "$refused" -gt 0 ]; then + echo "kyaml-format: $refused of $total file(s) REFUSED — not judged, not clean" >&2 + exit 2 fi - -exit $RETCODE +if [ "$drift" -gt 0 ]; then + echo "kyaml-format: $drift of $total file(s) are not KYAML" >&2 + exit 1 +fi +if [ "$MODE" = check ]; then + echo "kyaml-format: all $total file(s) are KYAML" +else + echo "kyaml-format: $total file(s) KYAML ($rewritten rewritten, $clean already clean)" +fi +exit 0 diff --git a/scripts/kyaml-migrate.sh b/scripts/kyaml-migrate.sh index fa5a7fa03..34e33c98d 100755 --- a/scripts/kyaml-migrate.sh +++ b/scripts/kyaml-migrate.sh @@ -69,6 +69,11 @@ if $DRY_RUN; then exit 0 fi +# Scratch file for each candidate rewrite: private (mktemp), never a fixed +# /tmp name, and removed on every exit path. +NEW_FILE="$(mktemp)" +trap 'rm -f "$NEW_FILE"' EXIT + CHANGED=false ERRORS=0 CONVERTED=0 @@ -85,24 +90,24 @@ for file in "${YAML_FILES[@]}"; do fi # Try to parse with yq - if ! yq -o kyaml "$file" > /tmp/kyaml_new_$$ 2>&1; then + if ! yq -o kyaml "$file" > "$NEW_FILE" 2>&1; then echo "ERROR (parse failed)" ERRORS=$((ERRORS + 1)) - rm -f /tmp/kyaml_new_$$ + : > "$NEW_FILE" continue fi # Check if conversion would change the file - if cmp -s "$file" /tmp/kyaml_new_$$; then + if cmp -s "$file" "$NEW_FILE"; then echo "OK (already KYAML)" - rm -f /tmp/kyaml_new_$$ + : > "$NEW_FILE" continue fi if $CHECK_MODE; then echo "WOULD CHANGE" CHANGED=true - rm -f /tmp/kyaml_new_$$ + : > "$NEW_FILE" continue fi @@ -111,7 +116,7 @@ for file in "${YAML_FILES[@]}"; do echo "WOULD CONVERT" CHANGED=true else - mv /tmp/kyaml_new_$$ "$file" + cat "$NEW_FILE" > "$file" # keep the target mode; mktemp is 0600 echo "CONVERTED" CONVERTED=$((CONVERTED + 1)) fi diff --git a/scripts/test_kyaml_comment_preservation.sh b/scripts/test_kyaml_comment_preservation.sh index 639477c4b..a46939cf8 100755 --- a/scripts/test_kyaml_comment_preservation.sh +++ b/scripts/test_kyaml_comment_preservation.sh @@ -25,7 +25,9 @@ log() { } # Create a test file with various comment types -TEST_FILE="/tmp/kyaml_test_$$" +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "$TEST_DIR"' EXIT +TEST_FILE="$TEST_DIR/kyaml_test" cat > "$TEST_FILE" << 'EOF' # Header comment # Another header line