277 lines
15 KiB
Bash
Executable File
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"
|