Files
ThothII/scripts/verify-schema-v3-only.sh

277 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
# Fail-closed absence gate for the supported schema-v4-only workspace runtime.
set -euo pipefail
shopt -s nocasematch
script_root="$(cd "$(dirname "$0")/.." && pwd -P)"
root_argument="$script_root"
root_was_selected=0
runtime_only=0
check_dist=0
bootstrap_trust_only=0
usage() {
cat >&2 <<EOF
usage: $0 [--bootstrap-trust-only [--root REPOSITORY]]
$0 [--runtime-only [--root REPOSITORY] [--check-dist]]
Default mode trusts the canonical Git checkout, runs a clean backend build, and
then validates generated output and documentation. Runtime-only uses the trusted
canonical Node installation, dependencies, verifier, and built
backend/dist/workspaces/schema.js; --root is only for isolated Git fixtures.
--check-dist makes an isolated runtime fixture provide backend/dist/schema.js and
server.js and checks it for stale migrators. Full mode never accepts overrides.
Expandable deployment blocks are trusted only by repository-relative path plus the
SHA-256 of their exact raw opener/body/closer bytes in the Node verifier. This is
an exact-content trust exception with rationale metadata, not semantic proof.
Release trust anchor and order (durable entry point):
Git index/filesystem trust is established by --bootstrap-trust-only before any
checkout-controlled helper or npm lifecycle can run. CI supplies a clean Git
checkout and performs an inline clean-checkout assertion before the wrapper.
scripts/verify-schema-v3-only-release.sh then runs npm ci --ignore-scripts from the trusted
backend/package-lock.json; clean build; npm Node verifier test; Bash regression;
and the full schema-v4-only gate, which repeats trust checks.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--root) [[ $# -ge 2 ]] || { usage; exit 2; }; root_argument="$2"; root_was_selected=1; shift 2 ;;
--runtime-only) runtime_only=1; shift ;;
--check-dist) check_dist=1; shift ;;
--bootstrap-trust-only) bootstrap_trust_only=1; shift ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
esac
done
[[ $root_was_selected -eq 0 || $runtime_only -eq 1 || $bootstrap_trust_only -eq 1 ]] || { echo "--root is available only with --runtime-only or --bootstrap-trust-only" >&2; exit 2; }
[[ $runtime_only -eq 0 || $bootstrap_trust_only -eq 0 ]] || { echo "--runtime-only and --bootstrap-trust-only are mutually exclusive" >&2; exit 2; }
[[ $check_dist -eq 0 || $runtime_only -eq 1 ]] || { echo "--check-dist is available only with --runtime-only" >&2; exit 2; }
if ! root="$(cd "$root_argument" 2>/dev/null && pwd -P)"; then
printf 'repository root is not accessible: %q\n' "$root_argument" >&2
exit 2
fi
[[ $runtime_only -eq 1 || $bootstrap_trust_only -eq 1 || "$root" == "$script_root" ]] || { echo "full mode is restricted to the canonical repository" >&2; exit 2; }
tmp="$(mktemp -d "${TMPDIR:-/tmp}/thoth-v4-gate.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
if git_top="$(git -C "$root" rev-parse --show-toplevel 2>"$tmp/rev-parse")"; then :; else
status=$?; printf 'Git repository discovery failed for %q\n' "$root" >&2; cat "$tmp/rev-parse" >&2; exit "$status"
fi
canonical_git_top="$(cd "$git_top" && pwd -P)"
[[ "$canonical_git_top" == "$root" ]] || { printf '%s\n' "--root must name the canonical Git root" >&2; exit 2; }
for npmrc in .npmrc backend/.npmrc; do
if [[ -e "$root/$npmrc" || -L "$root/$npmrc" ]]; then
printf 'npm configuration node forbidden: %q\n' "$npmrc" >&2
exit 1
fi
done
policy_roots=(backend/src frontend/src backend/scripts scripts)
trust_roots=(backend/src frontend/src backend/scripts scripts deploy/workspaces)
print_path() { printf '%q' "$1"; }
fail_path() { local message="$1" path="$2"; printf '%s: ' "$message" >&2; print_path "$path" >&2; printf '\n' >&2; return 1; }
forbidden_module_stems='deprecated-v2-descriptor|migrate-legacy|migrate-v2-qdrant'
is_deleted_basename() {
[[ "${1##*/}" =~ ^($forbidden_module_stems)(\..*)?$ ]]
}
# Exact path + category exceptions only. They remain fully subject to trust checks.
is_allowed_match() {
local category="$1" path="$2"
case "$category:$path" in
prescribed-symbol:scripts/verify-schema-v3-only.sh|prescribed-symbol:scripts/test-verify-schema-v3-only.sh|prescribed-symbol:backend/scripts/verify-workspace-descriptor-files.mjs|prescribed-symbol:backend/scripts/verify-workspace-descriptor-files.test.mjs|prescribed-symbol:backend/scripts/revision-state-policy.mjs|prescribed-symbol:backend/scripts/revision-state-policy.test.mjs|prescribed-symbol:backend/scripts/bash-heredoc.mjs|prescribed-symbol:backend/scripts/revision_state_policy.py|prescribed-symbol:backend/scripts/test_revision_state_policy.py) return 0 ;;
legacy-workspace:scripts/verify-schema-v3-only.sh|legacy-workspace:scripts/test-verify-schema-v3-only.sh|legacy-workspace:backend/scripts/verify-workspace-descriptor-files.mjs|legacy-workspace:backend/scripts/verify-workspace-descriptor-files.test.mjs|legacy-workspace:backend/scripts/revision-state-policy.mjs|legacy-workspace:backend/scripts/revision-state-policy.test.mjs|legacy-workspace:backend/scripts/bash-heredoc.mjs|legacy-workspace:backend/scripts/revision_state_policy.py|legacy-workspace:backend/scripts/test_revision_state_policy.py) return 0 ;;
migration-marker:scripts/verify-schema-v3-only.sh|migration-marker:scripts/test-verify-schema-v3-only.sh|migration-marker:backend/scripts/verify-workspace-descriptor-files.mjs|migration-marker:backend/scripts/verify-workspace-descriptor-files.test.mjs|migration-marker:backend/scripts/revision-state-policy.mjs|migration-marker:backend/scripts/revision-state-policy.test.mjs|migration-marker:backend/scripts/bash-heredoc.mjs|migration-marker:backend/scripts/revision_state_policy.py|migration-marker:backend/scripts/test_revision_state_policy.py) return 0 ;;
migration-marker:scripts/workspace_descriptor_doc_contract.py|migration-marker:scripts/test_workspace_descriptor_doc_contract.py) return 0 ;;
migration-marker:backend/src/workspaces/schema.ts) return 0 ;;
migration-marker:backend/scripts/clean-dist.test.mjs) return 0 ;;
*) return 1 ;;
esac
}
# Common policy is defined once and scanned over every policy root. Revision-state is the sole layer.
prescribed_symbol_forbidden='WorkspaceV1|WorkspaceV2|DeprecatedV2Descriptor|LegacyMigrationResult|LegacyMigrationOptions|WorkspaceV2MigrationInput|migrateLegacyWorkspace|writeMigratedWorkspace|migrateWorkspaceV1ToV2|migrateWorkspaceV2ToV3'
legacy_workspace_forbidden='LegacyWorkspace'
migration_marker_forbidden="migration_required|($forbidden_module_stems)"
require_category_absent() {
local category="$1" sensitivity="$2" pattern="$3" output="$tmp/grep-$1" status path
if [[ "$sensitivity" == insensitive ]]; then
if git -C "$root" grep -z -l -I -i -E -e "$pattern" -- "${policy_roots[@]}" >"$output" 2>"$output.err"; then status=0; else status=$?; fi
else
if git -C "$root" grep -z -l -I -E -e "$pattern" -- "${policy_roots[@]}" >"$output" 2>"$output.err"; then status=0; else status=$?; fi
fi
case "$status" in
0)
while IFS= read -r -d '' path; do
is_allowed_match "$category" "$path" && continue
printf 'forbidden %s match: ' "$category" >&2; print_path "$path" >&2; printf '\n' >&2
return 1
done <"$output"
;;
1) : ;;
*) printf 'scanner failure (%s): %s\n' "$status" "$category" >&2; cat "$output.err" >&2; return "$status" ;;
esac
}
# One batched index inventory validates modes and working-tree presence for all tracked paths.
index_entries="$tmp/index"
if git -C "$root" ls-files -s -z -- "${trust_roots[@]}" >"$index_entries" 2>"$tmp/index.err"; then :; else
status=$?; echo "tracked index scan failed ($status)" >&2; cat "$tmp/index.err" >&2; exit "$status"
fi
tracked_paths="$tmp/tracked"
: >"$tracked_paths"
while IFS= read -r -d '' record; do
metadata="${record%%$'\t'*}"; path="${record#*$'\t'}"; mode="${metadata%% *}"
case "$mode" in 100*) ;; 120000) fail_path "tracked symlink forbidden" "$path"; exit 1 ;; *) fail_path "non-regular tracked entry forbidden" "$path"; exit 1 ;; esac
printf '%s\0' "$path" >>"$tracked_paths"
[[ -f "$root/$path" && ! -L "$root/$path" ]] || { fail_path "tracked file missing or unsafe" "$path"; exit 1; }
is_deleted_basename "$path" && { fail_path "deleted source basename remains tracked" "$path"; exit 1; }
done <"$index_entries"
# Filesystem node trust has no test/fixture exclusions.
filesystem="$tmp/filesystem"
if find "${trust_roots[@]/#/$root/}" -mindepth 1 -print0 >"$filesystem" 2>"$tmp/find.err"; then :; else
status=$?; echo "filesystem trust scan failed ($status)" >&2; cat "$tmp/find.err" >&2; exit "$status"
fi
while IFS= read -r -d '' absolute; do
path="${absolute#"$root/"}"
[[ ! -L "$absolute" ]] || { fail_path "symlink forbidden in trusted root" "$path"; exit 1; }
[[ -d "$absolute" || -f "$absolute" ]] || { fail_path "non-directory/non-regular node forbidden in trusted root" "$path"; exit 1; }
is_deleted_basename "$path" && { fail_path "deleted source basename remains on filesystem" "$path"; exit 1; }
done <"$filesystem"
reject_name_list() {
local label="$1" file="$2" path
while IFS= read -r -d '' path; do fail_path "$label" "$path"; return 1; done <"$file"
}
for spec in "untracked:--others --exclude-standard" "ignored:--others --ignored --exclude-standard"; do
label="${spec%%:*}"; options="${spec#*:}"; output="$tmp/$label"
# shellcheck disable=SC2086
if git -C "$root" ls-files -z $options -- "${trust_roots[@]}" >"$output" 2>"$output.err"; then :; else
status=$?; echo "$label scan failed ($status)" >&2; cat "$output.err" >&2; exit "$status"
fi
reject_name_list "$label file in trusted root" "$output" || exit 1
done
for mode in worktree cached; do
output="$tmp/dirty-$mode"
if [[ "$mode" == cached ]]; then command=(git -C "$root" diff --cached --name-only -z --); else command=(git -C "$root" diff --name-only -z --); fi
if "${command[@]}" "${trust_roots[@]}" >"$output" 2>"$output.err"; then :; else
status=$?; echo "$mode dirty scan failed ($status)" >&2; cat "$output.err" >&2; exit "$status"
fi
reject_name_list "modified trusted file ($mode)" "$output" || exit 1
done
require_trusted_files_at() {
local repository="$1"; shift
local listing="$tmp/explicit-$RANDOM" record metadata path mode expected
if git -C "$repository" ls-files -s -z -- "$@" >"$listing" 2>"$listing.err"; then :; else
status=$?; echo "explicit trust index scan failed ($status)" >&2; cat "$listing.err" >&2; return "$status"
fi
: >"$listing.paths"
while IFS= read -r -d '' record; do
metadata="${record%%$'\t'*}"; path="${record#*$'\t'}"; mode="${metadata%% *}"
case "$mode" in 100*) ;; *) fail_path "required file is not regular in index" "$path"; return 1 ;; esac
printf '%s\n' "$path" >>"$listing.paths"
done <"$listing"
for expected in "$@"; do
[[ -f "$repository/$expected" && ! -L "$repository/$expected" ]] || { fail_path "required trusted file missing or unsafe" "$expected"; return 1; }
grep -Fqx -- "$expected" "$listing.paths" || { fail_path "required file is not tracked" "$expected"; return 1; }
done
git -C "$repository" diff --quiet -- "$@" || { echo "required trust files are dirty" >&2; return 1; }
git -C "$repository" diff --quiet --cached -- "$@" || { echo "required trust files are staged dirty" >&2; return 1; }
}
# Bootstrap uses only Git/filesystem primitives. It must precede every npm or
# checkout-controlled helper in the durable release wrapper.
bootstrap_files=(
backend/package.json backend/package-lock.json
backend/scripts/verify-workspace-descriptor-files.mjs
backend/scripts/verify-workspace-descriptor-files.test.mjs
backend/scripts/revision-state-policy.mjs
backend/scripts/revision-state-policy.test.mjs
backend/scripts/bash-heredoc.mjs
backend/scripts/revision_state_policy.py
backend/scripts/test_revision_state_policy.py
scripts/verify-schema-v3-only.sh scripts/test-verify-schema-v3-only.sh
scripts/verify-schema-v3-only-release.sh .github/workflows/deployment.yml
scripts/workspace_descriptor_doc_contract.py
)
if [[ $bootstrap_trust_only -eq 1 ]]; then
require_trusted_files_at "$root" "${bootstrap_files[@]}"
echo "schema-v4-only bootstrap trust passed"
exit 0
fi
# Runtime fixtures execute only canonical trusted verifier code and dependencies.
require_trusted_files_at "$script_root" \
backend/scripts/verify-workspace-descriptor-files.mjs \
backend/scripts/revision-state-policy.mjs \
backend/scripts/bash-heredoc.mjs \
backend/scripts/revision_state_policy.py \
backend/package.json backend/package-lock.json \
scripts/verify-schema-v3-only.sh
workspace_verifier="$script_root/backend/scripts/verify-workspace-descriptor-files.mjs"
workspace_schema="$script_root/backend/dist/workspaces/schema.js"
if [[ $runtime_only -eq 0 ]]; then
require_trusted_files_at "$root" \
scripts/verify-schema-v3-only-release.sh .github/workflows/deployment.yml
(cd "$root/backend" && npm run build)
fi
[[ -f "$workspace_schema" && ! -L "$workspace_schema" ]] || { echo "trusted compiled schema module is missing or unsafe: backend/dist/workspaces/schema.js" >&2; exit 1; }
workspace_manifest="$tmp/workspace-manifest"
: >"$workspace_manifest"
while IFS= read -r -d '' path; do
case "$path" in
backend/src/*|frontend/src/*|backend/scripts/*|scripts/*) printf '%s\0%s\0' policy_text "$path" >>"$workspace_manifest" ;;
esac
kind=""
case "$path" in
deploy/workspaces/server-sessions.yaml.example) ;;
deploy/workspaces/*.yaml|deploy/workspaces/*.yml|deploy/workspaces/*.yaml.example|deploy/workspaces/*.yml.example|scripts/fixtures/workspace-registry-*.yaml|scripts/fixtures/workspace-registry-*.yml|scripts/fixtures/*/workspace-registry-*.yaml|scripts/fixtures/*/workspace-registry-*.yml) kind=workspace_descriptor ;;
scripts/*.sh|scripts/*.ps1)
case "$path" in scripts/verify-schema-v3-only.sh|scripts/test-verify-schema-v3-only.sh) ;; *) kind=deployment_script ;; esac
;;
esac
[[ -z "$kind" ]] || printf '%s\0%s\0' "$kind" "$path" >>"$workspace_manifest"
done <"$tracked_paths"
node "$workspace_verifier" --root "$root" --manifest "$workspace_manifest"
require_category_absent prescribed-symbol insensitive "$prescribed_symbol_forbidden"
require_category_absent legacy-workspace sensitive "$legacy_workspace_forbidden"
require_category_absent migration-marker insensitive "$migration_marker_forbidden"
check_dist_tree() {
local dist_root="$1" entries="$tmp/dist" absolute path
[[ -f "$dist_root/backend/dist/workspaces/schema.js" && ! -L "$dist_root/backend/dist/workspaces/schema.js" ]] || { echo "expected compiled schema module is missing or unsafe: backend/dist/workspaces/schema.js" >&2; return 1; }
[[ -f "$dist_root/backend/dist/server.js" && ! -L "$dist_root/backend/dist/server.js" ]] || { echo "expected compiled backend output is missing or unsafe: backend/dist/server.js" >&2; return 1; }
find "$dist_root/backend/dist" -mindepth 1 -print0 >"$entries"
while IFS= read -r -d '' absolute; do
path="${absolute#"$dist_root/"}"
if is_deleted_basename "$path"; then
fail_path "stale compiled workspace migrator output exists" "$path"
return 1
fi
done <"$entries"
}
[[ $runtime_only -eq 1 && $check_dist -eq 0 ]] || check_dist_tree "$root"
if [[ $runtime_only -eq 0 ]]; then
require_trusted_files_at "$root" \
scripts/workspace_descriptor_doc_contract.py README.md PROJECT_STATE.md \
docs/install/first-start.md docs/operations/workspaces.md
"$root/scripts/workspace_descriptor_doc_contract.py" \
--document "$root/README.md" \
--document "$root/docs/operations/workspaces.md"
fi
echo "schema-v4-only absence gate passed"