diff --git a/backend/src/workspaces/bindings.ts b/backend/src/workspaces/bindings.ts index 91e1a099..a5f01015 100644 --- a/backend/src/workspaces/bindings.ts +++ b/backend/src/workspaces/bindings.ts @@ -199,5 +199,5 @@ export function resolveRuntimeBindings( export function supportsSessionRuntime(bindings: RuntimeBindings): boolean { return bindings.dwh.transport !== "ssh_tunnel" && bindings.vector.transport !== "ssh_tunnel" - && (bindings.evidence?.missing.length ?? 0) === 0; + && bindings.evidence.missing.length === 0; } diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 31d085c2..d93b019c 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -2800,15 +2800,19 @@ test("POST /sessions bootstrap failure emits only a fixed recovery message", asy test.each([ - { mode: "missing signed Evidence file", evidence: true, safe: false, expectedStatus: 409, reachesReadiness: false }, - { mode: "safe signed Evidence file", evidence: true, safe: true, expectedStatus: 503, reachesReadiness: true }, - { mode: "no Evidence descriptor", evidence: false, safe: false, expectedStatus: 503, reachesReadiness: true }, + { mode: "missing signed Evidence file", evidence: true, binding: "missing", expectedStatus: 409, reachesReadiness: false }, + { mode: "unsafe signed Evidence file", evidence: true, binding: "unsafe", expectedStatus: 409, reachesReadiness: false }, + { mode: "safe signed Evidence file", evidence: true, binding: "safe", expectedStatus: 503, reachesReadiness: true }, + { mode: "no Evidence descriptor", evidence: false, binding: "missing", expectedStatus: 503, reachesReadiness: true }, ])("real buildApp admission handles $mode before Pi spawn", async ({ - evidence, safe, expectedStatus, reachesReadiness, + evidence, binding, expectedStatus, reachesReadiness, }) => { const root = mkdtempSync(path.join(tmpdir(), "thoth-evidence-admission-")); + const unsafeRoot = mkdtempSync(path.join(tmpdir(), "thoth-evidence-unsafe-")); const signedFile = path.join(root, "signed-urls.json"); + const unsafeFile = path.join(unsafeRoot, "signed-urls.json"); writeFileSync(signedFile, '["CANARY-SIGNED-QUERY"]'); + writeFileSync(unsafeFile, '["CANARY-UNSAFE-SIGNED-QUERY"]'); const variable = "THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE"; const previous = { transport: process.env.THT_WS_PSD_CLINICAL_DWH_TRANSPORT, @@ -2817,7 +2821,8 @@ test.each([ }; process.env.THT_WS_PSD_CLINICAL_DWH_TRANSPORT = "rest_api"; process.env.THT_WS_PSD_CLINICAL_DWH_BASE_URL = "https://dwh.example.test"; - if (safe) process.env[variable] = signedFile; + if (binding === "safe") process.env[variable] = signedFile; + else if (binding === "unsafe") process.env[variable] = unsafeFile; else delete process.env[variable]; const descriptor = { @@ -2882,6 +2887,7 @@ test.each([ expect(JSON.stringify(descriptor)).toBe(canonicalBefore); expect(revision.commit).toBe("a".repeat(40)); expect(response.body).not.toContain("CANARY-SIGNED-QUERY"); + expect(response.body).not.toContain("CANARY-UNSAFE-SIGNED-QUERY"); } finally { const restore = (name: string, value: string | undefined) => { if (value === undefined) delete process.env[name]; @@ -2891,5 +2897,6 @@ test.each([ restore("THT_WS_PSD_CLINICAL_DWH_BASE_URL", previous.baseUrl); restore(variable, previous.signed); rmSync(root, { recursive: true, force: true }); + rmSync(unsafeRoot, { recursive: true, force: true }); } }); diff --git a/backend/test/routes-workspaces.test.ts b/backend/test/routes-workspaces.test.ts index f9f9e931..083ae35d 100644 --- a/backend/test/routes-workspaces.test.ts +++ b/backend/test/routes-workspaces.test.ts @@ -5,6 +5,7 @@ import { expect, test, vi } from "vitest"; import yazl from "yazl"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; +import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostics.js"; import { WorkspaceRegistryError } from "../src/workspaces/git-repository.js"; import type { WorkspaceRegistry, WorkspaceRevision } from "../src/workspaces/registry.js"; import { renderWorkspaceDocs, serializeWorkspaceYaml, type CanonicalWorkspace, type WorkspaceV2 } from "../src/workspaces/schema.js"; @@ -283,6 +284,51 @@ test("runs diagnostics for a schema v3 workspace without external semantic bindi }), { writeProbe: false }); }); +test("reports missing Evidence binding through the real test route without changing registry revision", async () => { + const evidenceWorkspace: CanonicalWorkspace = { + ...workspace, + evidence: { + source: { + type: "http", + uris: ["https://evidence.example.test/guide.md"], + authentication: "signed_urls_file", + connect_timeout_ms: 5_000, + read_timeout_ms: 30_000, + max_bytes: 10 * 1024 * 1024, + max_redirects: 5, + allow_private_hosts: false, + max_cache_bytes: 64 * 1024 * 1024, + }, + policy: { max_chunk_chars: 4_000, retain_published_generations: 3 }, + }, + }; + const read = vi.fn(async () => ({ workspace: evidenceWorkspace, revision })); + const registry = registryFake({ read }); + const app = appFor(registry, createProductionWorkspaceDiagnoser(100)); + const variable = "THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE"; + const previous = process.env[variable]; + delete process.env[variable]; + + try { + const res = await app.inject({ method: "POST", url: "/workspaces/psd-clinical/test", payload: {} }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.activatable).toBe(false); + expect(body.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ + code: "binding_missing", + field: "evidence.source.authentication", + variable, + })])); + expect(read).toHaveBeenCalledTimes(1); + expect(registry.publish).not.toHaveBeenCalled(); + expect(revision).toMatchObject({ commit: "a".repeat(40), blob: "b".repeat(40) }); + } finally { + if (previous === undefined) delete process.env[variable]; + else process.env[variable] = previous; + } +}); + test("returns a 409 field conflict instead of overwriting a changed workspace", async () => { const conflict = Object.assign( new WorkspaceRegistryError("workspace_conflict", "Workspace has changed"), diff --git a/backend/test/workspace-runtime-renderer.test.ts b/backend/test/workspace-runtime-renderer.test.ts index 166bf2b6..7bba52a7 100644 --- a/backend/test/workspace-runtime-renderer.test.ts +++ b/backend/test/workspace-runtime-renderer.test.ts @@ -123,6 +123,7 @@ const directBindings: RuntimeBindings = { missing: [], values: { THT_WS_PSD_CLINICAL_EMBEDDING_BASE_URL: "http://embedding.internal:11434" }, }, + evidence: { missing: [], values: {} }, }; test("runtime support stays fail-closed for either SSH connector", () => { diff --git a/harness/tests/test_config_resources.py b/harness/tests/test_config_resources.py index 25db5d8f..d20853cc 100644 --- a/harness/tests/test_config_resources.py +++ b/harness/tests/test_config_resources.py @@ -363,16 +363,16 @@ evidence: root: {tmp_path} max_bytes: 123 - type: http - urls: ['https://example.test/doc.md?token=transport-only'] + urls: ['https://example.test/doc.md'] """) cfg = load_config(modern) - assert "transport-only" not in repr(cfg.evidence) - assert "transport-only" not in cfg.evidence.model_dump_json() + assert "example.test" not in repr(cfg.evidence) + assert "example.test" not in cfg.evidence.model_dump_json() assert cfg.evidence.sources[1].allow_private_hosts is False sources = build_evidence_sources(cfg) assert isinstance(sources[0], FilesystemEvidenceSource) assert isinstance(sources[1], HttpManifestEvidenceSource) - assert "transport-only" not in repr(sources[1]) + assert "example.test" not in repr(sources[1]) legacy = tmp_path / "legacy.yaml" (tmp_path / "curated").mkdir() diff --git a/harness/tests/test_registry_evidence_config.py b/harness/tests/test_registry_evidence_config.py index 996f1ac5..89019985 100644 --- a/harness/tests/test_registry_evidence_config.py +++ b/harness/tests/test_registry_evidence_config.py @@ -1,4 +1,5 @@ import json +import traceback import pytest import yaml @@ -112,6 +113,33 @@ def test_signed_http_file_resolves_in_memory_and_preserves_provenance_order(tmp_ assert_no_canaries(repr(adapter)) +def test_signed_http_file_requires_explicit_provenance_urls(tmp_path): + secret_file = tmp_path / "signed-urls.json" + secret_file.write_text(json.dumps([ + f"https://evidence.example.test/guide.md?token={SIGNED_CANARY}", + ])) + path = write_config(tmp_path, { + "type": "http", "signed_urls_file": str(secret_file), + }) + + with pytest.raises(ConfigError) as caught: + load_config(path) + assert "provenance" in str(caught.value).lower() + assert_no_canaries(caught.value) + + +def test_public_http_rejects_inline_query_bearing_transport_urls(tmp_path): + path = write_config(tmp_path, { + "type": "http", + "urls": [f"https://evidence.example.test/guide.md?token={SIGNED_CANARY}"], + }) + + with pytest.raises(ConfigError) as caught: + load_config(path) + assert "evidence.sources.0" in str(caught.value) + assert_no_canaries(caught.value) + + @pytest.mark.parametrize("contents", [ "{malformed", json.dumps({"url": "https://evidence.example.test/guide.md"}), json.dumps([]), json.dumps(["https://evidence.example.test/guide.md", 3]), @@ -264,6 +292,7 @@ def test_validation_repr_cli_and_exception_output_never_disclose_transport_secre with pytest.raises(ConfigError) as caught: load_config(path) assert_no_canaries(caught.value) + assert_no_canaries("".join(traceback.format_exception(caught.value))) valid_file = tmp_path / "valid-signed-urls.json" valid_file.write_text(json.dumps([ diff --git a/harness/tht/config.py b/harness/tht/config.py index 1dbe5de0..102e6b99 100644 --- a/harness/tht/config.py +++ b/harness/tht/config.py @@ -60,6 +60,8 @@ def _resolve_http_signed_url_files(value: Any) -> Any: return resolved if "urls" in resolved: raise ConfigError("HTTP signed URL file cannot be combined with urls") + if "provenance_urls" not in resolved: + raise ConfigError("HTTP signed URL file requires provenance_urls") path_value = resolved.pop("signed_urls_file") if not isinstance(path_value, str): raise ConfigError("Invalid signed URL file reference") @@ -76,8 +78,8 @@ def _resolve_http_signed_url_files(value: Any) -> Any: if len(payload) > _MAX_SIGNED_URL_FILE_BYTES: raise OSError parsed = json.loads(payload.decode("utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError) as exc: - raise ConfigError("Cannot read signed URL file") from exc + except (OSError, UnicodeError, json.JSONDecodeError): + raise ConfigError("Cannot read signed URL file") from None if ( not isinstance(parsed, list) or not parsed @@ -307,6 +309,8 @@ class HttpEvidenceSourceConfig(BaseModel): ): raise ValueError("HTTP transport URL must use http or https") if self.provenance_urls is None: + if canonical != transport_urls or len(set(canonical)) != len(canonical): + raise ValueError("Public HTTP URLs must be canonical query-free identities") return self try: provenance = [canonical_provenance_uri(url) for url in self.provenance_urls] @@ -532,7 +536,7 @@ def load_config(path: Path) -> Config: cfg = Config.model_validate(translated) except ValidationError as e: details = _format_validation_error(e) - raise ConfigError(f"Configurazione non valida in {path}:\n{details}") from e + raise ConfigError(f"Configurazione non valida in {path}:\n{details}") from None env_profile = os.environ.get("THT_PROFILE") if env_profile is not None: if env_profile not in ("server", "workstation"):