#!/usr/bin/env bash # Fail-closed absence gate for the supported schema-v3-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 <&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-v3-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/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-v3-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-v3-only absence gate passed"