diff --git a/backend/src/workspaces/schema.ts b/backend/src/workspaces/schema.ts index b8962e88..47beb0b9 100644 --- a/backend/src/workspaces/schema.ts +++ b/backend/src/workspaces/schema.ts @@ -104,7 +104,52 @@ interface QdrantVectorStore { distance: "cosine"; } -export interface WorkspaceV3 extends WorkspaceBase<3, QdrantVectorStore> {} +export interface EvidencePolicy { + max_chunk_chars: number; + retain_published_generations: number; +} + +export type EvidenceSource = + | { + type: "filesystem"; + uri: string; + patterns: string[]; + max_bytes: number; + } + | { + type: "http"; + uris: string[]; + authentication: "none" | "signed_urls_file"; + connect_timeout_ms: number; + read_timeout_ms: number; + max_bytes: number; + max_redirects: number; + allow_private_hosts: boolean; + max_cache_bytes: number; + } + | { + type: "s3"; + uri: string; + endpoint_url?: string; + region?: string; + credentials: "ambient" | "static_files"; + trusted_endpoint: boolean; + allow_private_endpoint: boolean; + allow_insecure_endpoint: boolean; + max_bytes: number; + max_objects: number; + max_pages: number; + page_size: number; + }; + +export interface WorkspaceEvidence { + source: EvidenceSource; + policy: EvidencePolicy; +} + +export interface WorkspaceV3 extends WorkspaceBase<3, QdrantVectorStore> { + evidence?: WorkspaceEvidence; +} export interface WorkspaceV2 extends WorkspaceBase<2, VectorStore & { database: string; schema: string }> {} /** A readable, non-operational v1 descriptor. It must be explicitly migrated before use. */ @@ -218,6 +263,147 @@ const llmPolicySchema = z.object({ allowed: z.array(modelReference).min(1), }).strict(); +const positiveSafeInteger = z.number().int().safe().positive(); +const nonnegativeSafeInteger = z.number().int().safe().nonnegative(); + +function isSafeEvidencePattern(value: string): boolean { + const parts = value.split("/"); + return value.length > 0 + && !value.startsWith("/") + && !value.includes("\\") + && !/[\u0000-\u001f\u007f]/u.test(value) + && parts.every((part) => part !== "" && part !== "." && part !== ".."); +} + +function parsePublicHttpUri(value: string): URL | undefined { + try { + const parsed = new URL(value); + if ( + !["http:", "https:"].includes(parsed.protocol) + || parsed.hostname.length === 0 + || parsed.username !== "" + || parsed.password !== "" + || parsed.search !== "" + || parsed.hash !== "" + ) return undefined; + return parsed; + } catch { + return undefined; + } +} + +function canonicalPublicHttpUri(value: string): string | undefined { + return parsePublicHttpUri(value)?.href; +} + +function isSafeS3Uri(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.protocol === "s3:" + && parsed.hostname.length > 0 + && parsed.username === "" + && parsed.password === "" + && parsed.search === "" + && parsed.hash === ""; + } catch { + return false; + } +} + +function isSafeS3Endpoint(value: string): boolean { + const parsed = parsePublicHttpUri(value); + return parsed !== undefined && (parsed.pathname === "/" || parsed.pathname === ""); +} + +const evidencePattern = z.string().refine(isSafeEvidencePattern, { + message: "evidence patterns must be normalized relative globs", +}); +const filesystemEvidenceSourceSchema = z.object({ + type: z.literal("filesystem"), + uri: z.string(), + patterns: z.array(evidencePattern).min(1).default(["**/*.md"]), + max_bytes: positiveSafeInteger.default(10 * 1024 * 1024), +}).strict().superRefine((source, context) => { + if (new Set(source.patterns).size !== source.patterns.length) { + context.addIssue({ code: "custom", path: ["patterns"], message: "evidence patterns must not repeat" }); + } +}); +const httpEvidenceUri = z.string().refine((value) => parsePublicHttpUri(value) !== undefined, { + message: "HTTP evidence URIs must be public http(s) identities without credentials, query, or fragment", +}); +const httpEvidenceSourceSchema = z.object({ + type: z.literal("http"), + uris: z.array(httpEvidenceUri).min(1), + authentication: z.enum(["none", "signed_urls_file"]).default("none"), + connect_timeout_ms: positiveSafeInteger.default(5_000), + read_timeout_ms: positiveSafeInteger.default(30_000), + max_bytes: positiveSafeInteger.default(10 * 1024 * 1024), + max_redirects: nonnegativeSafeInteger.default(5), + allow_private_hosts: z.boolean().default(false), + max_cache_bytes: positiveSafeInteger.default(64 * 1024 * 1024), +}).strict().superRefine((source, context) => { + const canonical = source.uris.map(canonicalPublicHttpUri); + if (new Set(canonical).size !== canonical.length) { + context.addIssue({ code: "custom", path: ["uris"], message: "HTTP evidence URIs must not repeat" }); + } +}); +const s3EvidenceSourceSchema = z.object({ + type: z.literal("s3"), + uri: z.string().refine(isSafeS3Uri, { + message: "S3 evidence URI must use s3:// without credentials, query, or fragment", + }), + endpoint_url: z.string().refine(isSafeS3Endpoint, { + message: "S3 endpoint must be an origin-only http(s) URL without credentials", + }).optional(), + region: z.string().trim().min(1).optional(), + credentials: z.enum(["ambient", "static_files"]).default("ambient"), + trusted_endpoint: z.boolean().default(false), + allow_private_endpoint: z.boolean().default(false), + allow_insecure_endpoint: z.boolean().default(false), + max_bytes: positiveSafeInteger.default(10 * 1024 * 1024), + max_objects: positiveSafeInteger.default(10_000), + max_pages: positiveSafeInteger.default(100), + page_size: positiveSafeInteger.max(1_000).default(1_000), +}).strict().superRefine((source, context) => { + if (source.endpoint_url === undefined) { + if (source.trusted_endpoint || source.allow_private_endpoint || source.allow_insecure_endpoint) { + context.addIssue({ + code: "custom", path: ["endpoint_url"], + message: "S3 endpoint policy requires endpoint_url", + }); + } + return; + } + if (!source.trusted_endpoint) { + context.addIssue({ + code: "custom", path: ["trusted_endpoint"], + message: "custom S3 endpoints must be explicitly trusted", + }); + } + if (source.endpoint_url.startsWith("http:") && !source.allow_insecure_endpoint) { + context.addIssue({ + code: "custom", path: ["allow_insecure_endpoint"], + message: "HTTP S3 endpoints require an explicit insecure opt-in", + }); + } +}); +const evidenceSourceSchema = z.discriminatedUnion("type", [ + filesystemEvidenceSourceSchema, + httpEvidenceSourceSchema, + s3EvidenceSourceSchema, +]); +const evidencePolicySchema = z.object({ + max_chunk_chars: positiveSafeInteger.default(4_000), + retain_published_generations: positiveSafeInteger.default(3), +}).strict(); +const workspaceEvidenceSchema = z.object({ + source: evidenceSourceSchema, + policy: evidencePolicySchema.default({ + max_chunk_chars: 4_000, + retain_published_generations: 3, + }), +}).strict(); + function unique(values: readonly T[], context: z.RefinementCtx, path: PropertyKey[]) { if (new Set(values).size !== values.length) { context.addIssue({ code: "custom", path, message: "supported transports must not repeat" }); @@ -235,6 +421,17 @@ function workspaceInvariants(workspace: any, context: z.RefinementCtx): void { } unique(workspace.llm_policy.allowed, context, ["llm_policy", "allowed"]); + if (workspace.evidence?.source.type === "filesystem") { + const expected = `workspace-content/${workspace.workspace.id}/evidence`; + if (workspace.evidence.source.uri !== expected) { + context.addIssue({ + code: "custom", + path: ["evidence", "source", "uri"], + message: "filesystem evidence URI must be the canonical workspace Evidence root", + }); + } + } + if (workspace.semantic_index.vector_store.dimensions !== workspace.semantic_index.embedding.dimensions) { context.addIssue({ code: "custom", @@ -303,6 +500,7 @@ const WorkspaceV2Schema = z.object({ const WorkspaceV3Schema = z.object({ dwh: dwhSchema, llm_policy: llmPolicySchema, + evidence: workspaceEvidenceSchema.optional(), diagnostics: z.object({ dwh_rest: dwhRestDiagnostic.optional(), }).strict().optional(), diff --git a/backend/src/workspaces/types.ts b/backend/src/workspaces/types.ts index 574caacb..25349274 100644 --- a/backend/src/workspaces/types.ts +++ b/backend/src/workspaces/types.ts @@ -16,96 +16,4 @@ export type WorkspaceErrorCode = | "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected" | "connector_unavailable" | "semantic_index_incompatible"; -export interface QdrantVectorStore { - engine: "qdrant"; - collection: string; - dimensions: 1024; - distance: "cosine"; -} - -export interface InternalEmbedding { - provider: "ollama_internal"; - model: "qwen3-embedding:0.6b"; - dimensions: 1024; -} - -export interface WorkspaceV2 { - workspace: { - schema_version: 2; - id: string; - name: string; - description?: string; - language: "en" | "it"; - }; - dwh: { - engine: "postgres"; - database: string; - schema: string; - port?: number; - timeout_ms?: number; - supported_transports: ("postgres_direct" | "rest_api" | "ssh_tunnel")[]; - }; - semantic_index: { - vector_store: { - engine: "pgvector"; - database: string; - schema: string; - collection: string; - dimensions: number; - distance: "cosine" | "l2" | "inner_product"; - port?: number; - timeout_ms?: number; - supported_transports: ("pgvector_direct" | "rest_api" | "ssh_tunnel")[]; - }; - vector_writer?: Record; - embedding: { - provider: "ollama_compatible" | "openai_compatible"; - model: string; - dimensions: number; - timeout_ms?: number; - }; - }; - llm_policy: { - default?: `${string}/${string}`; - allowed: `${string}/${string}`[]; - }; - diagnostics?: { - dwh_rest?: { - method: "GET" | "POST"; - path: string; - auth: "none" | "bearer" | "x-api-key"; - response: { database: string; schema: string }; - }; - vector_rest?: { - metadata: { - method: "GET" | "POST"; - path: string; - auth: "none" | "bearer" | "x-api-key"; - response: { collection: string; dimensions: string; distance: string }; - }; - reversible_probe?: { - method: "POST"; - path: string; - auth: "bearer" | "x-api-key"; - response: { operation: string }; - }; - }; - embedding?: { - method: "GET" | "POST"; - path: string; - auth: "none" | "bearer" | "x-api-key"; - response: { model: string; dimensions: string }; - }; - }; -} - -export interface WorkspaceV3 { - workspace: WorkspaceV2["workspace"] & { schema_version: 3 }; - dwh: WorkspaceV2["dwh"]; - semantic_index: { - vector_store: QdrantVectorStore; - embedding: InternalEmbedding; - }; - llm_policy: WorkspaceV2["llm_policy"]; - diagnostics?: Pick, "dwh_rest">; -} +export type { WorkspaceV2, WorkspaceV3 } from "./schema.js"; diff --git a/backend/test/workspaces-migrate-legacy.test.ts b/backend/test/workspaces-migrate-legacy.test.ts index 51819dc3..71d05ac1 100644 --- a/backend/test/workspaces-migrate-legacy.test.ts +++ b/backend/test/workspaces-migrate-legacy.test.ts @@ -26,6 +26,7 @@ test("migrates the current local PSD descriptor without copying secret values", const result = migrateLegacyWorkspace(readFixture("local.yaml"), { id: "local", collection: "local" }); expect(result.workspace.workspace).toMatchObject({ id: "local", schema_version: 3, language: "it" }); + expect(result.workspace).not.toHaveProperty("evidence"); expect(JSON.stringify(result)).not.toMatch(/password:|api_key:|\$\{THT_/i); }); diff --git a/backend/test/workspaces-migrate-v2-qdrant.test.ts b/backend/test/workspaces-migrate-v2-qdrant.test.ts index e2f9a995..ec6412a5 100644 --- a/backend/test/workspaces-migrate-v2-qdrant.test.ts +++ b/backend/test/workspaces-migrate-v2-qdrant.test.ts @@ -65,6 +65,7 @@ test("migrates a schema v2 workspace to the internal qdrant schema v3 shape", () const migrated = migrateWorkspaceV2ToV3(legacy, "psd-clinical"); + expect(migrated).not.toHaveProperty("evidence"); expect(migrated).toMatchObject({ workspace: { schema_version: 3, id: "psd-clinical", name: "Policlinico San Donato" }, dwh: legacy.dwh, diff --git a/backend/test/workspaces-schema.test.ts b/backend/test/workspaces-schema.test.ts index e70cadfd..fa53bf81 100644 --- a/backend/test/workspaces-schema.test.ts +++ b/backend/test/workspaces-schema.test.ts @@ -6,6 +6,7 @@ import { parseWorkspaceYaml, serializeWorkspaceYaml, validateCanonicalWorkspace, + validateWorkspaceDescriptor, type WorkspaceDescriptor, } from "../src/workspaces/schema.js"; @@ -272,3 +273,316 @@ test("serializes canonical YAML that parses back to the same workspace", () => { expect(serializeWorkspaceYaml(parseWorkspaceYaml(serialized))).toBe(serialized); expect(parseWorkspaceYaml(serialized)).toEqual(workspace); }); + + +function validWorkspaceObject(): Record { + return parseWorkspaceYaml(validYaml) as Record; +} + +function withEvidence(source: Record, policy?: Record): Record { + const workspace = structuredClone(validWorkspaceObject()); + workspace.evidence = policy === undefined ? { source } : { source, policy }; + return workspace; +} + +function expectSafeEvidenceError(workspace: unknown, path: RegExp, canary?: string): void { + let message = ""; + try { + validateWorkspaceDescriptor(workspace); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message.replace(/\s+/g, " ")).toMatch(path); + if (canary !== undefined) expect(message).not.toContain(canary); +} + +const explicitPolicy = { max_chunk_chars: 8_000, retain_published_generations: 5 }; + +const validEvidenceSources = [ + { + name: "filesystem with explicit values", + source: { + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + patterns: ["documents/**/*.pdf", "notes/*.md"], + max_bytes: 12_000_000, + }, + }, + { + name: "HTTP without authentication", + source: { + type: "http", + uris: ["https://evidence.example/manifest.json", "http://evidence.example/files/list.txt"], + authentication: "none", + connect_timeout_ms: 2_000, + read_timeout_ms: 20_000, + max_bytes: 12_000_000, + max_redirects: 2, + allow_private_hosts: false, + max_cache_bytes: 24_000_000, + }, + }, + { + name: "HTTP signed URL manifest", + source: { + type: "http", + uris: ["https://evidence.example/signed-urls.txt"], + authentication: "signed_urls_file", + connect_timeout_ms: 2_000, + read_timeout_ms: 20_000, + max_bytes: 12_000_000, + max_redirects: 2, + allow_private_hosts: true, + max_cache_bytes: 24_000_000, + }, + }, + { + name: "S3 with ambient credentials", + source: { + type: "s3", + uri: "s3://clinical-evidence/published/", + region: "eu-west-1", + credentials: "ambient", + trusted_endpoint: false, + allow_private_endpoint: false, + allow_insecure_endpoint: false, + max_bytes: 12_000_000, + max_objects: 2_000, + max_pages: 20, + page_size: 100, + }, + }, + { + name: "S3 with static-file credentials and a trusted endpoint", + source: { + type: "s3", + uri: "s3://clinical-evidence/published/", + endpoint_url: "https://objects.example", + region: "eu-west-1", + credentials: "static_files", + trusted_endpoint: true, + allow_private_endpoint: false, + allow_insecure_endpoint: false, + max_bytes: 12_000_000, + max_objects: 2_000, + max_pages: 20, + page_size: 100, + }, + }, +] as const; + +test.each(validEvidenceSources)("accepts evidence source: $name", ({ source }) => { + expect(validateWorkspaceDescriptor(withEvidence(source, explicitPolicy))).toMatchObject({ + evidence: { source, policy: explicitPolicy }, + }); +}); + +test("applies filesystem and policy defaults to the canonical descriptor", () => { + const parsed = validateWorkspaceDescriptor(withEvidence({ + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + })); + + expect(parsed).toMatchObject({ + evidence: { + source: { + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + patterns: ["**/*.md"], + max_bytes: 10 * 1024 * 1024, + }, + policy: { max_chunk_chars: 4_000, retain_published_generations: 3 }, + }, + }); +}); + +test("keeps evidence optional on schema v3", () => { + expect(validateWorkspaceDescriptor(validWorkspaceObject())).not.toHaveProperty("evidence"); +}); + +test("serializes defaulted evidence canonically and parses it without loss", () => { + const canonical = validateWorkspaceDescriptor(withEvidence({ + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + })); + if (canonical.workspace.schema_version !== 3) throw new Error("expected schema v3"); + + expect(parseWorkspaceYaml(serializeWorkspaceYaml(canonical))).toEqual(canonical); +}); + +const invalidFilesystemPaths = [ + "/workspace-content/psd-clinical/evidence", + "workspace-content/../psd-clinical/evidence", + "workspace-content/./psd-clinical/evidence", + "workspace-content//psd-clinical/evidence", + "workspace-content/psd-clinical/evidence/..", + "workspace-content\\psd-clinical\\evidence", + "workspace-content/psd-clinical/evidence\u0000", + "workspace-content/other-workspace/evidence", + "workspace-content/psd-clinical", + "workspace-content/psd-clinical/evidence/nested", +]; + +test.each(invalidFilesystemPaths)("rejects unsafe or noncanonical filesystem URI %#", (uri) => { + expectSafeEvidenceError(withEvidence({ type: "filesystem", uri }), /evidence.*source.*uri/i); +}); + +const invalidPatterns = ["", "/absolute", "../escape", ".", "folder/./file", "folder//file", "folder/../file", "a\\b", "a\u0007b"]; + +test.each(invalidPatterns)("rejects unsafe evidence glob %#", (pattern) => { + expectSafeEvidenceError(withEvidence({ + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + patterns: [pattern], + }), /evidence.*source.*patterns/i); +}); + +test("rejects empty and duplicate filesystem patterns", () => { + const source = { type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }; + expectSafeEvidenceError(withEvidence({ ...source, patterns: [] }), /patterns/i); + expectSafeEvidenceError(withEvidence({ ...source, patterns: ["**/*.pdf", "**/*.pdf"] }), /patterns/i); +}); + +test.each(["ftp", "git", "unknown"])("rejects unsupported evidence discriminator %s", (type) => { + expectSafeEvidenceError(withEvidence({ type, uri: "workspace-content/psd-clinical/evidence" }), /evidence.*source.*type/i); +}); + +test("rejects unknown evidence keys", () => { + expectSafeEvidenceError({ ...withEvidence({ + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + }), evidence: { + source: { type: "filesystem", uri: "workspace-content/psd-clinical/evidence", mystery: true }, + policy: explicitPolicy, + mystery: true, + } }, /unrecognized|mystery/i); +}); + +const credentialFields = [ + "password", "api_key", "access_key", "secret_key", "session_token", "signed_url", "headers", "ca_contents", +]; + +test.each(credentialFields)("rejects credential-shaped evidence field %s without leaking it", (field) => { + const canary = `CANARY-${field}-DO-NOT-LEAK`; + expectSafeEvidenceError(withEvidence({ + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + [field]: canary, + }), /evidence.*source/i, canary); +}); + +const invalidHttpUris = [ + "https://user:CANARY-HTTP@example.com/manifest", + "https://example.com/manifest?token=CANARY-HTTP", + "https://example.com/manifest#CANARY-HTTP", + "ftp://example.com/manifest/CANARY-HTTP", +]; + +test.each(invalidHttpUris)("rejects unsafe HTTP descriptor URI %# without leaking it", (uri) => { + expectSafeEvidenceError(withEvidence({ type: "http", uris: [uri] }), /evidence.*source.*uris.*0/i, "CANARY-HTTP"); +}); + +test("rejects empty and canonically duplicate HTTP manifests", () => { + expectSafeEvidenceError(withEvidence({ type: "http", uris: [] }), /uris/i); + expectSafeEvidenceError(withEvidence({ + type: "http", + uris: ["https://EXAMPLE.com:443/manifest", "https://example.com/manifest"], + }), /uris/i); +}); + +const invalidHttpBounds = [ + ["connect_timeout_ms", 0], ["read_timeout_ms", 0], ["max_bytes", 0], + ["max_redirects", -1], ["max_cache_bytes", 0], ["connect_timeout_ms", Number.MAX_SAFE_INTEGER + 1], +] as const; + +test.each(invalidHttpBounds)("rejects invalid HTTP bound %s=%s", (field, value) => { + expectSafeEvidenceError(withEvidence({ + type: "http", + uris: ["https://example.com/manifest"], + [field]: value, + }), new RegExp(field, "i")); +}); + +const invalidS3Uris = [ + "https://bucket/prefix", "s3:///prefix", "s3://user:CANARY-S3@bucket/prefix", + "s3://bucket/prefix?token=CANARY-S3", "s3://bucket/prefix#CANARY-S3", +]; + +test.each(invalidS3Uris)("rejects invalid S3 URI %# without leaking it", (uri) => { + expectSafeEvidenceError(withEvidence({ type: "s3", uri }), /evidence.*source.*uri/i, "CANARY-S3"); +}); + +const invalidS3Endpoints = [ + { endpoint_url: "ftp://objects.example", trusted_endpoint: true }, + { endpoint_url: "https://user:CANARY-S3@objects.example", trusted_endpoint: true }, + { endpoint_url: "https://objects.example/path", trusted_endpoint: true }, + { endpoint_url: "https://objects.example?token=CANARY-S3", trusted_endpoint: true }, + { endpoint_url: "https://objects.example#CANARY-S3", trusted_endpoint: true }, + { endpoint_url: "https://objects.example", trusted_endpoint: false }, + { endpoint_url: "http://objects.example", trusted_endpoint: true, allow_insecure_endpoint: false }, + { trusted_endpoint: true }, + { allow_private_endpoint: true }, + { allow_insecure_endpoint: true }, +]; + +test.each(invalidS3Endpoints)("rejects unsafe or inconsistent S3 endpoint %#", (endpoint) => { + expectSafeEvidenceError(withEvidence({ type: "s3", uri: "s3://bucket/prefix", ...endpoint }), /evidence.*source/i, "CANARY-S3"); +}); + +const invalidS3Bounds = [ + ["max_bytes", 0], ["max_objects", 0], ["max_pages", 0], ["page_size", 0], + ["max_objects", Number.MAX_SAFE_INTEGER + 1], +] as const; + +test.each(invalidS3Bounds)("rejects invalid S3 bound %s=%s", (field, value) => { + expectSafeEvidenceError(withEvidence({ + type: "s3", uri: "s3://bucket/prefix", [field]: value, + }), new RegExp(field, "i")); +}); + +test.each([ + ["max_chunk_chars", 0], + ["max_chunk_chars", Number.MAX_SAFE_INTEGER + 1], + ["retain_published_generations", 0], + ["retain_published_generations", Number.MAX_SAFE_INTEGER + 1], +] as const)("rejects invalid evidence policy bound %s=%s", (field, value) => { + expectSafeEvidenceError(withEvidence({ + type: "filesystem", uri: "workspace-content/psd-clinical/evidence", + }, { ...explicitPolicy, [field]: value }), new RegExp(field, "i")); +}); + +test("rejects evidence on strict schema v1 and v2 descriptors", () => { + for (const schemaVersion of [1, 2]) { + const yaml = validYaml + .replace("schema_version: 3", `schema_version: ${schemaVersion}`) + .replace(`semantic_index: + vector_store: + engine: qdrant + collection: psd-clinical + dimensions: 1024 + distance: cosine + embedding: + provider: ollama_internal + model: qwen3-embedding:0.6b + dimensions: 1024`, `semantic_index: + vector_store: + engine: pgvector + database: postgres + schema: vectors + collection: documents + dimensions: 1024 + distance: cosine + supported_transports: + - pgvector_direct + embedding: + provider: ollama_compatible + model: evidence-test + dimensions: 1024`) + + `evidence: + source: + type: filesystem + uri: workspace-content/psd-clinical/evidence +`; + expect(() => parseWorkspaceYaml(yaml)).toThrow(/evidence|unrecognized/i); + } +});