From 4ba5e4b2f5ec3ade0b4d3c8b924da3e7b0a07120 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Mon, 27 Jul 2026 23:28:14 +0100
Subject: [PATCH] ci: vendor validation scripts and remove remote action pins

---
 .githooks/validate-a2ml.sh         | 350 ++++++++++++++++++++++++++++
 .githooks/validate-k9.sh           | 357 +++++++++++++++++++++++++++++
 .github/workflows/dogfood-gate.yml |  12 +-
 3 files changed, 709 insertions(+), 10 deletions(-)
 create mode 100755 .githooks/validate-a2ml.sh
 create mode 100755 .githooks/validate-k9.sh

diff --git a/.githooks/validate-a2ml.sh b/.githooks/validate-a2ml.sh
new file mode 100755
index 0000000..b053676
--- /dev/null
+++ b/.githooks/validate-a2ml.sh
@@ -0,0 +1,350 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: MPL-2.0
+# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
+#
+# validate-a2ml.sh — A2ML manifest validation script
+#
+# Scans for .a2ml files and validates:
+#   1. Required fields: agent-id or pedigree name, version
+#   2. SPDX-License-Identifier header presence
+#   3. Attestation block structure (if present)
+#   4. Section heading syntax ([section] or ## section)
+#
+# Environment variables:
+#   INPUT_PATH   — Directory to scan (default: .)
+#   INPUT_STRICT — Promote warnings to errors (default: false)
+#
+# Exit codes:
+#   0 — All files valid (or only warnings in non-strict mode)
+#   1 — Validation errors found
+
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+SCAN_PATH="${INPUT_PATH:-.}"
+STRICT="${INPUT_STRICT:-false}"
+PATHS_IGNORE_RAW="${INPUT_PATHS_IGNORE:-}"
+GITHUB_OUTPUT_FILE="${GITHUB_OUTPUT:-/dev/null}"
+
+# Parse paths-ignore: newline-separated fragments, blank lines and # comments
+# stripped. Each fragment is a substring match against the file path. Pattern
+# adopted from hyperpolymath/hypatia#243 — content-pattern validators must
+# distinguish a target from a vendored / fixture file that legitimately
+# contains the very pattern being checked.
+PATHS_IGNORE=()
+while IFS= read -r _frag; do
+    # Strip leading and trailing whitespace (canonical bash idiom).
+    _frag="${_frag#"${_frag%%[![:space:]]*}"}"
+    _frag="${_frag%"${_frag##*[![:space:]]}"}"
+    [[ -z "$_frag" || "$_frag" == \#* ]] && continue
+    PATHS_IGNORE+=("$_frag")
+done <<< "$PATHS_IGNORE_RAW"
+
+# Returns 0 if path should be skipped (matches any ignore fragment)
+path_ignored() {
+    local p="$1" frag
+    for frag in "${PATHS_IGNORE[@]}"; do
+        [[ "$p" == *"$frag"* ]] && return 0
+    done
+    return 1
+}
+
+# Counters
+FILES_SCANNED=0
+ERRORS=0
+WARNINGS=0
+
+# ---------------------------------------------------------------------------
+# Helper: emit GitHub annotation
+# ---------------------------------------------------------------------------
+# Usage: annotate <level> <file> <line> <message>
+#   level: error | warning | notice
+annotate() {
+    local level="$1" file="$2" line="$3" message="$4"
+    echo "::${level} file=${file},line=${line}::${message}"
+}
+
+# ---------------------------------------------------------------------------
+# Helper: report issue (respects strict mode)
+# ---------------------------------------------------------------------------
+# Usage: report_issue <severity> <file> <line> <message>
+#   severity: error | warning
+report_issue() {
+    local severity="$1" file="$2" line="$3" message="$4"
+
+    if [[ "$severity" == "warning" && "$STRICT" == "true" ]]; then
+        severity="error"
+    fi
+
+    annotate "$severity" "$file" "$line" "$message"
+
+    if [[ "$severity" == "error" ]]; then
+        ERRORS=$((ERRORS + 1))
+    else
+        WARNINGS=$((WARNINGS + 1))
+    fi
+}
+
+# ---------------------------------------------------------------------------
+# Validator: check a single .a2ml file
+# ---------------------------------------------------------------------------
+validate_a2ml() {
+    local file="$1"
+    FILES_SCANNED=$((FILES_SCANNED + 1))
+
+    # --- Check 1: SPDX header ---
+    # The SPDX-License-Identifier should appear in the first 10 lines
+    local has_spdx=false
+    local line_num=0
+    while IFS= read -r line; do
+        line_num=$((line_num + 1))
+        if [[ $line_num -gt 10 ]]; then
+            break
+        fi
+        if [[ "$line" == *"SPDX-License-Identifier"* ]]; then
+            has_spdx=true
+            break
+        fi
+    done < "$file"
+
+    if [[ "$has_spdx" == "false" ]]; then
+        report_issue "warning" "$file" 1 \
+            "Missing SPDX-License-Identifier in first 10 lines"
+    fi
+
+    # --- Check 2: Required identity fields ---
+    # A2ML files must contain either:
+    #   - agent-id = "..." or agent_id = "..."
+    #   - pedigree block with name field
+    #   - name = "..." at top level (for AI manifests)
+    #   - project = "..." (for STATE.a2ml)
+    local has_identity=false
+    local has_version=false
+    line_num=0
+
+    while IFS= read -r line; do
+        line_num=$((line_num + 1))
+
+        # Check for identity fields (various A2ML patterns)
+        # TOML/kv form: `name = "..."`, `project = "..."`, `agent-id = "..."`
+        if [[ "$line" =~ ^[[:space:]]*(agent[-_]id|name|project)[[:space:]]*= ]]; then
+            has_identity=true
+        fi
+        # S-expression form: `(name "...")`, `(project "...")`,
+        # `(agent-id "...")`. Some A2ML dialects (audit registries,
+        # classification stores) use Lisp-style s-expressions for the
+        # metadata block instead of TOML. Identity carries the same
+        # semantics; only the syntax differs. Match at any indent so it
+        # also picks up entries nested under `(metadata ...)`.
+        if [[ "$line" =~ ^[[:space:]]*\([[:space:]]*(agent[-_]id|name|project)[[:space:]]+\" ]]; then
+            has_identity=true
+        fi
+        # Colon / brace-block form: `name: "..."`, `id: "..."`, `project: "..."`.
+        # YAML-ish and brace-block A2ML dialects (e.g. `Trust { name: "..." }`,
+        # `id: "tsdm-standard"`) carry the same identity semantics; only the
+        # delimiter (`:` vs `=`) differs. `id` is the brace-block spelling of an
+        # identity key.
+        if [[ "$line" =~ ^[[:space:]]*(agent[-_]id|name|project|id)[[:space:]]*: ]]; then
+            has_identity=true
+        fi
+        # Check for version field — TOML form
+        if [[ "$line" =~ ^[[:space:]]*(version|schema_version)[[:space:]]*= ]]; then
+            has_version=true
+        fi
+        # Version field — s-expression form
+        if [[ "$line" =~ ^[[:space:]]*\([[:space:]]*(version|schema_version)[[:space:]]+\" ]]; then
+            has_version=true
+        fi
+        # Version field — colon / brace-block form
+        if [[ "$line" =~ ^[[:space:]]*(version|schema_version)[[:space:]]*: ]]; then
+            has_version=true
+        fi
+    done < "$file"
+
+    # AI manifest files (0-AI-MANIFEST.a2ml, 0.1-AI-MANIFEST.a2ml, etc.)
+    # use markdown-style headers and free text, so identity check is relaxed
+    local basename
+    basename="$(basename "$file")"
+    local is_manifest=false
+    if [[ "$basename" == *"AI-MANIFEST"* ]]; then
+        is_manifest=true
+    fi
+    # Canonical typed manifests under .machine_readable/descriptiles/ — identity comes
+    # from the enclosing directory + filename, not an in-file field. Sibling
+    # files in the same directory (ECOSYSTEM.a2ml, STATE.a2ml) DO carry their
+    # own $name/project and continue to be validated normally.
+    case "$basename" in
+        AGENTIC.a2ml|META.a2ml|NEUROSYM.a2ml|PLAYBOOK.a2ml|AI.a2ml)
+            # AI.a2ml = free-text "AI Assistant Instructions" manifest, the same
+            # doc type as 0-AI-MANIFEST.a2ml but with the bare name; identity is
+            # carried by the enclosing repo/plugin dir, not an in-file field.
+            is_manifest=true
+            ;;
+        # Dockerfile-style top-level typed manifests (Intentfile, Trustfile, …)
+        # use markdown-flavoured A2ML; identity is carried by the parent repo.
+        *file.a2ml)
+            is_manifest=true
+            ;;
+    esac
+
+    # Contractile-shape A2ML files use `@directive:` syntax instead of
+    # TOML `key = value`. Trustfile.a2ml, Intentfile.a2ml, Mustfile.a2ml,
+    # Adjustfile.a2ml etc. are policy / trust / intent / abstract files
+    # whose identity is implicit in their @-prefixed directives
+    # (`@trust-level`, `@intent`, ...) rather than a TOML name/version
+    # pair. Treating them as manifest-shape produces 100% false positives —
+    # they're a different A2ML doc type. Detected by the presence of any
+    # contractile directive in the file body.
+    local is_contractile_shape=false
+    if grep -qE '^@(abstract|trust-level|trust-boundary|trust-actions|trust-deny|intent|must|adjust|end)([[:space:]]*:|$)' "$file"; then
+        is_contractile_shape=true
+    fi
+
+    # Canonical structured A2ML tree. Everything under a `.machine_readable/`
+    # directory is a typed agent-readable doc (CLADE, ANCHOR, STATE,
+    # ECOSYSTEM, bot_directives/{debt,coverage,methodology}, ai/AI,
+    # policies/*, integrations/*, …). Per the RSR convention these carry
+    # identity structurally — owning repo + path + filename — not via an
+    # in-file `name`/`agent-id`. This generalises the `.machine_readable/descriptiles/`
+    # rationale above to the whole tree: rsr-template-repo itself ships these
+    # files without an in-file identity key, so requiring one produces
+    # estate-wide false positives on every repo built from the canonical
+    # template. Files outside `.machine_readable/` are still validated.
+    local is_structural_identity=false
+    if [[ "$file" == *"/.machine_readable/"* || "$file" == "./.machine_readable/"* || "$file" == ".machine_readable/"* ]]; then
+        is_structural_identity=true
+    fi
+
+    if [[ "$has_identity" == "false" && "$is_manifest" == "false" && "$is_contractile_shape" == "false" && "$is_structural_identity" == "false" ]]; then
+        report_issue "error" "$file" 1 \
+            "Missing required identity field (agent-id, name, or project)"
+    fi
+
+    if [[ "$has_version" == "false" && "$is_manifest" == "false" && "$is_contractile_shape" == "false" && "$is_structural_identity" == "false" ]]; then
+        report_issue "warning" "$file" 1 \
+            "Missing version or schema_version field"
+    fi
+
+    # --- Check 3: Attestation block structure ---
+    # If file contains [attestation] or ## ATTESTATION, validate it has
+    # required sub-fields: proof or signature
+    local in_attestation=false
+    local attestation_line=0
+    local attestation_has_content=false
+    line_num=0
+
+    while IFS= read -r line; do
+        line_num=$((line_num + 1))
+
+        # Detect attestation section start
+        if [[ "$line" =~ ^\[attestation\] ]] || [[ "$line" =~ ^##[[:space:]]+[Aa]ttestation ]] || [[ "$line" =~ ^##[[:space:]]+ATTESTATION ]]; then
+            in_attestation=true
+            attestation_line=$line_num
+            continue
+        fi
+
+        # Detect next section (ends attestation block)
+        if [[ "$in_attestation" == "true" ]]; then
+            if [[ "$line" =~ ^\[.+\] ]] || [[ "$line" =~ ^##[[:space:]] ]]; then
+                in_attestation=false
+                continue
+            fi
+            # Check for content in attestation block
+            if [[ "$line" =~ (proof|signature|verified|hash)[[:space:]]*= ]]; then
+                attestation_has_content=true
+            fi
+        fi
+    done < "$file"
+
+    if [[ $attestation_line -gt 0 && "$attestation_has_content" == "false" ]]; then
+        report_issue "warning" "$file" "$attestation_line" \
+            "Attestation block found but missing proof/signature/hash fields"
+    fi
+
+    # --- Check 4: Section heading syntax ---
+    # Validate that [section] headings are well-formed (no unclosed brackets)
+    line_num=0
+    while IFS= read -r line; do
+        line_num=$((line_num + 1))
+        # Lines starting with [ should have a matching ]
+        if [[ "$line" =~ ^\[ && ! "$line" =~ ^\[.+\] ]]; then
+            # Exclude markdown-style links and multi-line values
+            if [[ ! "$line" =~ ^\[.*\]\( && ! "$line" =~ ^\[TODO && ! "$line" =~ ^\[YOUR ]]; then
+                report_issue "warning" "$file" "$line_num" \
+                    "Possibly malformed section heading: unclosed bracket"
+            fi
+        fi
+    done < "$file"
+}
+
+# ---------------------------------------------------------------------------
+# Main: discover and validate .a2ml files
+# ---------------------------------------------------------------------------
+
+echo "::group::A2ML Manifest Validation"
+echo "Scanning ${SCAN_PATH} for .a2ml files..."
+echo ""
+
+# Find all .a2ml files, excluding .git directory
+mapfile -t a2ml_candidates < <(find "$SCAN_PATH" -name '*.a2ml' -not -path '*/.git/*' -type f | sort)
+
+# Apply paths-ignore filter
+a2ml_files=()
+SKIPPED=0
+for _f in "${a2ml_candidates[@]}"; do
+    if path_ignored "$_f"; then
+        SKIPPED=$((SKIPPED + 1))
+        continue
+    fi
+    a2ml_files+=("$_f")
+done
+
+if [[ $SKIPPED -gt 0 ]]; then
+    echo "::notice::Skipped ${SKIPPED} file(s) matching paths-ignore"
+fi
+
+if [[ ${#a2ml_files[@]} -eq 0 ]]; then
+    echo "::notice::No .a2ml files found in ${SCAN_PATH}"
+    echo "files_scanned=0" >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true
+    echo "errors=0" >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true
+    echo "warnings=0" >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true
+    echo "::endgroup::"
+    exit 0
+fi
+
+echo "Found ${#a2ml_files[@]} .a2ml file(s)"
+echo ""
+
+for file in "${a2ml_files[@]}"; do
+    echo "  Validating: ${file}"
+    validate_a2ml "$file"
+done
+
+echo ""
+echo "────────────────────────────────────────"
+echo "Files scanned: ${FILES_SCANNED}"
+echo "Errors:        ${ERRORS}"
+echo "Warnings:      ${WARNINGS}"
+echo "Strict mode:   ${STRICT}"
+echo "────────────────────────────────────────"
+
+# Write outputs for GitHub Actions
+{
+    echo "files_scanned=${FILES_SCANNED}"
+    echo "errors=${ERRORS}"
+    echo "warnings=${WARNINGS}"
+} >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true
+
+echo "::endgroup::"
+
+# Exit with failure if errors were found
+if [[ $ERRORS -gt 0 ]]; then
+    echo "::error::A2ML validation failed with ${ERRORS} error(s)"
+    exit 1
+fi
+
+echo "A2ML validation passed."
+exit 0
diff --git a/.githooks/validate-k9.sh b/.githooks/validate-k9.sh
new file mode 100755
index 0000000..c83e290
--- /dev/null
+++ b/.githooks/validate-k9.sh
@@ -0,0 +1,357 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: MPL-2.0
+# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
+#
+# validate-k9.sh — K9 configuration file validation script
+#
+# Scans for .k9 and .k9.ncl files and validates:
+#   1. K9! magic number on line 1
+#   2. Pedigree block presence with required fields (name, version)
+#   3. Security 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
+#
+# Environment variables:
+#   INPUT_PATH   — Directory to scan (default: .)
+#   INPUT_STRICT — Promote warnings to errors (default: false)
+#
+# Exit codes:
+#   0 — All files valid (or only warnings in non-strict mode)
+#   1 — Validation errors found
+
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+SCAN_PATH="${INPUT_PATH:-.}"
+STRICT="${INPUT_STRICT:-false}"
+PATHS_IGNORE_RAW="${INPUT_PATHS_IGNORE:-}"
+GITHUB_OUTPUT_FILE="${GITHUB_OUTPUT:-/dev/null}"
+
+# Parse paths-ignore: newline-separated fragments, blank lines and # comments
+# stripped. Each fragment is a substring match against the file path. Pattern
+# adopted from hyperpolymath/hypatia#243 — content-pattern validators must
+# distinguish a target from a vendored / fixture file that legitimately
+# contains the very pattern being checked.
+PATHS_IGNORE=()
+while IFS= read -r _frag; do
+    # Strip leading and trailing whitespace (canonical bash idiom).
+    _frag="${_frag#"${_frag%%[![:space:]]*}"}"
+    _frag="${_frag%"${_frag##*[![:space:]]}"}"
+    [[ -z "$_frag" || "$_frag" == \#* ]] && continue
+    PATHS_IGNORE+=("$_frag")
+done <<< "$PATHS_IGNORE_RAW"
+
+# Returns 0 if path should be skipped (matches any ignore fragment)
+path_ignored() {
+    local p="$1" frag
+    for frag in "${PATHS_IGNORE[@]}"; do
+        [[ "$p" == *"$frag"* ]] && return 0
+    done
+    return 1
+}
+
+# Counters
+FILES_SCANNED=0
+ERRORS=0
+WARNINGS=0
+
+# Valid security levels (the leash metaphor)
+VALID_LEVELS="kennel yard hunt"
+
+# ---------------------------------------------------------------------------
+# Helper: emit GitHub annotation
+# ---------------------------------------------------------------------------
+annotate() {
+    local level="$1" file="$2" line="$3" message="$4"
+    echo "::${level} file=${file},line=${line}::${message}"
+}
+
+# ---------------------------------------------------------------------------
+# Helper: report issue (respects strict mode)
+# ---------------------------------------------------------------------------
+report_issue() {
+    local severity="$1" file="$2" line="$3" message="$4"
+
+    if [[ "$severity" == "warning" && "$STRICT" == "true" ]]; then
+        severity="error"
+    fi
+
+    annotate "$severity" "$file" "$line" "$message"
+
+    if [[ "$severity" == "error" ]]; then
+        ERRORS=$((ERRORS + 1))
+    else
+        WARNINGS=$((WARNINGS + 1))
+    fi
+}
+
+# ---------------------------------------------------------------------------
+# Helper: normalise a security level string
+# ---------------------------------------------------------------------------
+# Strips quotes, leading/trailing whitespace, Nickel enum tick prefix
+normalise_level() {
+    local raw="$1"
+    # Remove surrounding quotes, tick prefix ('Kennel -> Kennel), whitespace
+    raw="${raw#*=}"              # Remove everything before =
+    raw="${raw//\"/}"            # Remove double quotes
+    raw="${raw//\'/}"            # Remove single quotes (Nickel tick)
+    raw="${raw//,/}"             # Remove trailing commas
+    raw="${raw## }"              # Trim leading space
+    raw="${raw%% }"              # Trim trailing space
+    raw="${raw%%#*}"             # Remove inline comments
+    raw="${raw## }"              # Trim again
+    raw="${raw%% }"
+    echo "${raw,,}"             # Lowercase
+}
+
+# ---------------------------------------------------------------------------
+# Validator: check a single K9 file
+# ---------------------------------------------------------------------------
+validate_k9() {
+    local file="$1"
+    FILES_SCANNED=$((FILES_SCANNED + 1))
+
+    # --- Check 1: K9! magic number on first non-empty line ---
+    local first_content_line=""
+    local first_content_line_num=0
+    local line_num=0
+
+    while IFS= read -r line; do
+        line_num=$((line_num + 1))
+        # Skip empty lines
+        if [[ -z "${line// /}" ]]; then
+            continue
+        fi
+        first_content_line="$line"
+        first_content_line_num=$line_num
+        break
+    done < "$file"
+
+    if [[ "$first_content_line" != "K9!" ]]; then
+        report_issue "error" "$file" "$first_content_line_num" \
+            "Missing K9! magic number. First non-empty line must be exactly 'K9!'"
+    fi
+
+    # --- Check 2: SPDX header ---
+    local has_spdx=false
+    line_num=0
+    while IFS= read -r line; do
+        line_num=$((line_num + 1))
+        if [[ $line_num -gt 10 ]]; then
+            break
+        fi
+        if [[ "$line" == *"SPDX-License-Identifier"* ]]; then
+            has_spdx=true
+            break
+        fi
+    done < "$file"
+
+    if [[ "$has_spdx" == "false" ]]; then
+        report_issue "warning" "$file" 1 \
+            "Missing SPDX-License-Identifier in first 10 lines"
+    fi
+
+    # --- Check 3: Pedigree block with required fields ---
+    local has_pedigree=false
+    local has_pedigree_name=false
+    local has_pedigree_version=false
+    local has_security_level=false
+    local security_level_value=""
+    local security_level_line=0
+    local has_signature_field=false
+    local in_pedigree=false
+    local pedigree_depth=0
+
+    line_num=0
+    while IFS= read -r line; do
+        line_num=$((line_num + 1))
+
+        # Detect pedigree block start. Note: do NOT `continue` here — the
+        # `pedigree = {` line itself contains the opening brace that
+        # establishes the block. Falling through to the brace counter
+        # below makes depth start at 1, so a subsequent `security = {…},`
+        # closing brace correctly takes depth to 1 (not 0), keeping us
+        # inside the pedigree block when later fields (name/version/leash)
+        # are checked. Previously the `continue` skipped this opening
+        # brace, depth started at 0, and the first nested block's close
+        # prematurely terminated the validator's view of the pedigree —
+        # making `pedigree.metadata.name` invisible.
+        if [[ "$line" =~ ^[[:space:]]*pedigree[[:space:]]*= ]]; then
+            has_pedigree=true
+            in_pedigree=true
+            pedigree_depth=0
+            # fall through
+        fi
+
+        if [[ "$in_pedigree" == "true" ]]; then
+            # Track brace depth to know when pedigree block ends
+            local opens closes
+            opens="${line//[^\{]/}"
+            closes="${line//[^\}]/}"
+            pedigree_depth=$(( pedigree_depth + ${#opens} - ${#closes} ))
+
+            if [[ $pedigree_depth -le 0 && "$has_pedigree" == "true" ]]; then
+                # Check this final line too before leaving
+                :
+            fi
+
+            # Check for name field within pedigree.metadata or pedigree directly.
+            # Two patterns cover both multi-line and single-line pedigrees:
+            #   1. ^[[:space:]]+name[[:space:]]*= — the normal multi-line case where
+            #      `name = "..."` appears on its own indented line.
+            #   2. [[:space:]]name[[:space:]]*= — inline within a single-line
+            #      pedigree assignment such as:
+            #        pedigree = component_pedigree & { name = "foo" }
+            #      (root cause: developer-ecosystem@baab1534 — single-line form
+            #      was missed entirely because the pedigree block opened and
+            #      closed in one line, never reaching the ^[[:space:]]+ check on
+            #      a subsequent iteration.)
+            if [[ "$line" =~ ^[[:space:]]+name[[:space:]]*= ]] || \
+               [[ "$line" =~ [[:space:]]name[[:space:]]*= ]]; then
+                has_pedigree_name=true
+            fi
+
+            # Check for version field
+            if [[ "$line" =~ ^[[:space:]]+(version|schema_version)[[:space:]]*= ]] || \
+               [[ "$line" =~ [[:space:]](version|schema_version)[[:space:]]*= ]]; then
+                has_pedigree_version=true
+            fi
+
+            # Check for security level (leash field)
+            if [[ "$line" =~ ^[[:space:]]+(leash|security_level)[[:space:]]*= ]]; then
+                has_security_level=true
+                security_level_value="$(normalise_level "$line")"
+                security_level_line=$line_num
+            fi
+
+            # Check for signature fields
+            if [[ "$line" =~ ^[[:space:]]+(signature|signature_required)[[:space:]]*= ]]; then
+                has_signature_field=true
+            fi
+
+            # End of pedigree block
+            if [[ $pedigree_depth -le 0 && "$has_pedigree" == "true" && "$line" == *"}"* ]]; then
+                in_pedigree=false
+            fi
+        fi
+
+        # Also check for signature fields outside pedigree (top-level)
+        if [[ "$line" =~ ^[[:space:]]*(signature)[[:space:]]*= ]]; then
+            has_signature_field=true
+        fi
+    done < "$file"
+
+    if [[ "$has_pedigree" == "false" ]]; then
+        report_issue "error" "$file" 1 \
+            "Missing pedigree block. K9 files must contain a 'pedigree = { ... }' section"
+    else
+        if [[ "$has_pedigree_name" == "false" ]]; then
+            report_issue "error" "$file" 1 \
+                "Pedigree block missing 'name' field (in pedigree.metadata.name or pedigree.name)"
+        fi
+
+        if [[ "$has_pedigree_version" == "false" ]]; then
+            report_issue "warning" "$file" 1 \
+                "Pedigree block missing 'version' or 'schema_version' field"
+        fi
+    fi
+
+    # --- Check 4: Security level validation ---
+    if [[ "$has_security_level" == "true" ]]; then
+        local level_valid=false
+        for valid in $VALID_LEVELS; do
+            if [[ "$security_level_value" == "$valid" ]]; then
+                level_valid=true
+                break
+            fi
+        done
+
+        if [[ "$level_valid" == "false" ]]; then
+            report_issue "error" "$file" "$security_level_line" \
+                "Invalid security level '${security_level_value}'. Must be one of: kennel, yard, hunt"
+        fi
+    else
+        if [[ "$has_pedigree" == "true" ]]; then
+            report_issue "warning" "$file" 1 \
+                "No security level (leash/security_level) found in pedigree block"
+        fi
+    fi
+
+    # --- Check 5: Hunt-level signature requirement ---
+    if [[ "$security_level_value" == "hunt" && "$has_signature_field" == "false" ]]; then
+        report_issue "error" "$file" "$security_level_line" \
+            "Hunt-level K9 file must include a 'signature' or 'signature_required' field"
+    fi
+}
+
+# ---------------------------------------------------------------------------
+# Main: discover and validate K9 files
+# ---------------------------------------------------------------------------
+
+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_candidates < <(find "$SCAN_PATH" \( -name '*.k9' -o -name '*.k9.ncl' \) -not -path '*/.git/*' -type f | sort)
+
+# Apply paths-ignore filter
+k9_files=()
+SKIPPED=0
+for _f in "${k9_candidates[@]}"; do
+    if path_ignored "$_f"; then
+        SKIPPED=$((SKIPPED + 1))
+        continue
+    fi
+    k9_files+=("$_f")
+done
+
+if [[ $SKIPPED -gt 0 ]]; then
+    echo "::notice::Skipped ${SKIPPED} file(s) matching paths-ignore"
+fi
+
+if [[ ${#k9_files[@]} -eq 0 ]]; then
+    echo "::notice::No K9 files found in ${SCAN_PATH}"
+    echo "files_scanned=0" >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true
+    echo "errors=0" >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true
+    echo "warnings=0" >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true
+    echo "::endgroup::"
+    exit 0
+fi
+
+echo "Found ${#k9_files[@]} K9 file(s)"
+echo ""
+
+for file in "${k9_files[@]}"; do
+    echo "  Validating: ${file}"
+    validate_k9 "$file"
+done
+
+echo ""
+echo "────────────────────────────────────────"
+echo "Files scanned: ${FILES_SCANNED}"
+echo "Errors:        ${ERRORS}"
+echo "Warnings:      ${WARNINGS}"
+echo "Strict mode:   ${STRICT}"
+echo "────────────────────────────────────────"
+
+# Write outputs for GitHub Actions
+{
+    echo "files_scanned=${FILES_SCANNED}"
+    echo "errors=${ERRORS}"
+    echo "warnings=${WARNINGS}"
+} >> "$GITHUB_OUTPUT_FILE" 2>/dev/null || true
+
+echo "::endgroup::"
+
+# Exit with failure if errors were found
+if [[ $ERRORS -gt 0 ]]; then
+    echo "::error::K9 validation failed with ${ERRORS} error(s)"
+    exit 1
+fi
+
+echo "K9 validation passed."
+exit 0
diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml
index cf6f595..7b642b0 100644
--- a/.github/workflows/dogfood-gate.yml
+++ b/.github/workflows/dogfood-gate.yml
@@ -38,11 +38,7 @@ jobs:
 
       - name: Validate A2ML manifests
         if: steps.detect.outputs.count > 0
-        uses: hyperpolymath/a2ml-ecosystem/validate-action@aa4b836bd969df2bc58128cb8e3d20bbc88d5e79 # main
-        with:
-          path: '.'
-          strict: 'false'
-
+        run: bash .githooks/validate-a2ml.sh
       - name: Write summary
         run: |
           A2ML_COUNT="${{ steps.detect.outputs.count }}"
@@ -86,11 +82,7 @@ jobs:
 
       - name: Validate K9 contracts
         if: steps.detect.outputs.k9_count > 0
-        uses: hyperpolymath/k9-ecosystem/validate-action@89f3c2702f4f650a92aa7411502f38da06abd562 # main
-        with:
-          path: '.'
-          strict: 'false'
-
+        run: bash .githooks/validate-k9.sh
       - name: Write summary
         run: |
           K9_COUNT="${{ steps.detect.outputs.k9_count }}"
