diff --git a/.github/workflows/validate-action-tests.yml b/.github/workflows/validate-action-tests.yml new file mode 100644 index 0000000..070cafc --- /dev/null +++ b/.github/workflows/validate-action-tests.yml @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Validate K9 action + +on: + pull_request: + paths: + - ".github/workflows/validate-action-tests.yml" + - "validate-action/**" + push: + branches: [main] + paths: + - ".github/workflows/validate-action-tests.yml" + - "validate-action/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + regression-tests: + name: K9 validator regression tests + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Check shell syntax + run: bash -n validate-action/validate-k9.sh validate-action/tests/test-validate-k9.sh + - name: Run regression and positive-control tests + run: validate-action/tests/test-validate-k9.sh diff --git a/.machine_readable/6a2/AGENTIC.a2ml b/.machine_readable/descriptiles/AGENTIC.a2ml similarity index 83% rename from .machine_readable/6a2/AGENTIC.a2ml rename to .machine_readable/descriptiles/AGENTIC.a2ml index 74c076e..9557878 100644 --- a/.machine_readable/6a2/AGENTIC.a2ml +++ b/.machine_readable/descriptiles/AGENTIC.a2ml @@ -6,7 +6,7 @@ (updated "2026-06-27")) (intent "Agent-facing coordination surface for the k9 hub.") (capabilities - (capability "resolve membership from .machine_readable/6a2/ECOSYSTEM.a2ml") + (capability "resolve membership from .machine_readable/descriptiles/ECOSYSTEM.a2ml") (capability "resolve upstream pins from .machine_readable/anchors/ANCHOR.a2ml") (capability "run K9 conformance gates over conformance/valid and conformance/invalid")) (status "stub — to be expanded with agent playbooks")) diff --git a/.machine_readable/6a2/ECOSYSTEM.a2ml b/.machine_readable/descriptiles/ECOSYSTEM.a2ml similarity index 100% rename from .machine_readable/6a2/ECOSYSTEM.a2ml rename to .machine_readable/descriptiles/ECOSYSTEM.a2ml diff --git a/.machine_readable/6a2/META.a2ml b/.machine_readable/descriptiles/META.a2ml similarity index 100% rename from .machine_readable/6a2/META.a2ml rename to .machine_readable/descriptiles/META.a2ml diff --git a/.machine_readable/6a2/NEUROSYM.a2ml b/.machine_readable/descriptiles/NEUROSYM.a2ml similarity index 100% rename from .machine_readable/6a2/NEUROSYM.a2ml rename to .machine_readable/descriptiles/NEUROSYM.a2ml diff --git a/.machine_readable/6a2/PLAYBOOK.a2ml b/.machine_readable/descriptiles/PLAYBOOK.a2ml similarity index 86% rename from .machine_readable/6a2/PLAYBOOK.a2ml rename to .machine_readable/descriptiles/PLAYBOOK.a2ml index 0fdb81b..7e4c511 100644 --- a/.machine_readable/6a2/PLAYBOOK.a2ml +++ b/.machine_readable/descriptiles/PLAYBOOK.a2ml @@ -8,7 +8,7 @@ (add-member (step "Create or identify the member repo under hyperpolymath/") (step "git submodule add -b main https://github.com/hyperpolymath/.git members//") - (step "Add a (member ...) entry to .machine_readable/6a2/ECOSYSTEM.a2ml and a check_member line to scripts/check-membership.sh") + (step "Add a (member ...) entry to .machine_readable/descriptiles/ECOSYSTEM.a2ml and a check_member line to scripts/check-membership.sh") (step "Run scripts/check-membership.sh until it passes")) (add-conformance-case (step "Add a .k9.ncl fixture under conformance/valid or conformance/invalid") @@ -18,7 +18,7 @@ (bump-upstream-pin (step "Cut or identify the target tag in hyperpolymath/standards") (step "Replace pin = TODO-tag in .machine_readable/anchors/ANCHOR.a2ml under [upstream.spec] and [upstream.governance]") - (step "Mirror the pin in .machine_readable/6a2/ECOSYSTEM.a2ml (upstream ...)") + (step "Mirror the pin in .machine_readable/descriptiles/ECOSYSTEM.a2ml (upstream ...)") (step "Make the upstream-pins CI gate enforcing"))) (open-threads (thread "Resolve spec home: standards/CONTRACTILE-SPEC vs create k9-svc") diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/descriptiles/STATE.a2ml similarity index 100% rename from .machine_readable/6a2/STATE.a2ml rename to .machine_readable/descriptiles/STATE.a2ml diff --git a/0-AI-MANIFEST.a2ml b/0-AI-MANIFEST.a2ml index b52f7e8..834ab10 100644 --- a/0-AI-MANIFEST.a2ml +++ b/0-AI-MANIFEST.a2ml @@ -13,15 +13,15 @@ (upstream "hyperpolymath/standards") (machine-readable (anchor ".machine_readable/anchors/ANCHOR.a2ml") - (ecosystem ".machine_readable/6a2/ECOSYSTEM.a2ml") - (state ".machine_readable/6a2/STATE.a2ml") - (meta ".machine_readable/6a2/META.a2ml") - (playbook ".machine_readable/6a2/PLAYBOOK.a2ml") - (agentic ".machine_readable/6a2/AGENTIC.a2ml") - (neurosym ".machine_readable/6a2/NEUROSYM.a2ml")) + (ecosystem ".machine_readable/descriptiles/ECOSYSTEM.a2ml") + (state ".machine_readable/descriptiles/STATE.a2ml") + (meta ".machine_readable/descriptiles/META.a2ml") + (playbook ".machine_readable/descriptiles/PLAYBOOK.a2ml") + (agentic ".machine_readable/descriptiles/AGENTIC.a2ml") + (neurosym ".machine_readable/descriptiles/NEUROSYM.a2ml")) (owned-here (conformance "conformance/") - (membership ".machine_readable/6a2/ECOSYSTEM.a2ml") + (membership ".machine_readable/descriptiles/ECOSYSTEM.a2ml") (drift-ci ".github/workflows/anchor-drift.yml")) (not-owned-here (spec (repo "hyperpolymath/standards") (path "docs/CONTRACTILE-SPEC.adoc") (pin "TODO-tag")) diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc index 0e08716..174e1f5 100644 --- a/CONTRIBUTING.adoc +++ b/CONTRIBUTING.adoc @@ -16,7 +16,7 @@ We welcome contributions in many forms: `+conformance/valid/+` and `+conformance/invalid/+` (keep `+conformance/manifest.a2ml+` in sync). * *Membership:* Propose member additions/removals via -`+.machine_readable/6a2/ECOSYSTEM.a2ml+`, kept in lockstep with +`+.machine_readable/descriptiles/ECOSYSTEM.a2ml+`, kept in lockstep with `+.gitmodules+` and `+scripts/check-membership.sh+`. * *Drift CI & tooling:* Improve `+.github/workflows/anchor-drift.yml+` or the helper `+scripts/+`. diff --git a/QUICKSTART-DEV.adoc b/QUICKSTART-DEV.adoc index db6c4be..b3add32 100644 --- a/QUICKSTART-DEV.adoc +++ b/QUICKSTART-DEV.adoc @@ -10,7 +10,7 @@ Clone the hub and initialise member submodules (or run `scripts/init-submodules. Run `scripts/check-membership.sh` to align manifest, `.gitmodules`, and gitlinks. == Add a member or conformance case -Follow the `add-member` / `add-conformance-case` steps in `.machine_readable/6a2/PLAYBOOK.a2ml`. +Follow the `add-member` / `add-conformance-case` steps in `.machine_readable/descriptiles/PLAYBOOK.a2ml`. == Run conformance locally Validate `conformance/valid` (0 errors) and `conformance/invalid` strict (every case errors). diff --git a/QUICKSTART-USER.adoc b/QUICKSTART-USER.adoc index af9eedf..f5a0484 100644 --- a/QUICKSTART-USER.adoc +++ b/QUICKSTART-USER.adoc @@ -7,7 +7,7 @@ The coordination hub and satellite for K9 self-validating components and Nickel contractiles. == Find a member -Locate implementations, tooling, CI, and examples via `.machine_readable/6a2/ECOSYSTEM.a2ml`. +Locate implementations, tooling, CI, and examples via `.machine_readable/descriptiles/ECOSYSTEM.a2ml`. == Use the specification The K9 contractile spec lives upstream in `hyperpolymath/standards`, pinned by tag. diff --git a/README.adoc b/README.adoc index e666941..9d3360c 100644 --- a/README.adoc +++ b/README.adoc @@ -15,7 +15,7 @@ link:.machine_readable/anchors/ANCHOR.a2ml[ANCHOR.a2ml] by tag. == Owned Here -* link:.machine_readable/6a2/ECOSYSTEM.a2ml[ECOSYSTEM.a2ml] records the member +* link:.machine_readable/descriptiles/ECOSYSTEM.a2ml[ECOSYSTEM.a2ml] records the member repository manifest. * link:conformance/manifest.a2ml[conformance/manifest.a2ml] indexes the local positive and negative K9 fixtures. @@ -38,8 +38,8 @@ member submodule. Whether the spec ultimately lives in `standards` or a new Coordination metadata lives under `.machine_readable/` only: * `anchors/ANCHOR.a2ml` — authority anchor and upstream pins. -* `6a2/ECOSYSTEM.a2ml` — membership manifest. -* `6a2/{STATE,META,PLAYBOOK,AGENTIC,NEUROSYM}.a2ml` — hub state, ADRs, playbook, +* `descriptiles/ECOSYSTEM.a2ml` — membership manifest. +* `descriptiles/{STATE,META,PLAYBOOK,AGENTIC,NEUROSYM}.a2ml` — hub state, ADRs, playbook, and agentic / neuro-symbolic notes. link:0-AI-MANIFEST.a2ml[0-AI-MANIFEST.a2ml] at the repository root is the entry diff --git a/SETUP.adoc b/SETUP.adoc index 26ef661..c4775a3 100644 --- a/SETUP.adoc +++ b/SETUP.adoc @@ -23,7 +23,7 @@ scripts/check-membership.sh `+scripts/init-submodules.sh+` initializes from sibling local checkouts when they are present and skips members that are not available in the local scope. `+scripts/check-membership.sh+` reads -`+.machine_readable/6a2/ECOSYSTEM.a2ml+`. +`+.machine_readable/descriptiles/ECOSYSTEM.a2ml+`. === Machine-Readable Layout @@ -31,8 +31,8 @@ Coordination metadata lives under `+.machine_readable/+` only: * `+.machine_readable/anchors/ANCHOR.a2ml+` — authority anchor and upstream pins. -* `+.machine_readable/6a2/ECOSYSTEM.a2ml+` — membership manifest. -* `+.machine_readable/6a2/{STATE,META,PLAYBOOK,AGENTIC,NEUROSYM}.a2ml+` +* `+.machine_readable/descriptiles/ECOSYSTEM.a2ml+` — membership manifest. +* `+.machine_readable/descriptiles/{STATE,META,PLAYBOOK,AGENTIC,NEUROSYM}.a2ml+` — hub state, ADRs, playbook, and agentic / neuro-symbolic notes. `+0-AI-MANIFEST.a2ml+` at the repository root indexes this surface. diff --git a/ex/0-AI-MANIFEST.a2ml b/ex/0-AI-MANIFEST.a2ml index 64e26d7..7c0bf79 100644 --- a/ex/0-AI-MANIFEST.a2ml +++ b/ex/0-AI-MANIFEST.a2ml @@ -7,9 +7,9 @@ format = "a2ml" [canonical-locations] machine-readable = ".machine_readable/" -state = ".machine_readable/6a2/STATE.a2ml" -meta = ".machine_readable/6a2/META.a2ml" -ecosystem = ".machine_readable/6a2/ECOSYSTEM.a2ml" +state = ".machine_readable/descriptiles/STATE.a2ml" +meta = ".machine_readable/descriptiles/META.a2ml" +ecosystem = ".machine_readable/descriptiles/ECOSYSTEM.a2ml" contractiles = ".machine_readable/contractiles/" bot-directives = ".machine_readable/bot_directives/" diff --git a/gleam/0-AI-MANIFEST.a2ml b/gleam/0-AI-MANIFEST.a2ml index 64e26d7..7c0bf79 100644 --- a/gleam/0-AI-MANIFEST.a2ml +++ b/gleam/0-AI-MANIFEST.a2ml @@ -7,9 +7,9 @@ format = "a2ml" [canonical-locations] machine-readable = ".machine_readable/" -state = ".machine_readable/6a2/STATE.a2ml" -meta = ".machine_readable/6a2/META.a2ml" -ecosystem = ".machine_readable/6a2/ECOSYSTEM.a2ml" +state = ".machine_readable/descriptiles/STATE.a2ml" +meta = ".machine_readable/descriptiles/META.a2ml" +ecosystem = ".machine_readable/descriptiles/ECOSYSTEM.a2ml" contractiles = ".machine_readable/contractiles/" bot-directives = ".machine_readable/bot_directives/" diff --git a/scripts/check-membership.sh b/scripts/check-membership.sh index 795de04..f6ade80 100755 --- a/scripts/check-membership.sh +++ b/scripts/check-membership.sh @@ -6,11 +6,14 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.." failures=0 +# Report a membership validation error and increment the failure counter. fail() { printf 'membership error: %s\n' "$1" >&2 failures=$((failures + 1)) } +# Check that a member repository is correctly registered in ECOSYSTEM.a2ml +# and .gitmodules with proper URL, branch, and submodule mode. check_member() { local group="$1" local name="$2" @@ -21,8 +24,8 @@ check_member() { local actual_branch local mode - if ! grep -Fq "(member \"${name}\" (group \"${group}\")" .machine_readable/6a2/ECOSYSTEM.a2ml; then - fail ".machine_readable/6a2/ECOSYSTEM.a2ml missing ${group}/${name}" + if ! grep -Fq "(member \"${name}\" (group \"${group}\")" .machine_readable/descriptiles/ECOSYSTEM.a2ml; then + fail ".machine_readable/descriptiles/ECOSYSTEM.a2ml missing ${group}/${name}" fi actual_url="$(git config -f .gitmodules --get "${module}.url" || true)" @@ -54,8 +57,8 @@ check_member ci k9-validate-action check_member ci k9-pre-commit check_member examples k9-showcase -if ! grep -Fq '(related "k9-svc"' .machine_readable/6a2/ECOSYSTEM.a2ml; then - fail ".machine_readable/6a2/ECOSYSTEM.a2ml missing future k9-svc related reference" +if ! grep -Fq '(related "k9-svc"' .machine_readable/descriptiles/ECOSYSTEM.a2ml; then + fail ".machine_readable/descriptiles/ECOSYSTEM.a2ml missing future k9-svc related reference" fi if [[ "${failures}" -gt 0 ]]; then diff --git a/showcase/0-AI-MANIFEST.a2ml b/showcase/0-AI-MANIFEST.a2ml index 64e26d7..7c0bf79 100644 --- a/showcase/0-AI-MANIFEST.a2ml +++ b/showcase/0-AI-MANIFEST.a2ml @@ -7,9 +7,9 @@ format = "a2ml" [canonical-locations] machine-readable = ".machine_readable/" -state = ".machine_readable/6a2/STATE.a2ml" -meta = ".machine_readable/6a2/META.a2ml" -ecosystem = ".machine_readable/6a2/ECOSYSTEM.a2ml" +state = ".machine_readable/descriptiles/STATE.a2ml" +meta = ".machine_readable/descriptiles/META.a2ml" +ecosystem = ".machine_readable/descriptiles/ECOSYSTEM.a2ml" contractiles = ".machine_readable/contractiles/" bot-directives = ".machine_readable/bot_directives/" diff --git a/validate-action/README.adoc b/validate-action/README.adoc index 8775089..c2c9602 100644 --- a/validate-action/README.adoc +++ b/validate-action/README.adoc @@ -13,7 +13,12 @@ compliance, pedigree completeness, and hunt-level signature requirements. == What It Validates -The action performs five checks on every K9 file discovered: +The action performs five checks on every target K9 pedigree contract +discovered. Vendored/fixture paths and estate coordination/session files that +reuse the suffix without being pedigree contracts are excluded by default. +Other `.k9`-suffixed files are treated as targets only when they contain a +pedigree signal such as `K9!`, `magic_number`, a pedigree block, or a K9 schema +reference. Non-pedigree naming collisions are reported as notices and skipped. [cols="1,1,3"] |=== @@ -21,7 +26,8 @@ The action performs five checks on every K9 file discovered: | K9! magic number | Error -| The first non-empty line must be exactly `K9!`. +| Plain `.k9` files must begin with `K9!`. Nickel `.k9.ncl` files may instead + carry `magic_number = "K9!"` or apply/import/merge the K9 pedigree schema. | SPDX header | Warning @@ -34,8 +40,9 @@ The action performs five checks on every K9 file discovered: | Security level | Error -| The `leash` or `security_level` field must be one of: `kennel`, `yard`, - `hunt` (case-insensitive). Nickel enum tick syntax (`'Kennel`) is handled. +| The `leash`, `security_level`, or schema-v1 `trust_level` field must be one + of: `kennel`, `yard`, `hunt` (case-insensitive). Nickel enum tick syntax + (`'Kennel`) is handled. | Hunt-level signature | Error @@ -60,7 +67,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: hyperpolymath/standards/k9-svc/actions/validate@main + # Replace with the reviewed immutable revision. + - uses: hyperpolymath/k9-ecosystem/validate-action@ with: path: '.' # Directory to scan (default: repo root) strict: 'false' # Promote warnings to errors (default: false) @@ -70,10 +78,11 @@ jobs: [source,yaml] ---- - - uses: hyperpolymath/standards/k9-svc/actions/validate@main + - uses: hyperpolymath/k9-ecosystem/validate-action@ id: k9 - run: | echo "Scanned: ${{ steps.k9.outputs.files-scanned }}" + echo "Skipped: ${{ steps.k9.outputs.files-skipped }}" echo "Errors: ${{ steps.k9.outputs.errors }}" echo "Warnings: ${{ steps.k9.outputs.warnings }}" ---- @@ -93,6 +102,11 @@ jobs: | `false` | When `true`, all warnings are promoted to errors and the action fails on any validation issue. + +| `paths-ignore` +| vendored, fixture, coordination/session defaults +| Newline-separated path fragments to exclude. A supplied value replaces the + defaults. Pass an explicit empty string to scan every `.k9`/`.k9.ncl` file. |=== == Outputs @@ -104,6 +118,10 @@ jobs: | `files-scanned` | Total number of K9 files discovered and processed. +| `files-skipped` +| Number of discovered K9-suffixed files excluded by path or because they do + not carry a pedigree-contract signal. + | `errors` | Count of validation errors found. Non-zero causes exit code 1. diff --git a/validate-action/action.yml b/validate-action/action.yml index 8f5c962..eb8c9bd 100644 --- a/validate-action/action.yml +++ b/validate-action/action.yml @@ -9,7 +9,8 @@ name: 'Validate K9 Configurations' description: >- Scan and validate K9 configuration files (.k9 and .k9.ncl) in your repository. Checks for K9! magic number, pedigree block (name, version, - security_level), valid security levels (kennel/yard/hunt), and ensures + security_level, or schema-v1 trust_level), valid security levels + (kennel/yard/hunt), and ensures hunt-level files include a signature field. author: 'Jonathan D.A. Jewell' @@ -30,11 +31,34 @@ inputs: will fail on any validation issue. Defaults to false. required: false default: 'false' + paths-ignore: + description: >- + Newline-separated path fragments to skip. Each fragment is matched + against the discovered file path. The defaults exclude vendored and + fixture trees plus estate-standard coordination/session files that use + the K9 suffix but are not pedigree contracts. Pass an empty string to + scan every matching file. + required: false + default: | + vendor/ + vendored/ + verified-container-spec/ + .audittraining/ + integration/fixtures/ + test/fixtures/ + tests/fixtures/ + absolute-zero/ + coordination.k9 + session/custom-checks.k9 + self-validating/methodology-guard.k9.ncl outputs: files-scanned: description: 'Number of K9 files scanned' value: ${{ steps.validate.outputs.files_scanned }} + files-skipped: + description: 'Number of non-target K9 paths skipped' + value: ${{ steps.validate.outputs.files_skipped }} errors: description: 'Number of validation errors found' value: ${{ steps.validate.outputs.errors }} @@ -51,5 +75,6 @@ runs: env: INPUT_PATH: ${{ inputs.path }} INPUT_STRICT: ${{ inputs.strict }} + INPUT_PATHS_IGNORE: ${{ inputs.paths-ignore }} run: | "${GITHUB_ACTION_PATH}/validate-k9.sh" diff --git a/validate-action/tests/test-validate-k9.sh b/validate-action/tests/test-validate-k9.sh new file mode 100755 index 0000000..ae4bf7a --- /dev/null +++ b/validate-action/tests/test-validate-k9.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 + +set -euo pipefail + +ACTION_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +FIXTURE_DIR=$(mktemp -d) +trap 'rm -rf "$FIXTURE_DIR"' EXIT + +# Write valid Nickel-dialect K9 test fixtures demonstrating various pedigree +# patterns: imported schema, base contract with magic_number, and schema application. +write_valid_nickel() { + mkdir -p "$FIXTURE_DIR/contracts" + cat > "$FIXTURE_DIR/contracts/imported.k9.ncl" <<'EOF' +# SPDX-License-Identifier: MPL-2.0 +let base = import "./base.k9.ncl" in +base.pedigree_schema & { + name = "imported-contract", + version = "1.0.0", + leash = 'Yard, +} +EOF + cat > "$FIXTURE_DIR/contracts/base.k9.ncl" <<'EOF' +# SPDX-License-Identifier: MPL-2.0 +{ + magic_number = "K9!", + pedigree = { name = "base-contract", version = "1.0.0", leash = 'Kennel }, + trust_level = "data-only", +} +EOF + cat > "$FIXTURE_DIR/contracts/schema-application.k9.ncl" <<'EOF' +# SPDX-License-Identifier: MPL-2.0 +let pedigree = import "./pedigree.ncl" in +pedigree.K9Pedigree { + metadata = { name = "schema-application", version = "1.0.0" }, + policy = { trust_level = 'Yard }, +} +EOF +} + +# Write a valid plain-dialect K9 test fixture with metadata block and security_level. +write_valid_plain() { + cat > "$FIXTURE_DIR/contracts/plain.k9" <<'EOF' +K9! +# SPDX-License-Identifier: MPL-2.0 +metadata: + name: plain-contract + version: 1.0.0 + security_level: yard +EOF +} + +# Write K9-suffixed files that should be skipped by default ignore rules: +# estate coordination files, session configs, and generated contractiles. +write_default_ignored_non_contracts() { + mkdir -p "$FIXTURE_DIR/session" "$FIXTURE_DIR/.machine_readable/self-validating" + cat > "$FIXTURE_DIR/coordination.k9" <<'EOF' +K9! +session_management: + source_of_truth: standards/session-management-standards +EOF + cat > "$FIXTURE_DIR/session/custom-checks.k9" <<'EOF' +K9! +checks: + - id: session-state-has-next-action +EOF + cat > "$FIXTURE_DIR/.machine_readable/self-validating/methodology-guard.k9.ncl" <<'EOF' +# SPDX-License-Identifier: MPL-2.0 +{ rules = [ "guard methodology" ] } +EOF + mkdir -p "$FIXTURE_DIR/generated/k9iser" + cat > "$FIXTURE_DIR/generated/k9iser/container-build.k9" <<'EOF' +# Auto-generated contractiles policy. This shares a suffix but is not a K9 +# pedigree contract. +[must] +security.non-root : bool { == true } +EOF + cat > "$FIXTURE_DIR/contracts/comment-only.k9.ncl" <<'EOF' +# SPDX-License-Identifier: MPL-2.0 +# magic_number = "K9!" +# pedigree = { name = "commented", version = "1.0.0", leash = 'Hunt } +# signature = "not-reachable" +{ unrelated = true } +EOF + cat > "$FIXTURE_DIR/contracts/quoted-schema-note.k9.ncl" <<'EOF' +# SPDX-License-Identifier: MPL-2.0 +{ note = "K9Pedigree and pedigree_schema are documentation here" } +EOF +} + +# Write an invalid K9 pedigree contract (has K9! marker but missing required +# pedigree fields) to test validation failure paths. +write_invalid_target() { + cat > "$FIXTURE_DIR/contracts/invalid-target.k9" <<'EOF' +K9! +# SPDX-License-Identifier: MPL-2.0 +settings: + enabled: true +EOF +} + +write_commented_required_field_target() { + cat > "$FIXTURE_DIR/contracts/commented-required.k9" <<'EOF' +K9! +# SPDX-License-Identifier: MPL-2.0 +metadata: + # name: this-comment-must-not-satisfy-the-gate + version: 1.0.0 + leash: yard +EOF +} + +write_commented_signature_target() { + cat > "$FIXTURE_DIR/contracts/commented-signature.k9" <<'EOF' +K9! +# SPDX-License-Identifier: MPL-2.0 +metadata: + name: unsigned-hunt + version: 1.0.0 + leash: hunt + # signature: this-comment-must-not-satisfy-the-gate +EOF +} + +write_legacy_trust_target() { + cat > "$FIXTURE_DIR/contracts/legacy-trust.k9" <<'EOF' +K9! +# SPDX-License-Identifier: MPL-2.0 +metadata: + name: legacy-trust-metadata + version: 1.0.0 + trust_level: internal +EOF +} + +write_valid_nickel +write_valid_plain +write_default_ignored_non_contracts + +pass_output=$(INPUT_PATH="$FIXTURE_DIR" "$ACTION_DIR/validate-k9.sh") +grep -q 'Files scanned: 4' <<< "$pass_output" +grep -q 'Files skipped: 6' <<< "$pass_output" +grep -q '3 by path, 3 without a pedigree signal' <<< "$pass_output" +grep -q 'Errors: 0' <<< "$pass_output" + +# Positive control: default exclusions must not turn the validator into a +# blanket pass. An unexcluded malformed pedigree contract must still fail. +write_invalid_target +if INPUT_PATH="$FIXTURE_DIR" "$ACTION_DIR/validate-k9.sh" > "$FIXTURE_DIR/invalid.log" 2>&1; then + echo 'expected invalid-target.k9 to fail validation' >&2 + exit 1 +fi +grep -q 'Missing pedigree' "$FIXTURE_DIR/invalid.log" +rm "$FIXTURE_DIR/contracts/invalid-target.k9" + +# Commented fields are not reachable syntax and cannot satisfy required fields +# or the Hunt signature requirement. +write_commented_required_field_target +if INPUT_PATH="$FIXTURE_DIR" "$ACTION_DIR/validate-k9.sh" \ + > "$FIXTURE_DIR/commented-required.log" 2>&1; then + echo 'expected a commented name field not to satisfy the pedigree gate' >&2 + exit 1 +fi +grep -q "missing 'name' field" "$FIXTURE_DIR/commented-required.log" +rm "$FIXTURE_DIR/contracts/commented-required.k9" + +write_commented_signature_target +if INPUT_PATH="$FIXTURE_DIR" "$ACTION_DIR/validate-k9.sh" \ + > "$FIXTURE_DIR/commented-signature.log" 2>&1; then + echo 'expected a commented signature not to satisfy a Hunt contract' >&2 + exit 1 +fi +grep -q "must include a 'signature'" "$FIXTURE_DIR/commented-signature.log" +rm "$FIXTURE_DIR/contracts/commented-signature.k9" + +# A legacy pedigree-level trust_level is descriptive metadata, not the +# schema-v1 policy.trust_level leash fallback. +write_legacy_trust_target +legacy_output=$(INPUT_PATH="$FIXTURE_DIR" "$ACTION_DIR/validate-k9.sh") +grep -q 'No security level (leash/security_level)' <<< "$legacy_output" +if grep -q "Invalid security level 'internal'" <<< "$legacy_output"; then + echo 'legacy trust_level was incorrectly treated as a security level' >&2 + exit 1 +fi +rm "$FIXTURE_DIR/contracts/legacy-trust.k9" + +# An explicitly empty override disables all defaults, so the non-contract +# coordination files become visible and make the lexical pedigree gate fail. +if INPUT_PATH="$FIXTURE_DIR" INPUT_PATHS_IGNORE='' \ + "$ACTION_DIR/validate-k9.sh" > "$FIXTURE_DIR/no-ignore.log" 2>&1; then + echo 'expected empty paths-ignore override to scan and reject non-contract files' >&2 + exit 1 +fi +grep -q 'coordination.k9' "$FIXTURE_DIR/no-ignore.log" + +echo 'K9 validator regression tests passed' diff --git a/validate-action/validate-k9.sh b/validate-action/validate-k9.sh index 24466c0..24d4f56 100755 --- a/validate-action/validate-k9.sh +++ b/validate-action/validate-k9.sh @@ -22,7 +22,8 @@ # Checks: # 1. Format marker (dialect-appropriate, see above) # 2. Pedigree presence with required fields (name; version as warning) -# 3. Security level is one of: kennel, yard, hunt (case-insensitive) +# 3. Security level (`leash`, `security_level`, or schema-v1 `trust_level`) +# is one of: kennel, yard, hunt (case-insensitive) # 4. Hunt-level files must have a signature or signature_required field # 5. SPDX-License-Identifier header presence # @@ -38,6 +39,9 @@ # Environment variables: # INPUT_PATH — Directory to scan (default: .) # INPUT_STRICT — Promote warnings to errors (default: false) +# INPUT_PATHS_IGNORE — Newline-separated path fragments to skip. When the +# variable is unset, estate-safe defaults are used. Set +# it to an empty string to scan every matching file. # # Exit codes: # 0 — All files valid (or only warnings in non-strict mode) @@ -52,6 +56,123 @@ set -euo pipefail SCAN_PATH="${INPUT_PATH:-.}" STRICT="${INPUT_STRICT:-false}" +# Some estate files share the .k9/.k9.ncl suffix without being pedigree +# contracts. Others are deliberately invalid fixtures or vendored copies that +# belong to a different repository's validation boundary. These defaults were +# introduced in 47cca67, then accidentally lost when fa30044 absorbed the +# standards implementation. Keep the policy here as well as in action.yml so +# direct/pre-push invocations behave the same as the GitHub Action. +DEFAULT_PATHS_IGNORE=$'vendor/\nvendored/\nverified-container-spec/\n.audittraining/\nintegration/fixtures/\ntest/fixtures/\ntests/fixtures/\nabsolute-zero/\ncoordination.k9\nsession/custom-checks.k9\nself-validating/methodology-guard.k9.ncl' +if [[ ${INPUT_PATHS_IGNORE+x} == x ]]; then + PATHS_IGNORE_RAW="$INPUT_PATHS_IGNORE" +else + PATHS_IGNORE_RAW="$DEFAULT_PATHS_IGNORE" +fi + +PATHS_IGNORE=() +while IFS= read -r fragment; do + fragment="${fragment#"${fragment%%[![:space:]]*}"}" + fragment="${fragment%"${fragment##*[![:space:]]}"}" + [[ -z "$fragment" || "$fragment" == \#* ]] && continue + PATHS_IGNORE+=("$fragment") +done <<< "$PATHS_IGNORE_RAW" + +# Check if a file path matches any configured ignore fragment. +# Returns 0 (true) if the path should be skipped, 1 (false) otherwise. +path_ignored() { + local path="$1" fragment + for fragment in "${PATHS_IGNORE[@]}"; do + [[ "$path" == *"$fragment"* ]] && return 0 + done + return 1 +} + +# Remove K9/YAML/Nickel line comments while preserving hashes inside +# double-quoted strings. SPDX detection deliberately continues to use the raw +# file because its marker is itself a comment. +strip_k9_comment() { + local input="$1" output="" char + local in_double=false escaped=false i + for ((i = 0; i < ${#input}; i++)); do + char="${input:i:1}" + if [[ "$escaped" == "true" ]]; then + output+="$char" + escaped=false + elif [[ "$in_double" == "true" && "$char" == "\\" ]]; then + output+="$char" + escaped=true + elif [[ "$char" == '"' ]]; then + output+="$char" + if [[ "$in_double" == "true" ]]; then + in_double=false + else + in_double=true + fi + elif [[ "$char" == '#' && "$in_double" == "false" ]]; then + break + else + output+="$char" + fi + done + printf '%s\n' "$output" +} + +comment_free_file() { + local file="$1" line + while IFS= read -r line; do + strip_k9_comment "$line" + done < "$file" +} + +# Replace double-quoted string contents with spaces before classifying a file. +# Schema words in prose or metadata values are not reachable K9 syntax, while +# structural expressions such as `pedigree.K9Pedigree` remain visible. +mask_k9_quoted_text() { + local input="$1" output="" char + local in_double=false escaped=false i + for ((i = 0; i < ${#input}; i++)); do + char="${input:i:1}" + if [[ "$in_double" == "true" ]]; then + if [[ "$escaped" == "true" ]]; then + output+=" " + escaped=false + elif [[ "$char" == "\\" ]]; then + output+=" " + escaped=true + elif [[ "$char" == '"' ]]; then + output+="$char" + in_double=false + else + output+=" " + fi + elif [[ "$char" == '"' ]]; then + output+="$char" + in_double=true + else + output+="$char" + fi + done + printf '%s\n' "$output" +} + +structural_syntax_file() { + local file="$1" line + while IFS= read -r line; do + mask_k9_quoted_text "$(strip_k9_comment "$line")" + done < "$file" +} + +# Check if a file is a K9 pedigree contract by searching for unambiguous +# pedigree signals (K9!, magic_number, pedigree metadata, or K9 schema reference). +# Returns 0 (true) if the file is a pedigree contract, 1 (false) otherwise. +is_pedigree_contract() { + local file="$1" syntax_content + syntax_content=$(structural_syntax_file "$file") + grep -Eq \ + '^[[:space:]]*K9![[:space:]]*$|^[[:space:]]*magic_number[[:space:]]*[=:]|^[[:space:]]*(let[[:space:]]+)?[A-Za-z_]*pedigree[[:space:]]*=|^[[:space:]]*(metadata|pedigree):[[:space:]]*$|K9Pedigree|pedigree_schema' \ + <<< "$syntax_content" +} + # Outside GitHub Actions GITHUB_OUTPUT is unset; under `set -u` an unset # expansion inside a redirection aborts the whole script (the `|| true` # cannot catch an expansion error). Default to /dev/null for local runs. @@ -122,6 +243,8 @@ normalise_level() { # --------------------------------------------------------------------------- validate_k9() { local file="$1" + local syntax_content + syntax_content=$(comment_free_file "$file") FILES_SCANNED=$((FILES_SCANNED + 1)) # Dialect: .k9.ncl is Nickel source; bare .k9 is the plain dialect. @@ -158,15 +281,15 @@ validate_k9() { local has_marker=false if [[ "$first_content_line" == "K9!" ]]; then has_marker=true - elif grep -Eq '^[[:space:]]*magic_number[[:space:]]*=[[:space:]]*"K9!"' "$file"; then + elif grep -Eq '^[[:space:]]*magic_number[[:space:]]*=[[:space:]]*"K9!"' <<< "$syntax_content"; then has_marker=true - elif grep -Eq '(K9Pedigree|pedigree_schema)[[:space:]]*&|&[[:space:]]*(.*\.)?(K9Pedigree|pedigree_schema)|import[[:space:]]*"[^"]*\.k9\.ncl"' "$file"; then + elif grep -Eq '(K9Pedigree|pedigree_schema)([[:space:]]*&|[[:space:]]*\{)|&[[:space:]]*(.*\.)?(K9Pedigree|pedigree_schema)|import[[:space:]]*"[^"]*(pedigree|\.k9)\.ncl"' <<< "$syntax_content"; then has_marker=true fi if [[ "$has_marker" == "false" ]]; then report_issue "error" "$file" "$first_content_line_num" \ - "Missing K9 format marker. A .k9.ncl file needs a magic_number = \"K9!\" field, a K9! preamble line, or a K9 pedigree schema import/merge" + "Missing K9 format marker. A .k9.ncl file needs a magic_number = \"K9!\" field, a K9! preamble line, or a K9 pedigree schema import/application/merge" fi fi @@ -198,16 +321,26 @@ validate_k9() { local has_pedigree_name=false local has_pedigree_version=false local has_security_level=false + local has_primary_security_level=false local security_level_value="" local security_level_line=0 local has_signature_field=false + local in_policy=false + local policy_depth=0 line_num=0 while IFS= read -r line; do line_num=$((line_num + 1)) - # Pedigree construct, Nickel forms: direct, let-bound, schema merge + if [[ "$line" =~ (^|[[:space:]\{,])policy[[:space:]]*=[[:space:]]*\{ ]]; then + in_policy=true + policy_depth=0 + fi + + # Pedigree construct, Nickel forms: direct, let-bound, schema + # application (`K9Pedigree { ... }`), or schema merge. if [[ "$line" =~ ^[[:space:]]*(let[[:space:]]+)?[A-Za-z_]*pedigree[[:space:]]*= ]] \ + || [[ "$line" =~ (K9Pedigree|pedigree_schema)[[:space:]]*\{ ]] \ || [[ "$line" =~ (K9Pedigree|pedigree_schema)[[:space:]]*\& ]] \ || [[ "$line" =~ \&[[:space:]]*([A-Za-z_][A-Za-z0-9_]*\.)?(K9Pedigree|pedigree_schema) ]]; then has_pedigree=true @@ -220,26 +353,52 @@ validate_k9() { fi # Required fields, either separator (= Nickel, : plain) - if [[ "$line" =~ ^[[:space:]]*name[[:space:]]*[=:] ]]; then + if [[ "$line" =~ (^|[[:space:]\{,])name[[:space:]]*[=:] ]]; then has_pedigree_name=true fi - if [[ "$line" =~ ^[[:space:]]*(version|schema_version)[[:space:]]*[=:] ]]; then + if [[ "$line" =~ (^|[[:space:]\{,])(version|schema_version)[[:space:]]*[=:] ]]; then has_pedigree_version=true fi # Security level (leash field) - if [[ "$line" =~ ^[[:space:]]*(leash|security_level)[[:space:]]*[=:] ]]; then + if [[ "$line" =~ (^|[[:space:]\{,])(leash|security_level)[[:space:]]*[=:][[:space:]]*([^,\}\#]+) ]]; then has_security_level=true - security_level_value="$(normalise_level "$line")" + has_primary_security_level=true + security_level_value="$(normalise_level "${BASH_REMATCH[0]}")" security_level_line=$line_num + elif [[ "$has_primary_security_level" == "false" ]]; then + local trust_is_policy=false + if [[ "$line" =~ policy[[:space:]]*\.[[:space:]]*trust_level[[:space:]]*[=:] ]] \ + || [[ "$in_policy" == "true" && "$line" =~ (^|[[:space:]\{,])trust_level[[:space:]]*[=:] ]] \ + || [[ "$line" =~ (^|[[:space:]\{,])policy[[:space:]]*=[[:space:]]*\{[^\}]*trust_level[[:space:]]*[=:] ]]; then + trust_is_policy=true + fi + if [[ "$trust_is_policy" == "true" ]] \ + && [[ "$line" =~ trust_level[[:space:]]*[=:][[:space:]]*([^,\}]+) ]]; then + # Only schema-v1 policy.trust_level is a leash. Legacy + # pedigree-level trust_level is descriptive metadata. + has_security_level=true + security_level_value="$(normalise_level "${BASH_REMATCH[0]}")" + security_level_line=$line_num + fi fi # Signature fields - if [[ "$line" =~ ^[[:space:]]*(signature|signature_required)[[:space:]]*[=:] ]]; then + if [[ "$line" =~ (^|[[:space:]\{,])(signature|signature_required)[[:space:]]*[=:] ]]; then has_signature_field=true fi - done < "$file" + + if [[ "$in_policy" == "true" ]]; then + local without_open="${line//\{/}" + local without_close="${line//\}/}" + policy_depth=$((policy_depth + ${#line} - ${#without_open} - ${#line} + ${#without_close})) + if (( policy_depth <= 0 )); then + in_policy=false + policy_depth=0 + fi + fi + done <<< "$syntax_content" if [[ "$has_pedigree" == "false" ]]; then report_issue "error" "$file" 1 \ @@ -299,12 +458,38 @@ echo "::group::K9 Configuration Validation" echo "Scanning ${SCAN_PATH} for K9 files (.k9, .k9.ncl)..." echo "" -# Find all K9 files, excluding .git directory -mapfile -t k9_files < <(find "$SCAN_PATH" \( -name '*.k9' -o -name '*.k9.ncl' \) -not -path '*/.git/*' -type f | sort) +# Find all K9 files, excluding .git and non-target paths. +mapfile -t k9_candidates < <(find "$SCAN_PATH" \( -name '*.k9' -o -name '*.k9.ncl' \) -not -path '*/.git/*' -type f | sort) + +k9_files=() +FILES_SKIPPED=0 +FILES_SKIPPED_PATH=0 +FILES_SKIPPED_NON_CONTRACT=0 +for file in "${k9_candidates[@]}"; do + if path_ignored "$file"; then + FILES_SKIPPED=$((FILES_SKIPPED + 1)) + FILES_SKIPPED_PATH=$((FILES_SKIPPED_PATH + 1)) + echo "::notice file=${file}::Skipped non-target K9 path" + continue + fi + if ! is_pedigree_contract "$file"; then + FILES_SKIPPED=$((FILES_SKIPPED + 1)) + FILES_SKIPPED_NON_CONTRACT=$((FILES_SKIPPED_NON_CONTRACT + 1)) + echo "::notice file=${file}::Skipped K9-suffixed file with no pedigree-contract signal" + continue + fi + k9_files+=("$file") +done + +if [[ $FILES_SKIPPED -gt 0 ]]; then + echo "Skipped ${FILES_SKIPPED} non-target K9 file(s) (${FILES_SKIPPED_PATH} by path, ${FILES_SKIPPED_NON_CONTRACT} without a pedigree signal)" + echo "" +fi if [[ ${#k9_files[@]} -eq 0 ]]; then echo "::notice::No K9 files found in ${SCAN_PATH}" echo "files_scanned=0" >> "$GITHUB_OUTPUT" 2>/dev/null || true + echo "files_skipped=${FILES_SKIPPED}" >> "$GITHUB_OUTPUT" 2>/dev/null || true echo "errors=0" >> "$GITHUB_OUTPUT" 2>/dev/null || true echo "warnings=0" >> "$GITHUB_OUTPUT" 2>/dev/null || true echo "::endgroup::" @@ -322,6 +507,7 @@ done echo "" echo "────────────────────────────────────────" echo "Files scanned: ${FILES_SCANNED}" +echo "Files skipped: ${FILES_SKIPPED}" echo "Errors: ${ERRORS}" echo "Warnings: ${WARNINGS}" echo "Strict mode: ${STRICT}" @@ -330,6 +516,7 @@ echo "──────────────────────── # Write outputs for GitHub Actions { echo "files_scanned=${FILES_SCANNED}" + echo "files_skipped=${FILES_SKIPPED}" echo "errors=${ERRORS}" echo "warnings=${WARNINGS}" } >> "$GITHUB_OUTPUT" 2>/dev/null || true