#!/usr/bin/env bash set -euo pipefail # The ONE entry point for provenance validation. It auto-detects whether the # target is a skill directory or an agent definition file — the same rule # validate.sh uses — and runs the matching suite from lib-provenance-skill.sh or # lib-provenance-agent.sh. The Contributing-files parser both suites need is # sourced once, from lib-contributing-files.sh, instead of being embedded twice. # # The two suites have DIFFERENT exit contracts, and merging the entry point does # not merge those: # # skill mode exits 0 with output when the only findings are INFO — a check # that could not run, announced rather than skipped silently. A # caller must read exit 0 plus output as INFO-only findings. # agent mode prints nothing at all on a clean run, and exits 0 SILENTLY when # the scope walk-up finds no plugin package above the agent file. # That is a verdict about a real file, not a rejected input; # scripts/check-scope-walkup-sync.sh's fixture 6 pins it. # # Exit 2 means the argument is not auditable at all — missing, doubled, the # wrong shape, or an environment problem. It is never a finding. # --- Path splitting, with bash builtins only ------------------------------- # dirname and basename are EXTERNAL commands, and every call below happens # before the mode's python3 preflight. Using them put coreutils ahead of python3 # in the dependency order: on a PATH carrying neither, this script died at exit # 127 naming `dirname` (and, through the sourced libraries, `cat`) instead of # reaching the preflight that names python3 — the exact failure # tests/test-adr0020-contract.sh assertion 2 exists to prevent. The pre-merge # validate-provenance.sh was one self-contained file that reached its preflight # on builtins alone; these two functions, plus the `read`-based loaders in the # sourced libraries, restore that property. `cd` and `pwd` are builtins and may # stay. # # They reproduce dirname/basename semantics for the shapes this script sees: # trailing slashes are stripped, a path with no slash yields "." / itself, and # "/" yields "/". _kf_dirname() { local _p="$1" while [[ "$_p" == */ && "$_p" != "/" ]]; do _p="${_p%/}"; done if [[ "$_p" == "/" ]]; then printf '%s' "/" return 0 fi if [[ "$_p" != */* ]]; then printf '%s' "." return 0 fi _p="${_p%/*}" while [[ "$_p" == */ && "$_p" != "/" ]]; do _p="${_p%/}"; done if [[ -z "$_p" ]]; then _p="/" fi printf '%s' "$_p" } _kf_basename() { local _p="$1" while [[ "$_p" == */ && "$_p" != "/" ]]; do _p="${_p%/}"; done if [[ "$_p" == "/" ]]; then printf '%s' "/" return 0 fi printf '%s' "${_p##*/}" } # --- The target's parent directory NAME, resolved ------------------------- # The agent rule tests the NAME of the target's parent directory. Reading that # name off the argument text — `_kf_basename "$(_kf_dirname "$TARGET")"` — # returned "." for a bare `git-orchestrate.md` typed from inside .claude/agents/ # (and for `./git-orchestrate.md`), so a file that IS directly under an agents/ # directory was refused as matching neither shape, by an error message naming # that exact shape as valid. The pre-merge agent validator had no path-shape # gate and worked from any working directory. # # So the parent is resolved with the `cd` and `pwd` builtins in a subshell — # still coreutils-free, for the reason above. It resolves LOGICALLY (`pwd`, not # `pwd -P`): an agents/ directory reached through a symlink named agents/ is # still addressed as agents/, which is what the literal test always honoured. # CDPATH is cleared and cd's output discarded; see SCRIPT_DIR below. A parent # that cannot be entered — a typo'd path — falls back to the literal name, so # the neither-shape error still fires for it. _kf_parent_name() { local _dir _resolved _dir="$(_kf_dirname "$1")" if _resolved="$(CDPATH='' cd -- "$_dir" > /dev/null 2>&1 && pwd)"; then _kf_basename "$_resolved" else _kf_basename "$_dir" fi } # --- This script's own directory, and the libraries beside it ------------- # `cd` PRINTS the directory it resolved whenever CDPATH supplied it, so with # CDPATH exported and the relative invocation the flow references prescribe # (`bash scripts/.sh`), a bare `$(cd ... && pwd)` captured two lines — # and could resolve through CDPATH to an unrelated directory and source a # same-named file from there. CDPATH is cleared for the one command, `--` ends # option parsing for a directory named like a flag, and stdout is discarded so # only `pwd` is captured. if ! SCRIPT_DIR="$(CDPATH='' cd -- "$(_kf_dirname "${BASH_SOURCE[0]}")" > /dev/null 2>&1 && pwd)"; then echo "Error: cannot enter the directory this script lives in ('$(_kf_dirname "${BASH_SOURCE[0]}")')." >&2 echo " Why: the check suites are sourced from files beside this script, so without its own directory nothing can run — and reporting that as findings would pass a broken install off as a failing audit." >&2 echo " Fix: invoke the script by a path to its real location inside factory-audit/scripts/." >&2 exit 2 fi # A sourced library that is missing or unreadable used to kill the script under # `set -e` with bash's own "No such file or directory" and exit 1 — the tier the # flow references tell the auditor to surface verbatim as REAL FINDINGS. A # partial install, or a copy or symlink of this one file taken out of scripts/, # was therefore reported as a failing audit. Checked explicitly instead, and # exit 2, which the same references read as "it never ran". _kf_require_lib() { if [[ ! -f "$SCRIPT_DIR/$1" || ! -r "$SCRIPT_DIR/$1" ]]; then echo "Error: required library '$SCRIPT_DIR/$1' is missing or unreadable." >&2 echo " Why: this script ships together with the lib-*.sh files in factory-audit/scripts/ and cannot run without them; this is an install problem, not a finding about the target." >&2 echo " Fix: reinstall the factory-audit skill so its scripts/ directory is complete, and run the script from there rather than from a copy or symlink of the file alone." >&2 exit 2 fi } # Each mode's own usage text lives in that mode's library, verbatim, so usage() # needs the libraries — but `--help` must not. Sourcing them unconditionally at # the top made a missing lib-*.sh turn `--help` into exit 2, so the one command # that explains how to use the script was the one command a partial install # could not answer. validate.sh's usage() is self-contained and always works; # this restores the same property without copying the per-mode text down here # and letting it drift from the libraries that own it. When a library is gone, # the shared half of the usage still prints and the mode's half says why it # cannot. # # The two call sites below are spelled out rather than folded into one helper # taking the library as a parameter: a parameterized `.` is a non-constant # source, which is SC1090 at warning severity — the level .pre-commit-config.yaml # runs shellcheck at — and the only way to silence it, a `source=/dev/null` # directive, is a directive that resolves to nothing, which # tests/test-vale-wrap.sh part C rejects outright because a non-resolving # directive silently disarms that file's array-seeding exemption. Two literal # sources with two real directives cost a few lines and keep both gates honest. _kf_lib_readable() { [[ -f "$SCRIPT_DIR/$1" && -r "$SCRIPT_DIR/$1" ]] } _kf_usage_lib_missing() { echo "(This mode's usage lives in $1, which is missing or unreadable in" echo "$SCRIPT_DIR. Reinstall the factory-audit skill to restore it. Note that" echo "an audit cannot run in this state either — it would exit 2.)" } usage() { cat < [--base-ref=] validate-provenance.sh Validate that a skill's or an agent's sources provenance chain is complete and internally consistent. The mode is detected from the target: skill mode the target is a directory (a skill directory contains SKILL.md), or the target IS a SKILL.md file. agent mode the target is a *.agent.md file, or a *.md file whose parent directory is named 'agents' (.apm/agents, .claude/agents, .github/agents, .copilot/agents). The two modes have different checks, different exit contracts and different flags — --base-ref belongs to skill mode's check 9 and agent mode has no check 9 — so each mode's own usage follows below, verbatim. Exit codes: 0 All checks passed (or nothing to validate; in agent mode, also "not plugin scope") 1 One or more checks failed 2 Usage error, the target matches neither a skill directory nor an agent file this script can read, or a lib-*.sh beside this script is missing or unreadable An exit code of 2 is NOT a finding. SKILL.md tells the auditor to surface a non-zero exit as findings, so a usage error leaving exit 1 with nothing on stdout was indistinguishable from a clean-but-failing run. Environment and argument problems exit 2; only real findings exit 1. === skill mode === EOF if _kf_lib_readable lib-provenance-skill.sh; then # shellcheck source=lib-provenance-skill.sh . "$SCRIPT_DIR/lib-provenance-skill.sh" kyberforge_prov_skill_usage else _kf_usage_lib_missing lib-provenance-skill.sh fi cat <&2 echo "" >&2 usage >&2 exit 2 fi # Sourced only once an audit is actually going to be attempted. It sat at the # top of the file until `--help` on a partial install exited 2 instead of # printing usage; see the comment above _kf_lib_readable for the whole story. # usage() loads the two provenance libraries on its own when it needs them, so # nothing here is reached by the --help path. _kf_require_lib lib-contributing-files.sh # shellcheck source=lib-contributing-files.sh . "$SCRIPT_DIR/lib-contributing-files.sh" _kf_require_lib lib-provenance-skill.sh # shellcheck source=lib-provenance-skill.sh . "$SCRIPT_DIR/lib-provenance-skill.sh" _kf_require_lib lib-provenance-agent.sh # shellcheck source=lib-provenance-agent.sh . "$SCRIPT_DIR/lib-provenance-agent.sh" # --- Detect the mode ------------------------------------------------------- # The first non-flag argument decides the mode. Only the mode is decided here: # the argument COUNT, the flag rules and every precondition belong to the mode's # own suite and are applied there, unchanged, over the original "$@". So a # --base-ref handed to an agent target is still an extra argument and is still # rejected, and a second positional is still rejected by whichever mode it # reaches. # _saw_positional is tracked separately because an EMPTY positional and NO # positional are different mistakes with different fixes, and `-z "$TARGET"` # alone cannot tell them apart: `validate-provenance.sh ""` — an unquoted shell # variable that expanded to nothing, the usual way this happens — was reported # as "only flags were given", which is false and sends the reader looking for a # flag they did not type instead of at the variable that came up empty. TARGET="" _saw_positional=false for _arg in "$@"; do case "$_arg" in --base-ref=*) ;; *) TARGET="$_arg"; _saw_positional=true; break ;; esac done if [[ "$_saw_positional" == false ]]; then echo "Error: a skill directory or an agent file is required." >&2 echo " Why: only flags were given, so there is no target to detect a mode from." >&2 echo " Fix: pass the skill directory, or the agent file, as a positional argument." >&2 exit 2 fi if [[ -z "$TARGET" ]]; then echo "Error: the target argument is an empty string." >&2 echo " Why: a positional argument was passed, but it is empty, so there is no path to detect a mode from — usually an unquoted or unset shell variable expanding to nothing at the call site, not a missing argument." >&2 echo " Fix: check the variable that supplies the target, and pass the skill directory, or the agent file, as a non-empty positional argument." >&2 exit 2 fi TARGET_BASE="$(_kf_basename "$TARGET")" TARGET_PARENT="$(_kf_parent_name "$TARGET")" if [[ -d "$TARGET" ]]; then if [[ -f "$TARGET/SKILL.md" ]]; then MODE=skill else echo "Error: '$TARGET' is a directory with no SKILL.md in it." >&2 echo " Why: a skill directory is identified by its SKILL.md, and an agent target is a file, never a directory — so this path matches neither mode and guessing one would run the wrong provenance checks." >&2 echo " Fix: pass the skill directory that holds SKILL.md, or an agent file (.agent.md, or a .md file under an agents/ directory)." >&2 exit 2 fi elif [[ "$TARGET_BASE" == "SKILL.md" ]]; then MODE=skill elif [[ "$TARGET_BASE" == *.agent.md ]]; then MODE=agent elif [[ "$TARGET_BASE" == *.md && "$TARGET_PARENT" == "agents" ]]; then MODE=agent else echo "Error: '$TARGET' matches neither a skill directory nor an agent file." >&2 echo " Why: skill mode needs a directory containing SKILL.md (or the SKILL.md itself); agent mode needs a .agent.md file, or a .md file directly under an agents/ directory (.apm/agents, .claude/agents, .github/agents, .copilot/agents). Picking a mode anyway would report a silent pass on a typo'd target, which is the failure both suites' preconditions exist to prevent." >&2 echo " Fix: pass one of those two shapes." >&2 exit 2 fi # In skill mode a SKILL.md target names its directory. The token is replaced in # place rather than assumed to be $1, because --base-ref may precede it; the # suite's own preconditions then apply to that directory, exactly as before the # merge. if [[ "$MODE" == skill && "$TARGET_BASE" == "SKILL.md" && ! -d "$TARGET" ]]; then declare -a _rewritten=() _replaced=false for _arg in "$@"; do if [[ "$_replaced" == false && "$_arg" == "$TARGET" ]]; then _rewritten+=("$(_kf_dirname "$TARGET")") _replaced=true else _rewritten+=("$_arg") fi done # Guarded expansion: bash 3.2 under `set -u` aborts on "${arr[@]}" when the # array is empty, and the loop above cannot prove non-emptiness to a static # scan. tests/test-vale-wrap.sh enforces bash-3.2 portability across this tree. set -- ${_rewritten[@]+"${_rewritten[@]}"} fi # Called UNTESTED, on purpose: `f || RC=$?` would disable errexit for the whole # function body. Each run function stashes its findings code in # KYBERFORGE_PROV_RC and returns 0; its error paths exit directly. KYBERFORGE_PROV_RC=0 case "$MODE" in skill) kyberforge_prov_skill_run "$@" ;; agent) kyberforge_prov_agent_run "$@" ;; esac RC="$KYBERFORGE_PROV_RC" exit "$RC"