1075 lines
55 KiB
Bash
Executable File
1075 lines
55 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Regression test for copyable installation examples and secret-path validation.
|
|
set -euo pipefail
|
|
|
|
root="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
output="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs-test.XXXXXX")"
|
|
verifier_functions="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs-functions.XXXXXX")"
|
|
negative_root="$(mktemp -d "${TMPDIR:-/tmp}/thoth-install-docs-negative.XXXXXX")"
|
|
trap 'rm -f "$output" "$verifier_functions"; rm -rf "$negative_root"' EXIT HUP INT TERM
|
|
|
|
"$root/scripts/verify-workspace-install-docs.sh" --fixtures-only >"$output"
|
|
|
|
for fixture in \
|
|
"internal semantic infrastructure documentation contract" \
|
|
"read-only workspace repository and encrypted runtime-secret contract" \
|
|
"workspace Evidence documentation contract" \
|
|
"local installation guide contract" \
|
|
"source update fail-closed semantics" \
|
|
"Windows line-ending recovery guide contract" \
|
|
"Pi management guide contract" \
|
|
"server installation guide contract" \
|
|
"Nginx reverse-proxy guide contract" \
|
|
"Caddy reverse-proxy guide contract" \
|
|
"local installation example rendered from path with spaces" \
|
|
"server installation example rendered from path with spaces" \
|
|
"server pinned migration image fixture" \
|
|
"server backup checksum root-only fixture" \
|
|
"local manual canonical base+override references" \
|
|
"server manual canonical base+override references" \
|
|
"canonical local base+override fixture" \
|
|
"canonical server base+override fixture" \
|
|
"relative secret-source fixture rejected" \
|
|
"CRLF recovery rewrites bytes and preserves mode-120000 symlinks"; do
|
|
grep -Fqx "$fixture passed" "$output" >/dev/null || {
|
|
echo "missing fixture verification: $fixture" >&2
|
|
cat "$output" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
|
|
grep -Fq '## Internal Qdrant + Ollama semantic infrastructure' "$root/PROJECT_STATE.md" || {
|
|
echo "PROJECT_STATE.md does not record the internal Qdrant/Ollama snapshot" >&2
|
|
exit 1
|
|
}
|
|
grep -Fq 'Qdrant and Ollama are internal Compose services' "$root/AGENTS.md" || {
|
|
echo "AGENTS.md does not record the stable internal semantic-service guidance" >&2
|
|
exit 1
|
|
}
|
|
grep -Fq 'Do not add vector or embedding endpoint credentials to the bundle.' \
|
|
"$root/deploy/secrets/README.md" || {
|
|
echo "secret bundle guide still permits vector/embedding runtime secrets" >&2
|
|
exit 1
|
|
}
|
|
legacy_pg_vector='engine: pg''vector'
|
|
legacy_ollama='provider: ollama''_compatible'
|
|
legacy_vector_binding='THT_WS_<NAMESPACE>_''VECTOR_TRANSPORT'
|
|
legacy_embedding_binding='THT_WS_<NAMESPACE>_''EMBEDDING_BASE_URL'
|
|
if rg -n "${legacy_pg_vector}|${legacy_ollama}|${legacy_vector_binding}|${legacy_embedding_binding}" \
|
|
"$root/docs/workspace-diagnostic-protocol.md"; then
|
|
echo "workspace diagnostic protocol still documents external vector or embedding contracts" >&2
|
|
exit 1
|
|
fi
|
|
|
|
server_guide="$root/docs/install/server.md"
|
|
grep -Fq 'scripts/prepare-server-pi-state.sh /srv/thothii/pi-state 10001 10001' "$server_guide" || {
|
|
echo "server guide does not initialize nested Pi-state targets before Compose" >&2
|
|
exit 1
|
|
}
|
|
grep -Eq '^sudo install -d -o 10001 -g thothii-ops -m 2750 /srv/thothii$' "$server_guide" || {
|
|
echo "server operations guide does not set the parent traversal boundary" >&2
|
|
exit 1
|
|
}
|
|
for required in \
|
|
'thothii-ops' \
|
|
'THT_BACKUP_ROOT=/srv/thothii-backups' \
|
|
'sessions migrate --yes' \
|
|
'"pending":[]' \
|
|
'"drifted":[]' \
|
|
'remove --yes' \
|
|
'sha256sum --check SHA256SUMS' \
|
|
'DOCKER-USER' \
|
|
'iptables -I INPUT' \
|
|
'com.docker.network.bridge.name'; do
|
|
grep -Fq -- "$required" "$server_guide" || {
|
|
echo "server operations guide lacks executable contract: $required" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
grep -Fq '"$THT_BIN" --help' "$server_guide" || {
|
|
echo "server guide lacks plain tht --help" >&2
|
|
exit 1
|
|
}
|
|
if grep -Fq '"$THT_BIN" --installation "$INSTALLATION" --help' "$server_guide"; then
|
|
echo "server guide still uses installation-scoped --help" >&2
|
|
exit 1
|
|
fi
|
|
|
|
for manual in "$root/docs/install/local-workspace-registry.md"; do
|
|
grep -Fq 'export THT_SOURCE_ROOT=/absolute/path/to/ThothII' "$manual" || {
|
|
echo "installation manual does not publish a self-contained THT_SOURCE_ROOT export: $manual" >&2
|
|
exit 1
|
|
}
|
|
if rg -n 'source[[:space:]]+\.env' "$manual"; then
|
|
echo "installation manual unsafely imports operator .env: $manual" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
grep -Fq 'THT_BIN=/srv/thothii/operator/tht' \
|
|
"$root/docs/install/server-workspace-registry.md" || {
|
|
echo "server installation manual does not use the installation-aware operator CLI" >&2
|
|
exit 1
|
|
}
|
|
grep -Fq 'INSTALLATION=/srv/thothii/operator/thothii-installation.yaml' \
|
|
"$root/docs/install/server-workspace-registry.md" || {
|
|
echo "server installation manual does not identify the server installation descriptor" >&2
|
|
exit 1
|
|
}
|
|
|
|
if rg -n 'local-compose\.workspace-registry|server-compose\.workspace-registry|connector-secrets\.workspace-registry|docker compose' \
|
|
"$root/docs/install/local-workspace-registry.md" \
|
|
"$root/docs/install/server-workspace-registry.md"; then
|
|
echo "installation manuals still document a bypassed Compose or copied connector override path" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Load only the verifier's function definitions so each deliberately unsafe guide can be checked
|
|
# in isolation without invoking Docker-backed Compose fixtures.
|
|
sed '/^case "\$mode" in/,$d' "$root/scripts/verify-workspace-install-docs.sh" >"$verifier_functions"
|
|
# shellcheck source=/dev/null
|
|
source "$verifier_functions"
|
|
|
|
PYTHONDONTWRITEBYTECODE=1 python3 "$root/scripts/test_workspace_descriptor_doc_contract.py"
|
|
|
|
project_topology_fixture="$negative_root/project-topology-contradiction.md"
|
|
python3 - "$root/PROJECT_STATE.md" "$project_topology_fixture" <<'PY'
|
|
import pathlib, sys
|
|
source = pathlib.Path(sys.argv[1]).read_text()
|
|
marker = source.index("# Historical archive")
|
|
contradiction = (
|
|
"The supported Compose stack is exactly `frontend` plus `core`.\n"
|
|
"DWH, vector DB, embedding, LLM, and reverse-proxy services are external endpoints.\n\n"
|
|
)
|
|
pathlib.Path(sys.argv[2]).write_text(source[:marker] + contradiction + source[marker:])
|
|
PY
|
|
project_topology_output="$negative_root/project-topology-output"
|
|
set +e
|
|
verify_project_state_current_contract "$project_topology_fixture" contradictory-project-topology \
|
|
>"$project_topology_output" 2>&1
|
|
project_topology_status=$?
|
|
set -e
|
|
if [[ $project_topology_status -eq 0 ]] || \
|
|
! grep -Fq "contradictory active text" "$project_topology_output"; then
|
|
echo "contradictory current PROJECT_STATE topology was not rejected" >&2
|
|
cat "$project_topology_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
workspace_fixture="$negative_root/workspace-invalid.yaml"
|
|
python3 - "$root/deploy/workspaces/example.yaml" "$workspace_fixture" <<'PY'
|
|
import pathlib, sys, yaml
|
|
doc = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text())
|
|
doc["semantic_index"]["embedding"]["dimensions"] = 768
|
|
pathlib.Path(sys.argv[2]).write_text(yaml.safe_dump(doc, sort_keys=False))
|
|
PY
|
|
workspace_output="$negative_root/workspace-output"
|
|
set +e
|
|
verify_workspace_descriptor_semantic_contract "$workspace_fixture" invalid-workspace >"$workspace_output" 2>&1
|
|
workspace_status=$?
|
|
set -e
|
|
if [[ $workspace_status -eq 0 ]] || ! grep -Fq "embedding dimensions must be 1024" "$workspace_output"; then
|
|
echo "semantic workspace fixture was not rejected correctly" >&2
|
|
cat "$workspace_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
local_manual_paraphrase="$negative_root/local-manual-paraphrase.md"
|
|
cp "$root/docs/install/local-workspace-registry.md" "$local_manual_paraphrase"
|
|
python3 - "$local_manual_paraphrase" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
text = text.replace(
|
|
"| Workspace semantic index | Each workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and memory records share that one collection and are separated by the `kind` payload. |",
|
|
"| Workspace semantic index | A workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and Memory remain together in that collection and are still separated by payload `kind`. |",
|
|
)
|
|
path.write_text(text)
|
|
PY
|
|
semantic_index_spec="$(semantic_index_relationship_spec)"
|
|
verify_markdown_table_relationships "$local_manual_paraphrase" "local manual paraphrase" "Semantic index ownership contract" "$semantic_index_spec" >/dev/null
|
|
|
|
production_paraphrase_root="$negative_root/production-paraphrase-root"
|
|
mkdir -p "$production_paraphrase_root"
|
|
rsync -a \
|
|
--exclude '.git' \
|
|
--exclude '.pytest_cache' \
|
|
--exclude 'node_modules' \
|
|
--exclude 'backend/node_modules' \
|
|
--exclude 'frontend/node_modules' \
|
|
--exclude 'harness/.venv' \
|
|
"$root/" "$production_paraphrase_root/"
|
|
python3 - "$production_paraphrase_root/docs/install/local-workspace-registry.md" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
text = text.replace(
|
|
"| Workspace semantic index | Each workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and memory records share that one collection and are separated by the `kind` payload. |",
|
|
"| Workspace semantic index | A workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and Memory remain together in that collection and are still separated by payload `kind`. |",
|
|
)
|
|
path.write_text(text)
|
|
PY
|
|
set +e
|
|
"$production_paraphrase_root/scripts/verify-workspace-install-docs.sh" --fixtures-only \
|
|
>"$workspace_output" 2>&1
|
|
workspace_status=$?
|
|
set -e
|
|
if [[ $workspace_status -ne 0 ]]; then
|
|
echo "production verifier rejected the accepted semantic-index paraphrase" >&2
|
|
cat "$workspace_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
local_manual_missing="$negative_root/local-manual-missing.md"
|
|
cp "$root/docs/install/local-workspace-registry.md" "$local_manual_missing"
|
|
python3 - "$local_manual_missing" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
text = text.replace(
|
|
"| Workspace semantic index | Each workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and memory records share that one collection and are separated by the `kind` payload. |\n",
|
|
"",
|
|
)
|
|
path.write_text(text)
|
|
PY
|
|
set +e
|
|
verify_markdown_table_relationships "$local_manual_missing" "local manual missing ownership" "Semantic index ownership contract" "$semantic_index_spec" >"$workspace_output" 2>&1
|
|
workspace_status=$?
|
|
set -e
|
|
if [[ $workspace_status -eq 0 ]]; then
|
|
echo "ownership omission fixture was not rejected correctly" >&2
|
|
cat "$workspace_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
local_manual_scattered="$negative_root/local-manual-scattered.md"
|
|
cp "$root/docs/install/local-workspace-registry.md" "$local_manual_scattered"
|
|
python3 - "$local_manual_scattered" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
text = text.replace(
|
|
"| Workspace semantic index | Each workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and memory records share that one collection and are separated by the `kind` payload. |\n",
|
|
"",
|
|
)
|
|
text += "\nWorkspace. Qdrant. Collection. Schema. Evidence. Memory. Payload kind.\n"
|
|
path.write_text(text)
|
|
PY
|
|
set +e
|
|
verify_markdown_table_relationships "$local_manual_scattered" "local manual scattered ownership" "Semantic index ownership contract" "$semantic_index_spec" >"$workspace_output" 2>&1
|
|
workspace_status=$?
|
|
set -e
|
|
if [[ $workspace_status -eq 0 ]]; then
|
|
echo "scattered ownership tokens fixture was not rejected correctly" >&2
|
|
cat "$workspace_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
compact_paraphrase="$negative_root/compact-paraphrase.md"
|
|
cp "$root/docs/installazione-docker-4-contesti.md" "$compact_paraphrase"
|
|
python3 - "$compact_paraphrase" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
text = text.replace("| DWH | Esterno | Endpoint esterno configurato dall'installazione. |", "| DWH | Esterno | Endpoint esterno deciso dall'installazione. |")
|
|
text = text.replace("| LLM | Esterno | Endpoint o policy esterna all'infrastruttura semantica interna. |", "| LLM | Esterno | Endpoint o policy che resta esterna all'infrastruttura semantica interna. |")
|
|
text = text.replace("| Qdrant | Interno | Servizio Compose interno obbligatorio con volume persistente `qdrant-data`. |", "| Qdrant | Interno | Servizio Compose interno obbligatorio con il volume persistente `qdrant-data`. |")
|
|
text = text.replace("| Ollama embedding | Interno | Servizio Compose interno obbligatorio per `qwen3-embedding:0.6b`. |", "| Ollama embedding | Interno | Servizio Compose interno obbligatorio dedicato a `qwen3-embedding:0.6b`. |")
|
|
path.write_text(text)
|
|
PY
|
|
compact_spec='{"rows":[
|
|
{"componente":"^DWH$","ownership":"^Esterno$","contratto operativo":"endpoint.*estern"},
|
|
{"componente":"^LLM$","ownership":"^Esterno$","contratto operativo":"esterna|esterno"},
|
|
{"componente":"^Qdrant$","ownership":"^Interno$","contratto operativo":"interno.*obbligatorio.*qdrant-data"},
|
|
{"componente":"^Ollama embedding$","ownership":"^Interno$","contratto operativo":"interno.*obbligatorio.*qwen3-embedding:0\\.6b"}
|
|
]}'
|
|
verify_markdown_table_relationships "$compact_paraphrase" "compact manual paraphrase" "Contratto sintetico di ownership" "$compact_spec" >/dev/null
|
|
|
|
compact_inversion="$negative_root/compact-inversion.md"
|
|
cp "$root/docs/installazione-docker-4-contesti.md" "$compact_inversion"
|
|
python3 - "$compact_inversion" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
text = text.replace("| Qdrant | Interno | Servizio Compose interno obbligatorio con volume persistente `qdrant-data`. |", "| Qdrant | Esterno | Servizio esterno condiviso. |")
|
|
path.write_text(text)
|
|
PY
|
|
set +e
|
|
verify_markdown_table_relationships "$compact_inversion" "compact inversion" "Contratto sintetico di ownership" "$compact_spec" >"$workspace_output" 2>&1
|
|
workspace_status=$?
|
|
set -e
|
|
if [[ $workspace_status -eq 0 ]]; then
|
|
echo "compact inversion fixture was not rejected correctly" >&2
|
|
cat "$workspace_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
compact_scattered="$negative_root/compact-scattered.md"
|
|
cp "$root/docs/installazione-docker-4-contesti.md" "$compact_scattered"
|
|
python3 - "$compact_scattered" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
start = text.index("## Contratto sintetico di ownership")
|
|
end = text.index("## Comando standard locale")
|
|
text = text[:start] + "Qdrant Interno DWH Esterno LLM Esterno Ollama embedding Interno.\n\n" + text[end:]
|
|
path.write_text(text)
|
|
PY
|
|
set +e
|
|
verify_markdown_table_relationships "$compact_scattered" "compact scattered tokens" "Contratto sintetico di ownership" "$compact_spec" >"$workspace_output" 2>&1
|
|
workspace_status=$?
|
|
set -e
|
|
if [[ $workspace_status -eq 0 ]]; then
|
|
echo "compact scattered-token fixture was not rejected correctly" >&2
|
|
cat "$workspace_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
adapted_reorder="$negative_root/caddy-adapted-reorder.json"
|
|
adapted_bypass="$negative_root/caddy-adapted-bypass.json"
|
|
node - "$adapted_reorder" "$adapted_bypass" <<'NODE'
|
|
const fs = require("fs");
|
|
const publicHeaders = [
|
|
"X-Thoth-Principal-Issuer", "X-Thoth-Principal-Subject",
|
|
"X-Thoth-Principal-Display-Name", "X-Thoth-Is-Admin",
|
|
];
|
|
const trustedHeaders = [
|
|
"X-Thoth-Trusted-Principal-Issuer", "X-Thoth-Trusted-Principal-Subject",
|
|
"X-Thoth-Trusted-Principal-Display-Name", "X-Thoth-Trusted-Is-Admin",
|
|
];
|
|
const clear = (name) => ({handler: "headers", request: {delete: [name]}});
|
|
const auth = {
|
|
handler: "reverse_proxy", upstreams: [{dial: "auth-gateway:4180"}],
|
|
handle_response: [{match: {status_code: [2]}, routes: [{handle: trustedHeaders.map((name, index) => ({
|
|
handler: "headers", request: {set: {[name]: [`{http.reverse_proxy.header.${publicHeaders[index]}}`]}},
|
|
}))}]}],
|
|
};
|
|
const document = {routes: [{handle: [
|
|
...publicHeaders.map(clear), auth, ...trustedHeaders.map(clear),
|
|
{handler: "reverse_proxy", upstreams: [{dial: "127.0.0.1:8080"}]},
|
|
]}]};
|
|
fs.writeFileSync(process.argv[2], JSON.stringify(document));
|
|
const frontend = {handler: "reverse_proxy", upstreams: [{dial: "127.0.0.1:8080"}]};
|
|
const validChain = [...publicHeaders, ...trustedHeaders].map(clear).concat(auth, frontend);
|
|
fs.writeFileSync(process.argv[3], JSON.stringify({routes: [
|
|
{handle: validChain},
|
|
{handle: [frontend]},
|
|
]}));
|
|
NODE
|
|
adapted_output="$negative_root/caddy-adapted-output"
|
|
set +e
|
|
verify_caddy_adapted_identity_order "$adapted_reorder" >"$adapted_output" 2>&1
|
|
adapted_status=$?
|
|
set -e
|
|
if [[ $adapted_status -eq 0 ]] || ! grep -Fq "Caddy adapted identity clears must execute before authentication" "$adapted_output"; then
|
|
echo "Caddy reordered adapted-handler fixture was not rejected correctly" >&2
|
|
cat "$adapted_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
set +e
|
|
verify_caddy_adapted_identity_order "$adapted_bypass" >"$adapted_output" 2>&1
|
|
adapted_status=$?
|
|
set -e
|
|
if [[ $adapted_status -eq 0 ]] || ! grep -Fq "Caddy adapted frontend path bypasses complete authentication contract" "$adapted_output"; then
|
|
echo "Caddy additional direct frontend route fixture was not rejected correctly" >&2
|
|
cat "$adapted_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
negative_failures=0
|
|
expect_evidence_fixture_rejected() {
|
|
local label="$1" target="$2" mutation="$3" expected_error="$4"
|
|
local fixture_root="$negative_root/evidence-${label// /-}"
|
|
local fixture_output="$fixture_root/output"
|
|
|
|
# Before Task 7's dedicated verifier exists, every mutation is deliberately accepted. This
|
|
# makes the complete Evidence test matrix RED without allowing command-not-found to abort it.
|
|
if ! declare -F verify_workspace_evidence_contract >/dev/null; then
|
|
echo "negative fixture accepted: $label (Evidence verifier missing)" >&2
|
|
negative_failures=$((negative_failures + 1))
|
|
return
|
|
fi
|
|
|
|
mkdir -p \
|
|
"$fixture_root/deploy/workspaces" \
|
|
"$fixture_root/docs/contracts" \
|
|
"$fixture_root/docs/install/examples" \
|
|
"$fixture_root/docs/install" \
|
|
"$fixture_root/docs/migrations"
|
|
cp "$root/deploy/workspaces/example.yaml" "$fixture_root/deploy/workspaces/example.yaml"
|
|
cp "$root/deploy/workspaces/psd.yaml.example" "$fixture_root/deploy/workspaces/psd.yaml.example"
|
|
cp "$root/docs/contracts/workspace-evidence-v3.md" \
|
|
"$fixture_root/docs/contracts/workspace-evidence-v3.md"
|
|
cp "$root/docs/install/local-workspace-registry.md" \
|
|
"$fixture_root/docs/install/local-workspace-registry.md"
|
|
cp "$root/docs/install/server-workspace-registry.md" \
|
|
"$fixture_root/docs/install/server-workspace-registry.md"
|
|
cp "$root/README.md" "$fixture_root/README.md"
|
|
cp "$root/docs/migrations/p1-to-p1-1-registry-layout.md" \
|
|
"$fixture_root/docs/migrations/p1-to-p1-1-registry-layout.md"
|
|
cp "$root/docs/install/examples/workspace-bindings.env.example" \
|
|
"$fixture_root/docs/install/examples/workspace-bindings.env.example"
|
|
|
|
python3 - "$fixture_root/$target" "$mutation" <<'PY'
|
|
import pathlib, sys, yaml
|
|
path = pathlib.Path(sys.argv[1])
|
|
mutation = sys.argv[2]
|
|
original = path.read_text()
|
|
changed = original
|
|
if mutation == "layout-omitted":
|
|
changed = original.replace("├── thoth-workspaces.yaml\n", "", 1)
|
|
elif mutation == "same-commit-omitted":
|
|
changed = original.replace(
|
|
"| Revision identity | The catalog blob, descriptor blob, and filesystem Evidence root tree are checked at the same 40-hex Git commit. |\n",
|
|
"",
|
|
1,
|
|
)
|
|
elif mutation == "flat-descriptor-path":
|
|
changed = original.replace("<id>/workspace.yaml", "workspaces/<id>.yaml", 1)
|
|
elif mutation in {"absolute-filesystem", "cross-workspace", "old-filesystem-layout"}:
|
|
document = yaml.safe_load(original)
|
|
document["evidence"]["source"]["uri"] = {
|
|
"absolute-filesystem": "/srv/evidence",
|
|
"cross-workspace": "other-workspace/evidence",
|
|
"old-filesystem-layout": "workspace-content/example/evidence",
|
|
}[mutation]
|
|
changed = yaml.safe_dump(document, sort_keys=False)
|
|
elif mutation == "wrong-docs-directory":
|
|
changed = original.replace(
|
|
"workspace-docs/<id>/{contract.env.example,README.md}",
|
|
"example/README.md and example/contract.env.example",
|
|
1,
|
|
)
|
|
elif mutation == "catalog-authority-omitted":
|
|
changed = original.replace(
|
|
"authoritative for workspace ID,",
|
|
"descriptor metadata may override workspace ID,",
|
|
1,
|
|
)
|
|
elif mutation == "bootstrap-omitted":
|
|
changed = original.replace(
|
|
"5. The API may create `<id>/workspace.yaml` only when the catalog slot already exists and no Git\n object exists at that path in the exact pulled base commit.\n",
|
|
"",
|
|
1,
|
|
)
|
|
elif mutation == "api-updates-existing":
|
|
changed = original.replace(
|
|
"6. After bootstrap, existing descriptors change only through curator Git commit/push and\n installation pull. The API never writes `thoth-workspaces.yaml` or `<id>/evidence/**`.\n",
|
|
"6. After bootstrap, use the API to update or delete existing descriptors directly from ThothII.\n",
|
|
1,
|
|
)
|
|
elif mutation == "public-http-mode-omitted":
|
|
changed = original.replace(
|
|
"Public HTTP (`authentication: none`) uses the declared query-free\nURIs directly and requires no Evidence credential file.",
|
|
"",
|
|
1,
|
|
)
|
|
elif mutation == "ambient-s3-mode-omitted":
|
|
changed = original.replace(
|
|
"Ambient S3\n(`credentials: ambient`) uses the runtime provider chain and requires no Evidence credential file.",
|
|
"",
|
|
1,
|
|
)
|
|
elif mutation == "numeric-domains-omitted":
|
|
changed = original.replace("positive safe integers", "positive integers", 1)
|
|
changed = changed.replace("nonnegative safe integer", "nonnegative integer", 1)
|
|
elif mutation == "endpoint-without-url-invariant-omitted":
|
|
changed = original.replace(
|
|
"Endpoint-policy flags cannot be enabled without `endpoint_url`.", "", 1
|
|
)
|
|
elif mutation == "http-file-boundary-omitted":
|
|
changed = original.replace(
|
|
"| Signed HTTP | `THT_WS_<NAMESPACE>_EVIDENCE_SIGNED_URLS_FILE` | Required for `signed_urls_file`; at most 1048576 bytes; nonempty UTF-8 JSON string array in declared-URI order; query-stripped identities must match `uris` one-to-one. |\n",
|
|
"",
|
|
1,
|
|
)
|
|
elif mutation == "s3-pair-boundary-omitted":
|
|
changed = original.replace(
|
|
"| Static S3 pair | `THT_WS_<NAMESPACE>_EVIDENCE_ACCESS_KEY_FILE` and `THT_WS_<NAMESPACE>_EVIDENCE_SECRET_KEY_FILE` | Required together for `static_files`; each file is at most 65536 bytes. |\n",
|
|
"",
|
|
1,
|
|
)
|
|
elif mutation == "s3-token-boundary-omitted":
|
|
changed = original.replace(
|
|
"| Static S3 session | `THT_WS_<NAMESPACE>_EVIDENCE_SESSION_TOKEN_FILE` | Optional, valid only with the required access/secret pair, and at most 65536 bytes. |\n",
|
|
"",
|
|
1,
|
|
)
|
|
elif mutation == "credential-literal":
|
|
changed = original + "\nTHT_WS_STATIC_S3_EVIDENCE_SECRET_KEY=AKIAEXAMPLECREDENTIAL\n"
|
|
elif mutation == "credential-literal-public-prose":
|
|
changed = original + "\nPublic credential example: AKIAABCDEFGHIJKLMNOP\n"
|
|
elif mutation == "credential-literal-public-yaml":
|
|
document = yaml.safe_load(original)
|
|
document["public_credential_example"] = "AKIAABCDEFGHIJKLMNOP"
|
|
changed = yaml.safe_dump(document, sort_keys=False)
|
|
elif mutation == "signed-query-example":
|
|
signed_query = "https://evidence.example.invalid/report" + "?X-Amz-Signature=unsafe"
|
|
changed = original + f"\nTHT_EVIDENCE_URI={signed_query}\n"
|
|
elif mutation == "unsafe-placeholder":
|
|
changed = original.replace(
|
|
"/run/secrets/signed-http-evidence-urls.json", "changeme", 1
|
|
)
|
|
elif mutation == "p1-scope-inversion":
|
|
changed = original.replace(
|
|
"P1.1 performs no acquisition, extraction, preprocessing/indexing, embeddings, Qdrant writes,\n`ACTIVE` publication, retention, or GC.",
|
|
"P1.1 materializes, extracts, and indexes Evidence before publication.",
|
|
1,
|
|
)
|
|
elif mutation.startswith("p1-append-"):
|
|
operations = {
|
|
"acquisition": ("acquire Evidence", "acquires Evidence", "Evidence acquisition"),
|
|
"materialization": (
|
|
"materialize Evidence", "materializes Evidence", "Evidence materialization"
|
|
),
|
|
"extraction": ("extract Evidence", "extracts Evidence", "Evidence extraction"),
|
|
"preprocessing": (
|
|
"preprocess Evidence", "preprocesses Evidence", "Evidence preprocessing"
|
|
),
|
|
"embeddings": ("create embeddings", "creates embeddings", "Evidence embeddings"),
|
|
"qdrant-writes": (
|
|
"write embeddings to Qdrant", "writes embeddings to Qdrant",
|
|
"Evidence Qdrant writes",
|
|
),
|
|
"indexing": ("index Evidence", "indexes Evidence", "Evidence indexing"),
|
|
"active": ("publish `ACTIVE`", "publishes `ACTIVE`", "Evidence `ACTIVE` publication"),
|
|
"retention": ("retain Evidence", "retains Evidence", "Evidence retention"),
|
|
"gc": ("garbage-collect Evidence", "garbage-collects Evidence", "Evidence GC"),
|
|
}
|
|
forms = (
|
|
"base", "third-person", "can", "may", "must", "will", "should",
|
|
"adverb-before-modal", "adverb-after-modal", "ownership",
|
|
)
|
|
suffix = mutation.removeprefix("p1-append-")
|
|
operation = next((name for name in operations if suffix.startswith(f"{name}-")), None)
|
|
form = suffix.removeprefix(f"{operation}-") if operation else ""
|
|
if operation is None or form not in forms:
|
|
raise SystemExit(f"unknown P1 append mutation: {mutation}")
|
|
base, third_person, ownership = operations[operation]
|
|
claims = {
|
|
"base": f"P1.1 does {base}.",
|
|
"third-person": f"P1.1 {third_person}.",
|
|
"can": f"P1.1 can {base}.",
|
|
"may": f"P1.1 may {base}.",
|
|
"must": f"P1.1 must {base}.",
|
|
"will": f"P1.1 will {base}.",
|
|
"should": f"P1.1 should {base}.",
|
|
"adverb-before-modal": f"P1.1 directly may {base}.",
|
|
"adverb-after-modal": f"P1.1 may directly {base}.",
|
|
"ownership": f"P1.1 owns {ownership}.",
|
|
}
|
|
changed = original + f"\n{claims[form]}\n"
|
|
elif mutation == "config-ordering":
|
|
changed = original.replace(
|
|
"tht config check -c <path>", "tht -c <path> config check", 1
|
|
)
|
|
elif mutation == "acceptance-conflation":
|
|
changed = original.replace("manual acceptance: PENDING\n", "", 1)
|
|
elif mutation == "curator-order":
|
|
second = "2. Keep `thoth-workspaces.yaml` curator-owned. It uses the `schema_version` value `1` and the ordered\n `workspaces` list of `{id, name, description?}` entries; it is authoritative for workspace ID,\n name, description, and display order."
|
|
third = "3. For an existing workspace, edit `<id>/workspace.yaml` and any embedded `<id>/evidence/**`, then\n commit and push."
|
|
changed = original.replace(second + "\n" + third, third + "\n" + second, 1)
|
|
elif mutation == "migration-commit-omitted":
|
|
changed = original.replace("git mv workspaces/<id>.yaml <id>/workspace.yaml\n", "", 1)
|
|
elif mutation == "migration-upgrade-omitted":
|
|
changed = original.replace("3. Upgrade ThothII only after that migration commit is pushed.\n", "", 1)
|
|
elif mutation == "migration-rollback-omitted":
|
|
changed = original.replace("Roll back the application revision and registry commit together.", "Roll back only the application revision.", 1)
|
|
else:
|
|
raise SystemExit(f"unknown Evidence mutation: {mutation}")
|
|
if changed == original:
|
|
raise SystemExit(f"Evidence mutation made no change: {mutation}")
|
|
path.write_text(changed)
|
|
PY
|
|
|
|
set +e
|
|
verify_workspace_evidence_contract "$fixture_root" >"$fixture_output" 2>&1
|
|
local status=$?
|
|
set -e
|
|
if [[ $status -eq 0 ]]; then
|
|
echo "negative fixture accepted: $label" >&2
|
|
cat "$fixture_output" >&2
|
|
negative_failures=$((negative_failures + 1))
|
|
elif ! grep -Fq -- "$expected_error" "$fixture_output"; then
|
|
echo "negative fixture failed for the wrong reason: $label" >&2
|
|
cat "$fixture_output" >&2
|
|
negative_failures=$((negative_failures + 1))
|
|
fi
|
|
}
|
|
|
|
expect_evidence_claim_accepted() {
|
|
local label="$1" claim="$2"
|
|
local fixture_root="$negative_root/evidence-safe-${label// /-}"
|
|
local fixture_output="$fixture_root/output"
|
|
|
|
mkdir -p \
|
|
"$fixture_root/deploy/workspaces" \
|
|
"$fixture_root/docs/contracts" \
|
|
"$fixture_root/docs/install/examples" \
|
|
"$fixture_root/docs/install" \
|
|
"$fixture_root/docs/migrations"
|
|
cp "$root/deploy/workspaces/example.yaml" "$fixture_root/deploy/workspaces/example.yaml"
|
|
cp "$root/deploy/workspaces/psd.yaml.example" "$fixture_root/deploy/workspaces/psd.yaml.example"
|
|
cp "$root/docs/contracts/workspace-evidence-v3.md" \
|
|
"$fixture_root/docs/contracts/workspace-evidence-v3.md"
|
|
cp "$root/docs/install/local-workspace-registry.md" \
|
|
"$fixture_root/docs/install/local-workspace-registry.md"
|
|
cp "$root/docs/install/server-workspace-registry.md" \
|
|
"$fixture_root/docs/install/server-workspace-registry.md"
|
|
cp "$root/README.md" "$fixture_root/README.md"
|
|
cp "$root/docs/migrations/p1-to-p1-1-registry-layout.md" \
|
|
"$fixture_root/docs/migrations/p1-to-p1-1-registry-layout.md"
|
|
cp "$root/docs/install/examples/workspace-bindings.env.example" \
|
|
"$fixture_root/docs/install/examples/workspace-bindings.env.example"
|
|
printf '\n%s\n' "$claim" >>"$fixture_root/docs/contracts/workspace-evidence-v3.md"
|
|
|
|
set +e
|
|
verify_workspace_evidence_contract "$fixture_root" >"$fixture_output" 2>&1
|
|
local status=$?
|
|
set -e
|
|
if [[ $status -ne 0 ]]; then
|
|
echo "safe Evidence fixture rejected: $label" >&2
|
|
cat "$fixture_output" >&2
|
|
negative_failures=$((negative_failures + 1))
|
|
fi
|
|
}
|
|
|
|
expect_guide_rejected() {
|
|
local label="$1" validator="$2" source_guide="$3" relative_path="$4"
|
|
local mutation="$5" expected_error="$6"
|
|
local fixture_root="$negative_root/${label// /-}"
|
|
local fixture_output="$fixture_root/output"
|
|
mkdir -p "$fixture_root/$(dirname "$relative_path")"
|
|
cp "$source_guide" "$fixture_root/$relative_path"
|
|
if [[ "$validator" == verify_windows_line_endings_guide ]]; then
|
|
mkdir -p "$fixture_root/scripts"
|
|
cp "$root/scripts/verify-line-endings.sh" "$fixture_root/scripts/verify-line-endings.sh"
|
|
elif [[ "$validator" == verify_pi_management_guide ]]; then
|
|
mkdir -p "$fixture_root/docs/contracts"
|
|
cp "$root/docs/contracts/tht-pi.md" "$fixture_root/docs/contracts/tht-pi.md"
|
|
fi
|
|
node - "$fixture_root/$relative_path" "$mutation" <<'NODE'
|
|
const fs = require("fs");
|
|
const [path, mutation] = process.argv.slice(2);
|
|
const original = fs.readFileSync(path, "utf8");
|
|
let changed = original;
|
|
switch (mutation) {
|
|
case "durable-selector":
|
|
changed = original.replaceAll("--source build", "--source stale-build");
|
|
break;
|
|
case "dangerous-volumes":
|
|
changed = original.replace("Do **not** run `docker compose down --volumes`", "Run `docker compose down --volumes`");
|
|
break;
|
|
case "incomplete-powershell":
|
|
changed = original.replaceAll("icacls.exe", "Write-Output");
|
|
break;
|
|
case "broken-crlf":
|
|
changed = original.replaceAll("git checkout-index --all --force --prefix=", "git add --renormalize . # ");
|
|
break;
|
|
case "raw-pi":
|
|
changed += "\n```sh\ndocker compose exec core pi --version\n```\n";
|
|
break;
|
|
case "server-secret-env":
|
|
changed += "\n```dotenv\nTHT_MODEL_API_KEY=unsafe-secret-value\n```\n";
|
|
break;
|
|
case "server-docker-socket":
|
|
changed += "\nMount /var/run/docker.sock into core for management.\n";
|
|
break;
|
|
case "server-coupling":
|
|
changed += "\nAttach core to the omics_portal application network.\n";
|
|
break;
|
|
case "server-host-loopback":
|
|
changed += "\nFor host-gateway, keep the external service listening on 127.0.0.1.\n";
|
|
break;
|
|
case "server-parent-traversal":
|
|
changed = original.replace("sudo install -d -o 10001 -g thothii-ops -m 2750 /srv/thothii\n", "");
|
|
break;
|
|
case "server-raw-remove":
|
|
changed += "\n```sh\ndocker rm thothii-core thothii-frontend\n```\n";
|
|
break;
|
|
case "server-pinned-migrator-mismatch":
|
|
changed += "\n```yaml\nservices:\n core:\n image: registry.invalid/core@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n session-migrate:\n image: thothii-core:local\n```\n";
|
|
break;
|
|
case "server-pinned-frontend-missing":
|
|
changed = original.replace(' frontend:\n build: !reset null\n image: registry.example.com/thothii/frontend@sha256:<64-lowercase-hex-digits>\n', '');
|
|
break;
|
|
case "nginx-no-auth":
|
|
changed = original.replace(" auth_request /_authenticate;", " # authentication omitted");
|
|
break;
|
|
case "nginx-core-upstream":
|
|
changed = original.replaceAll("http://127.0.0.1:8080", "http://127.0.0.1:8787");
|
|
break;
|
|
case "nginx-no-sse":
|
|
changed = original.replace(" proxy_buffering off;", " proxy_buffering on;");
|
|
break;
|
|
case "nginx-no-issuer-clear":
|
|
changed = original.replaceAll('proxy_set_header X-Thoth-Principal-Issuer "";', 'proxy_set_header X-Thoth-Principal-Issuer $http_x_thoth_principal_issuer;');
|
|
break;
|
|
case "nginx-no-subject-capture":
|
|
changed = original.replace("auth_request_set $thoth_principal_subject", "# missing auth capture $thoth_principal_subject");
|
|
break;
|
|
case "nginx-no-display-map":
|
|
changed = original.replace("proxy_set_header X-Thoth-Trusted-Principal-Display-Name $thoth_principal_display_name;", "proxy_set_header X-Thoth-Trusted-Principal-Display-Name \"\";");
|
|
break;
|
|
case "nginx-no-admin-map":
|
|
changed = original.replace("proxy_set_header X-Thoth-Trusted-Is-Admin $thoth_is_admin;", "proxy_set_header X-Thoth-Trusted-Is-Admin \"\";");
|
|
break;
|
|
case "nginx-admin-clear-wrong-scope": {
|
|
const clear = ' proxy_set_header X-Thoth-Is-Admin "";';
|
|
const authAt = original.indexOf(clear);
|
|
changed = original.slice(0, authAt) + original.slice(authAt + clear.length + 1);
|
|
const frontendAt = changed.indexOf(clear);
|
|
changed = changed.slice(0, frontendAt) + clear + "\n" + clear + changed.slice(frontendAt + clear.length);
|
|
break;
|
|
}
|
|
case "nginx-additional-bypass":
|
|
changed = original.replace(" location / {", " location /bypass {\n proxy_pass http://127.0.0.1:8080;\n }\n\n location / {");
|
|
break;
|
|
case "nginx-comment-only-auth":
|
|
changed = original.replace(" location / {", ` location /comment-only-auth {
|
|
# auth_request /_authenticate;
|
|
# auth_request_set $thoth_principal_issuer $upstream_http_x_thoth_principal_issuer;
|
|
# auth_request_set $thoth_principal_subject $upstream_http_x_thoth_principal_subject;
|
|
# auth_request_set $thoth_principal_display_name $upstream_http_x_thoth_principal_display_name;
|
|
# auth_request_set $thoth_is_admin $upstream_http_x_thoth_is_admin;
|
|
# proxy_set_header X-Thoth-Principal-Issuer "";
|
|
# proxy_set_header X-Thoth-Principal-Subject "";
|
|
# proxy_set_header X-Thoth-Principal-Display-Name "";
|
|
# proxy_set_header X-Thoth-Is-Admin "";
|
|
# proxy_set_header X-Thoth-Trusted-Principal-Issuer $thoth_principal_issuer;
|
|
# proxy_set_header X-Thoth-Trusted-Principal-Subject $thoth_principal_subject;
|
|
# proxy_set_header X-Thoth-Trusted-Principal-Display-Name $thoth_principal_display_name;
|
|
# proxy_set_header X-Thoth-Trusted-Is-Admin $thoth_is_admin;
|
|
proxy_set_header X-Comment-Literal "quoted#value"; # preserve the quoted hash
|
|
proxy_pass http://127.0.0.1:8080; # active frontend path
|
|
}
|
|
|
|
location / {`);
|
|
break;
|
|
case "caddy-no-auth":
|
|
changed = original.replace("forward_auth auth-gateway:4180 {", "# forward authentication omitted");
|
|
break;
|
|
case "caddy-client-identity":
|
|
changed = original.replace("X-Thoth-Principal-Subject>X-Thoth-Trusted-Principal-Subject", "X-Thoth-Principal-Subject");
|
|
break;
|
|
case "caddy-core-upstream":
|
|
changed = original.replaceAll("127.0.0.1:8080", "127.0.0.1:8787");
|
|
break;
|
|
case "caddy-no-issuer-public-clear":
|
|
changed = original.replace("request_header -X-Thoth-Principal-Issuer", "request_header X-Thoth-Principal-Issuer {header.X-Thoth-Principal-Issuer}");
|
|
break;
|
|
case "caddy-no-subject-trusted-clear":
|
|
changed = original.replace("request_header -X-Thoth-Trusted-Principal-Subject", "request_header X-Thoth-Trusted-Principal-Subject {header.X-Thoth-Trusted-Principal-Subject}");
|
|
break;
|
|
case "caddy-no-display-map":
|
|
changed = original.replace("X-Thoth-Principal-Display-Name>X-Thoth-Trusted-Principal-Display-Name", "X-Thoth-Principal-Display-Name");
|
|
break;
|
|
case "caddy-no-admin-map":
|
|
changed = original.replace("X-Thoth-Is-Admin>X-Thoth-Trusted-Is-Admin", "X-Thoth-Is-Admin");
|
|
break;
|
|
case "caddy-clears-after-auth": {
|
|
const clearPattern = /(?:\t\trequest_header -X-(?:Authenticated-User|Thoth-[^\n]+)\n)+/;
|
|
const clears = original.match(clearPattern)?.[0] || "";
|
|
changed = original.replace(clearPattern, "");
|
|
changed = changed.replace("\n\t\treverse_proxy 127.0.0.1:8080 {", "\n" + clears + "\n\t\treverse_proxy 127.0.0.1:8080 {");
|
|
break;
|
|
}
|
|
case "dirty-source":
|
|
changed = original.replaceAll("git status --porcelain --untracked-files=all", "git status --short");
|
|
break;
|
|
case "failed-pull":
|
|
changed = original.replace("if ! git pull --ff-only; then abort_update", "if git pull --ff-only; then abort_update");
|
|
break;
|
|
case "failed-status":
|
|
changed = original.replace("if ! RUNNING_PI_VERSION=", "if RUNNING_PI_VERSION=");
|
|
break;
|
|
case "failed-build":
|
|
changed = original.replace("if ! bash scripts/build-local.sh; then", "if bash scripts/build-local.sh; then");
|
|
break;
|
|
case "same-version-no-selector":
|
|
changed = original.replace("TRANSACTIONAL_PI_UPDATE=false", "TRANSACTIONAL_PI_UPDATE=true # unsafe same-version no-op");
|
|
break;
|
|
case "powershell-source-failure":
|
|
changed = original.replace("Assert-NativeSuccess 'Pi status'", "Write-Output 'Pi status unchecked'");
|
|
break;
|
|
case "failed-export":
|
|
changed = original.replace("if ! git checkout-index --all --force", "if git checkout-index --all --force");
|
|
break;
|
|
case "partial-export":
|
|
changed = original.replace("if ! validate_index_export; then", "if validate_index_export; then");
|
|
break;
|
|
case "mode-120000":
|
|
changed = original.replaceAll("120000", "100644-no-symlink-mode");
|
|
break;
|
|
case "powershell-crlf-failure":
|
|
changed = original.replace("Assert-NativeSuccess 'index export'", "Write-Output 'index export unchecked'");
|
|
break;
|
|
default:
|
|
throw new Error(`unknown negative-fixture mutation: ${mutation}`);
|
|
}
|
|
if (changed === original) throw new Error(`negative-fixture mutation made no change: ${mutation}`);
|
|
fs.writeFileSync(path, changed);
|
|
NODE
|
|
set +e
|
|
(root="$fixture_root"; set -e; "$validator") >"$fixture_output" 2>&1
|
|
local status=$?
|
|
set -e
|
|
if [[ $status -eq 0 ]]; then
|
|
echo "negative fixture accepted: $label" >&2
|
|
cat "$fixture_output" >&2
|
|
negative_failures=$((negative_failures + 1))
|
|
elif ! grep -Fq -- "$expected_error" "$fixture_output"; then
|
|
echo "negative fixture failed for the wrong reason: $label" >&2
|
|
cat "$fixture_output" >&2
|
|
negative_failures=$((negative_failures + 1))
|
|
fi
|
|
}
|
|
|
|
expect_guide_rejected \
|
|
"durable selector keeps old core" verify_local_guide \
|
|
"$root/docs/install/local.md" docs/install/local.md durable-selector \
|
|
"installation-aware source update lacks structural token: --source build"
|
|
expect_guide_rejected \
|
|
"dangerous down volumes instruction" verify_local_guide \
|
|
"$root/docs/install/local.md" docs/install/local.md dangerous-volumes \
|
|
"docker compose down --volumes must appear only in an explicit prose prohibition"
|
|
expect_guide_rejected \
|
|
"incomplete native PowerShell path" verify_local_guide \
|
|
"$root/docs/install/local.md" docs/install/local.md incomplete-powershell \
|
|
"native PowerShell setup lacks structural token: icacls.exe"
|
|
expect_guide_rejected \
|
|
"renormalize leaves CRLF worktree bytes" verify_windows_line_endings_guide \
|
|
"$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md broken-crlf \
|
|
"Windows line-ending guide lacks required instruction: git checkout-index --all --force"
|
|
expect_guide_rejected \
|
|
"raw non-installation-aware Pi access" verify_pi_management_guide \
|
|
"$root/docs/install/pi-management.md" docs/install/pi-management.md raw-pi \
|
|
"raw non-installation-aware Compose Pi access is forbidden"
|
|
expect_guide_rejected \
|
|
"server secret in environment" verify_server_guide \
|
|
"$root/docs/install/server.md" docs/install/server.md server-secret-env \
|
|
"server installation guide embeds a secret value"
|
|
expect_guide_rejected \
|
|
"server Docker socket mount" verify_server_guide \
|
|
"$root/docs/install/server.md" docs/install/server.md server-docker-socket \
|
|
"server installation guide introduces a Docker socket dependency"
|
|
expect_guide_rejected \
|
|
"server application coupling" verify_server_guide \
|
|
"$root/docs/install/server.md" docs/install/server.md server-coupling \
|
|
"server installation guide introduces forbidden application coupling"
|
|
expect_guide_rejected \
|
|
"server host-gateway loopback listener" verify_server_guide \
|
|
"$root/docs/install/server.md" docs/install/server.md server-host-loopback \
|
|
"server host-gateway guidance assumes a host loopback listener"
|
|
expect_guide_rejected \
|
|
"server parent traversal boundary" verify_server_guide \
|
|
"$root/docs/install/server.md" docs/install/server.md server-parent-traversal \
|
|
"server installation guide does not set parent traversal boundary"
|
|
expect_guide_rejected \
|
|
"server raw container removal" verify_server_guide \
|
|
"$root/docs/install/server.md" docs/install/server.md server-raw-remove \
|
|
"server uninstall bypasses installation-aware removal"
|
|
expect_guide_rejected \
|
|
"server pinned migrator differs from core" verify_server_guide \
|
|
"$root/docs/install/server.md" docs/install/server.md server-pinned-migrator-mismatch \
|
|
"server pinned migration image must equal the pinned core image"
|
|
expect_guide_rejected \
|
|
"server pinned frontend is missing" verify_server_guide \
|
|
"$root/docs/install/server.md" docs/install/server.md server-pinned-frontend-missing \
|
|
"server pinned image override must pin core, session-migrate, and frontend without builds"
|
|
expect_guide_rejected \
|
|
"Nginx identity without authentication" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-auth \
|
|
"Nginx proxy lacks structural token: auth_request /_authenticate;"
|
|
expect_guide_rejected \
|
|
"Nginx direct core exposure" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-core-upstream \
|
|
"Nginx proxy must forward only to frontend on 127.0.0.1:8080"
|
|
expect_guide_rejected \
|
|
"Nginx buffered SSE" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-sse \
|
|
"Nginx proxy lacks structural token: proxy_buffering off;"
|
|
expect_guide_rejected \
|
|
"Nginx issuer inbound claim not cleared" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-issuer-clear \
|
|
"Nginx auth location does not clear inbound issuer identity"
|
|
expect_guide_rejected \
|
|
"Nginx subject auth response not captured" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-subject-capture \
|
|
"Nginx frontend location does not capture authenticated subject identity"
|
|
expect_guide_rejected \
|
|
"Nginx display identity not mapped to private hop" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-display-map \
|
|
"Nginx frontend location does not map authenticated display identity"
|
|
expect_guide_rejected \
|
|
"Nginx admin identity not mapped to private hop" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-admin-map \
|
|
"Nginx frontend location does not map authenticated admin identity"
|
|
expect_guide_rejected \
|
|
"Nginx admin clear moved out of auth scope" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-admin-clear-wrong-scope \
|
|
"Nginx auth location does not clear inbound admin identity"
|
|
expect_guide_rejected \
|
|
"Nginx additional frontend bypass location" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-additional-bypass \
|
|
"Nginx direct OIDC mode contains an additional frontend bypass location"
|
|
expect_guide_rejected \
|
|
"Nginx frontend auth directives only in comments" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-comment-only-auth \
|
|
"Nginx direct OIDC mode contains an additional frontend bypass location"
|
|
expect_guide_rejected \
|
|
"Caddy identity without authentication" verify_reverse_proxy_caddy_guide \
|
|
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-auth \
|
|
"Caddy proxy lacks structural token: forward_auth auth-gateway:4180 {"
|
|
expect_guide_rejected \
|
|
"Caddy untrusted identity forwarding" verify_reverse_proxy_caddy_guide \
|
|
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-client-identity \
|
|
"Caddy proxy does not map authenticated subject identity"
|
|
expect_guide_rejected \
|
|
"Caddy direct core exposure" verify_reverse_proxy_caddy_guide \
|
|
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-core-upstream \
|
|
"Caddy proxy must forward only to frontend on 127.0.0.1:8080"
|
|
expect_guide_rejected \
|
|
"Caddy issuer inbound claim not cleared" verify_reverse_proxy_caddy_guide \
|
|
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-issuer-public-clear \
|
|
"Caddy proxy does not clear inbound issuer identity"
|
|
expect_guide_rejected \
|
|
"Caddy subject private-hop claim not cleared" verify_reverse_proxy_caddy_guide \
|
|
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-subject-trusted-clear \
|
|
"Caddy proxy does not clear inbound trusted subject identity"
|
|
expect_guide_rejected \
|
|
"Caddy display identity not mapped to private hop" verify_reverse_proxy_caddy_guide \
|
|
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-display-map \
|
|
"Caddy proxy does not map authenticated display identity"
|
|
expect_guide_rejected \
|
|
"Caddy admin identity not mapped to private hop" verify_reverse_proxy_caddy_guide \
|
|
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-admin-map \
|
|
"Caddy proxy does not map authenticated admin identity"
|
|
expect_guide_rejected \
|
|
"Caddy identity clears reordered after auth" verify_reverse_proxy_caddy_guide \
|
|
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-clears-after-auth \
|
|
"Caddy identity clears must precede forward_auth"
|
|
|
|
expect_guide_rejected \
|
|
"dirty or untracked source tree" verify_local_guide \
|
|
"$root/docs/install/local.md" docs/install/local.md dirty-source \
|
|
"installation-aware source update lacks structural token: git status --porcelain --untracked-files=all"
|
|
expect_guide_rejected \
|
|
"failed source pull" verify_local_guide \
|
|
"$root/docs/install/local.md" docs/install/local.md failed-pull \
|
|
"POSIX source update does not fail closed: source pull"
|
|
expect_guide_rejected \
|
|
"failed tht Pi status" verify_local_guide \
|
|
"$root/docs/install/local.md" docs/install/local.md failed-status \
|
|
"POSIX source update does not fail closed: Pi status"
|
|
expect_guide_rejected \
|
|
"failed local build" verify_local_guide \
|
|
"$root/docs/install/local.md" docs/install/local.md failed-build \
|
|
"POSIX source update does not fail closed: local build"
|
|
expect_guide_rejected \
|
|
"same Pi version without durable selector" verify_local_guide \
|
|
"$root/docs/install/local.md" docs/install/local.md same-version-no-selector \
|
|
"POSIX source update lacks the same-version/no-selector path"
|
|
expect_guide_rejected \
|
|
"PowerShell source command failure propagation" verify_local_guide \
|
|
"$root/docs/install/local.md" docs/install/local.md powershell-source-failure \
|
|
"PowerShell source update does not propagate failure: Pi status"
|
|
expect_guide_rejected \
|
|
"failed index export" verify_windows_line_endings_guide \
|
|
"$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md failed-export \
|
|
"POSIX CRLF repair lacks fail-closed semantic: if ! git checkout-index"
|
|
expect_guide_rejected \
|
|
"partial index export" verify_windows_line_endings_guide \
|
|
"$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md partial-export \
|
|
"POSIX CRLF repair does not prove a complete export before destructive rewrite"
|
|
expect_guide_rejected \
|
|
"mode 120000 symlink preservation" verify_windows_line_endings_guide \
|
|
"$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md mode-120000 \
|
|
"POSIX CRLF repair lacks fail-closed semantic: 120000"
|
|
expect_guide_rejected \
|
|
"PowerShell CRLF command failure propagation" verify_windows_line_endings_guide \
|
|
"$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md powershell-crlf-failure \
|
|
"PowerShell CRLF repair lacks failure propagation: Assert-NativeSuccess 'index export'"
|
|
|
|
expect_evidence_fixture_rejected "root catalog omitted" docs/contracts/workspace-evidence-v3.md layout-omitted "missing canonical Evidence layout"
|
|
expect_evidence_fixture_rejected "same revision ownership omitted" docs/contracts/workspace-evidence-v3.md same-commit-omitted "missing same-revision ownership"
|
|
expect_evidence_fixture_rejected "flat descriptor path" docs/contracts/workspace-evidence-v3.md flat-descriptor-path 'The descriptor at `<id>/workspace.yaml` must match the'
|
|
expect_evidence_fixture_rejected "absolute filesystem Evidence path" deploy/workspaces/example.yaml absolute-filesystem "noncanonical filesystem Evidence URI"
|
|
expect_evidence_fixture_rejected "cross-workspace Evidence path" deploy/workspaces/psd.yaml.example cross-workspace "Evidence namespace mismatch"
|
|
expect_evidence_fixture_rejected "old filesystem Evidence layout" deploy/workspaces/example.yaml old-filesystem-layout "Evidence namespace mismatch"
|
|
expect_evidence_fixture_rejected "public HTTP mode omitted" docs/contracts/workspace-evidence-v3.md public-http-mode-omitted "missing public HTTP mode"
|
|
expect_evidence_fixture_rejected "ambient S3 mode omitted" docs/contracts/workspace-evidence-v3.md ambient-s3-mode-omitted "missing ambient S3 mode"
|
|
expect_evidence_fixture_rejected "strict Evidence numeric domains omitted" docs/contracts/workspace-evidence-v3.md numeric-domains-omitted "missing strict Evidence numeric domains"
|
|
expect_evidence_fixture_rejected "S3 endpoint policy without endpoint invariant omitted" docs/contracts/workspace-evidence-v3.md endpoint-without-url-invariant-omitted "missing S3 endpoint policy without endpoint invariant"
|
|
expect_evidence_fixture_rejected "signed HTTP file boundary omitted" docs/contracts/workspace-evidence-v3.md http-file-boundary-omitted "missing signed HTTP file boundary"
|
|
expect_evidence_fixture_rejected "static S3 pair boundary omitted" docs/contracts/workspace-evidence-v3.md s3-pair-boundary-omitted "missing static S3 file boundary"
|
|
expect_evidence_fixture_rejected "static S3 optional token boundary omitted" docs/contracts/workspace-evidence-v3.md s3-token-boundary-omitted "missing static S3 session-token boundary"
|
|
expect_evidence_fixture_rejected "credential literal in public bindings" docs/install/examples/workspace-bindings.env.example credential-literal "credential literal forbidden"
|
|
expect_evidence_fixture_rejected "credential literal in public prose" docs/contracts/workspace-evidence-v3.md credential-literal-public-prose "credential literal forbidden"
|
|
expect_evidence_fixture_rejected "credential literal in public YAML" deploy/workspaces/example.yaml credential-literal-public-yaml "credential literal forbidden"
|
|
expect_evidence_fixture_rejected "signed query in public bindings" docs/install/examples/workspace-bindings.env.example signed-query-example "query-bearing public URI forbidden"
|
|
expect_evidence_fixture_rejected "unsafe Evidence file placeholder" docs/install/examples/workspace-bindings.env.example unsafe-placeholder "unsafe file placeholder/path"
|
|
expect_evidence_fixture_rejected "P1.1 Evidence scope inversion" docs/contracts/workspace-evidence-v3.md p1-scope-inversion "P1.1 scope violation"
|
|
expect_evidence_fixture_rejected "migration commit step omitted" docs/migrations/p1-to-p1-1-registry-layout.md migration-commit-omitted "migration guide missing commit step"
|
|
expect_evidence_fixture_rejected "migration upgrade ordering omitted" docs/migrations/p1-to-p1-1-registry-layout.md migration-upgrade-omitted "migration guide missing upgrade ordering"
|
|
expect_evidence_fixture_rejected "migration rollback rule omitted" docs/migrations/p1-to-p1-1-registry-layout.md migration-rollback-omitted "migration guide missing rollback rule"
|
|
p1_operations=(
|
|
acquisition materialization extraction preprocessing embeddings
|
|
qdrant-writes indexing active retention gc
|
|
)
|
|
p1_positive_forms=(
|
|
base third-person can may must will should
|
|
adverb-before-modal adverb-after-modal ownership
|
|
)
|
|
for operation in "${p1_operations[@]}"; do
|
|
for form in "${p1_positive_forms[@]}"; do
|
|
expect_evidence_fixture_rejected \
|
|
"appended P1.1 ${operation} ${form} claim" \
|
|
docs/contracts/workspace-evidence-v3.md "p1-append-${operation}-${form}" \
|
|
"P1.1 scope violation"
|
|
done
|
|
done
|
|
p1_safe_bases=(
|
|
"acquire Evidence" "materialize Evidence" "extract Evidence" "preprocess Evidence"
|
|
"create embeddings" "write embeddings to Qdrant" "index Evidence" 'publish `ACTIVE`'
|
|
"retain Evidence" "garbage-collect Evidence"
|
|
)
|
|
p1_safe_third_person=(
|
|
"acquires Evidence" "materializes Evidence" "extracts Evidence" "preprocesses Evidence"
|
|
"creates embeddings" "writes embeddings to Qdrant" "indexes Evidence" 'publishes `ACTIVE`'
|
|
"retains Evidence" "garbage-collects Evidence"
|
|
)
|
|
p1_safe_ownership=(
|
|
"Evidence acquisition" "Evidence materialization" "Evidence extraction"
|
|
"Evidence preprocessing" "Evidence embeddings" "Evidence Qdrant writes"
|
|
"Evidence indexing" 'Evidence `ACTIVE` publication' "Evidence retention" "Evidence GC"
|
|
)
|
|
for index in "${!p1_operations[@]}"; do
|
|
operation="${p1_operations[$index]}"
|
|
base="${p1_safe_bases[$index]}"
|
|
third_person="${p1_safe_third_person[$index]}"
|
|
ownership="${p1_safe_ownership[$index]}"
|
|
expect_evidence_claim_accepted "negative P1 ${operation} cannot" "P1 cannot ${base}."
|
|
expect_evidence_claim_accepted "negative P1 ${operation} must not" "P1 must not ${base}."
|
|
expect_evidence_claim_accepted "negative P1 ${operation} does not" "P1 does not ${base}."
|
|
expect_evidence_claim_accepted "negative P1 ${operation} never" "P1 never ${third_person}."
|
|
expect_evidence_claim_accepted \
|
|
"later plan ${operation}" "A later plan may assign ${ownership} to P1."
|
|
expect_evidence_claim_accepted "P2 ${operation}" "P2 may directly ${base}."
|
|
expect_evidence_claim_accepted "P6 ${operation}" "P6 ${third_person}."
|
|
done
|
|
expect_evidence_fixture_rejected \
|
|
"config check option reordered" docs/contracts/workspace-evidence-v3.md config-ordering \
|
|
"exact config-check ordering missing"
|
|
expect_evidence_fixture_rejected \
|
|
"acceptance states conflated" docs/contracts/workspace-evidence-v3.md acceptance-conflation \
|
|
"separate automated/manual states missing"
|
|
|
|
if (( negative_failures != 0 )); then
|
|
echo "$negative_failures unsafe installation-document fixtures were accepted" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "unsafe installation-document fixtures rejected passed"
|