From a580c4ca8acd8a6ffad985b1bd9ee1c37e104912 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 9 Aug 2026 20:27:20 +0200 Subject: [PATCH] fix: preserve workspace evidence in browser drafts --- frontend/src/api/workspaces.test.ts | 139 +++++++++++++- frontend/src/api/workspaces.ts | 91 ++++++++- frontend/src/shell/WorkspaceEditor.test.tsx | 60 ++++++ frontend/src/shell/WorkspaceEditor.tsx | 19 ++ frontend/src/workspaces/drafts.test.ts | 137 ++++++++++++- frontend/src/workspaces/drafts.ts | 203 +++++++++++++++++++- 6 files changed, 637 insertions(+), 12 deletions(-) diff --git a/frontend/src/api/workspaces.test.ts b/frontend/src/api/workspaces.test.ts index 39e3308f..cfe7c285 100644 --- a/frontend/src/api/workspaces.test.ts +++ b/frontend/src/api/workspaces.test.ts @@ -1,7 +1,10 @@ import { expect, test } from "vitest"; import { http, HttpResponse } from "msw"; import { server } from "../test/msw"; -import { asWorkspaceConflict, importWorkspace, publishWorkspace, type CanonicalWorkspace } from "./workspaces"; +import { + asWorkspaceConflict, getWorkspace, importWorkspace, publishWorkspace, validateWorkspace, + type CanonicalWorkspace, +} from "./workspaces"; const workspace: CanonicalWorkspace = { workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" }, @@ -13,6 +16,27 @@ const workspace: CanonicalWorkspace = { llm_policy: { allowed: ["zai/glm-5.2"] }, }; +const evidenceWorkspace = { + ...workspace, + 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 }, + }, +} satisfies CanonicalWorkspace; + +const revision = { + id: "psd-clinical", + commit: "a".repeat(40), + blob: "b".repeat(40), + snapshotPath: "workspaces/psd-clinical.yaml", + state: "operational" as const, +}; + test("uploads a workspace bundle without JSON content type", async () => { let contentType: string | null = null; server.use(http.post("/api/workspaces/import", ({ request }) => { @@ -107,3 +131,116 @@ test("rejects a conflict payload that attempts to surface removed vector transpo expect(asWorkspaceConflict(error)).toBeUndefined(); }); + + +test("sanitizes read and validate responses while preserving Evidence", async () => { + server.use( + http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: evidenceWorkspace, revision })), + http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: evidenceWorkspace, contract: {} })), + ); + + const read = await getWorkspace("psd-clinical"); + const validated = await validateWorkspace(evidenceWorkspace); + + expect(read.workspace.evidence).toEqual(evidenceWorkspace.evidence); + expect(read.workspace).not.toBe(evidenceWorkspace); + expect(validated.workspace.evidence).toEqual(evidenceWorkspace.evidence); +}); + +test("rejects malformed workspace API responses instead of exposing unknown Evidence fields", async () => { + const malformed = { + ...evidenceWorkspace, + evidence: { ...evidenceWorkspace.evidence, signed_urls_file: "/run/secrets/urls" }, + }; + server.use( + http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: malformed, revision })), + http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: malformed, contract: {} })), + ); + + await expect(getWorkspace("psd-clinical")).rejects.toThrow(); + await expect(validateWorkspace(evidenceWorkspace)).rejects.toThrow(); +}); + +test("publishes Evidence without mutating or dropping it from the request", async () => { + let sent: unknown; + server.use(http.post("/api/workspaces/publish", async ({ request }) => { + sent = await request.json(); + return HttpResponse.json({ revision }); + })); + + await publishWorkspace({ + action: "update", workspace: evidenceWorkspace, + baseCommit: revision.commit, baseBlob: revision.blob, + }); + + expect(sent).toMatchObject({ workspace: { evidence: evidenceWorkspace.evidence } }); + expect(evidenceWorkspace.evidence.source.patterns).toEqual(["**/*.md"]); +}); + +const evidenceConflictFields = [ + "evidence", + "evidence.source", + "evidence.source.type", + "evidence.source.uri", + "evidence.source.patterns", + "evidence.source.max_bytes", + "evidence.source.uris", + "evidence.source.authentication", + "evidence.source.connect_timeout_ms", + "evidence.source.read_timeout_ms", + "evidence.source.max_redirects", + "evidence.source.allow_private_hosts", + "evidence.source.max_cache_bytes", + "evidence.source.endpoint_url", + "evidence.source.region", + "evidence.source.credentials", + "evidence.source.trusted_endpoint", + "evidence.source.allow_private_endpoint", + "evidence.source.allow_insecure_endpoint", + "evidence.source.max_objects", + "evidence.source.max_pages", + "evidence.source.page_size", + "evidence.policy", + "evidence.policy.max_chunk_chars", + "evidence.policy.retain_published_generations", +] as const; + +test.each(evidenceConflictFields)("accepts canonical Evidence conflict field %s", async (field) => { + server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ + code: "workspace_conflict", + message: "Workspace changed in the registry.", + fields: [field], + expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, + actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, + base: evidenceWorkspace, + local: evidenceWorkspace, + remote: evidenceWorkspace, + }, { status: 409 }))); + + const error = await publishWorkspace({ + action: "update", workspace: evidenceWorkspace, + baseCommit: "a".repeat(40), baseBlob: "b".repeat(40), + }).catch((cause: unknown) => cause); + + expect(asWorkspaceConflict(error)).toMatchObject({ fields: [field] }); +}); + +test("rejects unknown Evidence conflict paths", async () => { + server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({ + code: "workspace_conflict", + message: "Workspace changed in the registry.", + fields: ["evidence.source.signed_url"], + expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, + actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, + base: evidenceWorkspace, + local: evidenceWorkspace, + remote: evidenceWorkspace, + }, { status: 409 }))); + + const error = await publishWorkspace({ + action: "update", workspace: evidenceWorkspace, + baseCommit: "a".repeat(40), baseBlob: "b".repeat(40), + }).catch((cause: unknown) => cause); + + expect(asWorkspaceConflict(error)).toBeUndefined(); +}); diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts index 66119600..b9551c5c 100644 --- a/frontend/src/api/workspaces.ts +++ b/frontend/src/api/workspaces.ts @@ -17,6 +17,49 @@ export interface CanonicalDiagnostics { dwh_rest?: RestDiagnosticRequest & { response: { database: string; schema: string } }; } +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 CanonicalWorkspace { workspace: { schema_version: 3; @@ -48,6 +91,7 @@ export interface CanonicalWorkspace { }; llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[] }; diagnostics?: CanonicalDiagnostics; + evidence?: WorkspaceEvidence; } export interface WorkspaceRevision { @@ -132,6 +176,16 @@ const conflictFields = new Set([ "diagnostics", "diagnostics.dwh_rest", "diagnostics.dwh_rest.method", "diagnostics.dwh_rest.path", "diagnostics.dwh_rest.auth", "diagnostics.dwh_rest.response.database", "diagnostics.dwh_rest.response.schema", + "evidence", "evidence.source", "evidence.source.type", "evidence.source.uri", + "evidence.source.patterns", "evidence.source.max_bytes", "evidence.source.uris", + "evidence.source.authentication", "evidence.source.connect_timeout_ms", + "evidence.source.read_timeout_ms", "evidence.source.max_redirects", + "evidence.source.allow_private_hosts", "evidence.source.max_cache_bytes", + "evidence.source.endpoint_url", "evidence.source.region", "evidence.source.credentials", + "evidence.source.trusted_endpoint", "evidence.source.allow_private_endpoint", + "evidence.source.allow_insecure_endpoint", "evidence.source.max_objects", + "evidence.source.max_pages", "evidence.source.page_size", "evidence.policy", + "evidence.policy.max_chunk_chars", "evidence.policy.retain_published_generations", ]); const workspaceErrorCodes = new Set([ @@ -193,20 +247,43 @@ export function asWorkspaceConflict(error: unknown): WorkspaceConflict | undefin }; } +function requireCanonicalWorkspace(value: unknown): CanonicalWorkspace { + const workspace = sanitizeCanonicalWorkspace(value); + if (!workspace) throw new Error("Workspace API returned an invalid canonical workspace"); + return workspace; +} + export const listWorkspaces = () => apiFetch("/workspaces"); -export const getWorkspace = (id: string) => apiFetch(`/workspaces/${encodeURIComponent(id)}`); +export const getWorkspace = async (id: string): Promise => { + const response = await apiFetch(`/workspaces/${encodeURIComponent(id)}`); + const source = object(response); + if (!source) throw new Error("Workspace API returned an invalid workspace record"); + return { + workspace: requireCanonicalWorkspace(source.workspace), + revision: source.revision as WorkspaceRevision, + }; +}; export const getWorkspaceRegistryStatus = () => apiFetch("/workspace-registry/status"); export const pullWorkspaceRegistry = () => apiFetch("/workspace-registry/pull", { method: "POST" }); -export const validateWorkspace = (workspace: CanonicalWorkspace) => - apiFetch<{ workspace: CanonicalWorkspace; contract: unknown }>("/workspaces/validate", { - method: "POST", body: JSON.stringify({ workspace }), +export const validateWorkspace = async (workspace: CanonicalWorkspace) => { + const safe = requireCanonicalWorkspace(workspace); + const response = await apiFetch("/workspaces/validate", { + method: "POST", body: JSON.stringify({ workspace: safe }), }); + const source = object(response); + if (!source) throw new Error("Workspace API returned an invalid validation result"); + return { workspace: requireCanonicalWorkspace(source.workspace), contract: source.contract }; +}; export const testWorkspace = (id: string) => apiFetch(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" }); -export const publishWorkspace = (request: PublishWorkspaceRequest) => - apiFetch<{ revision: WorkspaceRevision } | undefined>("/workspaces/publish", { - method: "POST", body: JSON.stringify(request), +export const publishWorkspace = (request: PublishWorkspaceRequest) => { + const safeRequest: PublishWorkspaceRequest = request.action === "delete" + ? request + : { ...request, workspace: requireCanonicalWorkspace(request.workspace) }; + return apiFetch<{ revision: WorkspaceRevision } | undefined>("/workspaces/publish", { + method: "POST", body: JSON.stringify(safeRequest), }); +}; export const exportWorkspace = (id: string) => apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`); export const importWorkspace = (bundle: File) => { diff --git a/frontend/src/shell/WorkspaceEditor.test.tsx b/frontend/src/shell/WorkspaceEditor.test.tsx index 7919bc04..e4aee007 100644 --- a/frontend/src/shell/WorkspaceEditor.test.tsx +++ b/frontend/src/shell/WorkspaceEditor.test.tsx @@ -20,6 +20,19 @@ const workspace: CanonicalWorkspace = { llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] }, }; +const evidenceWorkspace: CanonicalWorkspace = { + ...workspace, + evidence: { + source: { + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + patterns: ["documents/**/*.pdf"], + max_bytes: 12_000_000, + }, + policy: { max_chunk_chars: 8_000, retain_published_generations: 5 }, + }, +}; + const draft: WorkspaceDraft = { workspaceId: "psd-clinical", baseCommit: "a".repeat(40), @@ -99,3 +112,50 @@ test("rejects a non-positive DWH timeout without saving a draft", async () => { expect(screen.getByLabelText("DWH timeout (ms)")).toHaveAttribute("aria-invalid", "true"); expect(onSaveDraft).not.toHaveBeenCalled(); }); + + +test("shows a safe read-only Evidence summary without authoring or secret binding controls", () => { + render(); + + const summary = screen.getByRole("region", { name: "Evidence" }); + expect(summary).toHaveTextContent("filesystem"); + expect(summary).toHaveTextContent("workspace-content/psd-clinical/evidence"); + expect(summary).toHaveTextContent("8,000"); + expect(summary).toHaveTextContent("5"); + expect(summary).toHaveTextContent("Evidence is managed by the registry descriptor in P1."); + expect(summary).not.toHaveTextContent(/signed_urls_file|static_files|secret|binding/i); + expect(screen.queryByLabelText(/evidence.*(source|uri|pattern|credential)/i)).not.toBeInTheDocument(); +}); + +test("publishes an edited DWH and LLM field without dropping or mutating Evidence", async () => { + const user = userEvent.setup(); + const onPublish = vi.fn().mockResolvedValue(undefined); + render(); + + await user.clear(screen.getByLabelText("DWH database")); + await user.type(screen.getByLabelText("DWH database"), "research"); + await user.clear(screen.getByLabelText("Allowed models")); + await user.type(screen.getByLabelText("Allowed models"), "openai/gpt-5"); + await user.click(screen.getByRole("button", { name: "Publish draft" })); + + expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({ + action: "update", + workspace: expect.objectContaining({ + dwh: expect.objectContaining({ database: "research" }), + llm_policy: { allowed: ["openai/gpt-5"] }, + evidence: evidenceWorkspace.evidence, + }), + })); + expect(evidenceWorkspace.evidence?.source).toEqual({ + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + patterns: ["documents/**/*.pdf"], + max_bytes: 12_000_000, + }); +}); + +test("does not show an Evidence summary for a workspace without Evidence", () => { + render(); + + expect(screen.queryByRole("region", { name: "Evidence" })).not.toBeInTheDocument(); +}); diff --git a/frontend/src/shell/WorkspaceEditor.tsx b/frontend/src/shell/WorkspaceEditor.tsx index cd7a85cf..93855c92 100644 --- a/frontend/src/shell/WorkspaceEditor.tsx +++ b/frontend/src/shell/WorkspaceEditor.tsx @@ -108,6 +108,23 @@ function Section({ title, children }: { title: string; children: React.ReactNode ); } +function EvidenceSummary({ evidence }: { evidence: NonNullable }) { + const sourceIdentity = evidence.source.type === "http" + ? `${evidence.source.uris.length} canonical URI${evidence.source.uris.length === 1 ? "" : "s"}` + : evidence.source.uri; + return ( +
+
+
Source type
{evidence.source.type}
+
Source
{sourceIdentity}
+
Chunk size
{evidence.policy.max_chunk_chars.toLocaleString("en-US")} characters
+
Retention
{evidence.policy.retain_published_generations.toLocaleString("en-US")} published generations
+
+

Evidence is managed by the registry descriptor in P1.

+
+ ); +} + const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 aria-invalid:border-destructive"; export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Boolean(draft?.baseBlob) }: WorkspaceEditorProps) { @@ -220,6 +237,8 @@ export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Bool + {workspace.evidence && } +

Hosts, users, secret-file paths, and credentials are installation bindings. They are intentionally not editable or stored in workspace drafts.

diff --git a/frontend/src/workspaces/drafts.test.ts b/frontend/src/workspaces/drafts.test.ts index cd83afa3..f2a48eed 100644 --- a/frontend/src/workspaces/drafts.test.ts +++ b/frontend/src/workspaces/drafts.test.ts @@ -1,6 +1,8 @@ import { beforeEach, expect, test } from "vitest"; import type { CanonicalWorkspace } from "../api/workspaces"; -import { workspaceDeletionDrafts, workspaceDrafts, workspacePreferences } from "./drafts"; +import { + sanitizeCanonicalWorkspace, workspaceDeletionDrafts, workspaceDrafts, workspacePreferences, +} from "./drafts"; const workspace: CanonicalWorkspace = { workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" }, @@ -17,6 +19,69 @@ const workspace: CanonicalWorkspace = { llm_policy: { allowed: ["zai/glm-5.2"] }, }; +const policy = { max_chunk_chars: 8_000, retain_published_generations: 5 }; + +const evidenceWorkspaces = [ + { + name: "filesystem", + workspace: { + ...workspace, + evidence: { + source: { + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + patterns: ["documents/**/*.pdf", "notes/*.md"], + max_bytes: 12_000_000, + }, + policy, + }, + } satisfies CanonicalWorkspace, + }, + { + name: "http", + workspace: { + ...workspace, + evidence: { + source: { + type: "http", + uris: ["https://evidence.example/manifest.json", "http://evidence.example/files/list.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: false, + max_cache_bytes: 24_000_000, + }, + policy, + }, + } satisfies CanonicalWorkspace, + }, + { + name: "s3", + workspace: { + ...workspace, + evidence: { + 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, + }, + policy, + }, + } satisfies CanonicalWorkspace, + }, +] as const; + test("keeps an anonymous user's model selection in browser storage", () => { workspacePreferences.save({ workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", @@ -146,3 +211,73 @@ test("rejects a draft that tries to persist removed external semantic configurat expect(workspaceDrafts.load("psd-clinical")).toBeUndefined(); expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toBeNull(); }); + + +test.each(evidenceWorkspaces)("deep-sanitizes canonical $name Evidence", ({ workspace: configured }) => { + const sanitized = sanitizeCanonicalWorkspace(configured); + + expect(sanitized).toEqual(configured); + expect(sanitized).not.toBe(configured); + expect(sanitized?.evidence).not.toBe(configured.evidence); + expect(sanitized?.evidence?.source).not.toBe(configured.evidence.source); + expect(sanitized?.evidence?.policy).not.toBe(configured.evidence.policy); +}); + +test.each(evidenceWorkspaces)("saves and reloads canonical $name Evidence", ({ workspace: configured }) => { + workspaceDrafts.save({ + workspaceId: "psd-clinical", + baseCommit: "a".repeat(40), + workspace: configured, + updatedAt: "2026-08-04T10:00:00.000Z", + }); + + expect(workspaceDrafts.load("psd-clinical")?.workspace.evidence).toEqual(configured.evidence); +}); + +test.each([ + ["an unknown Evidence key", { ...evidenceWorkspaces[0].workspace.evidence, extra: "unexpected" }], + ["a secret-shaped source key", { + ...evidenceWorkspaces[1].workspace.evidence, + source: { ...evidenceWorkspaces[1].workspace.evidence.source, signed_urls_file: "/run/secrets/urls" }, + }], + ["an HTTP URI with credentials", { + ...evidenceWorkspaces[1].workspace.evidence, + source: { ...evidenceWorkspaces[1].workspace.evidence.source, uris: ["https://user:secret@evidence.example/file"] }, + }], + ["an HTTP URI with a signed query", { + ...evidenceWorkspaces[1].workspace.evidence, + source: { ...evidenceWorkspaces[1].workspace.evidence.source, uris: ["https://evidence.example/file?token=secret"] }, + }], + ["an unsafe S3 URI", { + ...evidenceWorkspaces[2].workspace.evidence, + source: { ...evidenceWorkspaces[2].workspace.evidence.source, uri: "s3://user:secret@clinical-evidence/published/" }, + }], + ["an invalid zero policy value", { + ...evidenceWorkspaces[0].workspace.evidence, + policy: { ...policy, max_chunk_chars: 0 }, + }], + ["an unsafe integer policy value", { + ...evidenceWorkspaces[0].workspace.evidence, + policy: { ...policy, retain_published_generations: Number.MAX_SAFE_INTEGER + 1 }, + }], + ["a malformed source union", { + ...evidenceWorkspaces[0].workspace.evidence, + source: { ...evidenceWorkspaces[0].workspace.evidence.source, uris: ["https://evidence.example/file"] }, + }], +])("rejects %s instead of putting it in browser state", (_reason, evidence) => { + const invalid = { ...workspace, evidence }; + expect(sanitizeCanonicalWorkspace(invalid)).toBeUndefined(); + + workspaceDrafts.save({ + workspaceId: "psd-clinical", + baseCommit: "a".repeat(40), + workspace: invalid as CanonicalWorkspace, + updatedAt: "2026-08-04T10:00:00.000Z", + }); + expect(workspaceDrafts.load("psd-clinical")).toBeUndefined(); +}); + +test("continues to sanitize workspaces without Evidence", () => { + expect(sanitizeCanonicalWorkspace(workspace)).toEqual(workspace); + expect(sanitizeCanonicalWorkspace(workspace)).not.toHaveProperty("evidence"); +}); diff --git a/frontend/src/workspaces/drafts.ts b/frontend/src/workspaces/drafts.ts index 2bf7818c..03e762b1 100644 --- a/frontend/src/workspaces/drafts.ts +++ b/frontend/src/workspaces/drafts.ts @@ -1,4 +1,7 @@ -import type { CanonicalDiagnostics, CanonicalWorkspace, RestDiagnosticRequest } from "../api/workspaces"; +import type { + CanonicalDiagnostics, CanonicalWorkspace, EvidencePolicy, EvidenceSource, + RestDiagnosticRequest, WorkspaceEvidence, +} from "../api/workspaces"; export { workspacePreferences, type WorkspacePreference } from "./preferences"; export interface WorkspaceDraft { @@ -156,7 +159,196 @@ function modelReference(value: unknown): `${string}/${string}` | undefined { } function positiveInteger(value: unknown, max = Number.MAX_SAFE_INTEGER): number | undefined { - return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= max ? value : undefined; + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= max ? value : undefined; +} + +function nonnegativeInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +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 { + if (value.trim() !== value || /[\u0000-\u001f\u007f\\]/u.test(value)) return 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 isSafeS3Uri(value: string): boolean { + if (value.trim() !== value || /[\u0000-\u001f\u007f\\]/u.test(value)) return false; + try { + const parsed = new URL(value); + const bucket = parsed.hostname; + const validBucket = /^(?=.{3,63}$)(?!-)(?!.*\.\.)(?!.*\.-)(?!.*-\.)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(bucket) + && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bucket); + return parsed.protocol === "s3:" + && validBucket + && parsed.port === "" + && parsed.username === "" + && parsed.password === "" + && parsed.search === "" + && parsed.hash === "" + && parsed.href === value; + } catch { + return false; + } +} + +function isSafeS3Endpoint(value: string): boolean { + const parsed = parsePublicHttpUri(value); + return parsed !== undefined && (parsed.pathname === "/" || parsed.pathname === ""); +} + +function copyEvidencePolicy(value: unknown): EvidencePolicy | undefined { + const source = exactRecord(value, ["max_chunk_chars", "retain_published_generations"]); + const maxChunkChars = positiveInteger(source?.max_chunk_chars); + const retainedGenerations = positiveInteger(source?.retain_published_generations); + return source && maxChunkChars && retainedGenerations + ? { max_chunk_chars: maxChunkChars, retain_published_generations: retainedGenerations } + : undefined; +} + +function copyFilesystemEvidence(value: unknown, id: string): EvidenceSource | undefined { + const source = exactRecord(value, ["type", "uri", "patterns", "max_bytes"]); + const uri = typeof source?.uri === "string" ? source.uri : undefined; + const patterns = source?.patterns; + const maxBytes = positiveInteger(source?.max_bytes); + if ( + source?.type !== "filesystem" + || uri !== `workspace-content/${id}/evidence` + || !Array.isArray(patterns) + || patterns.length === 0 + || !patterns.every((pattern) => typeof pattern === "string" && isSafeEvidencePattern(pattern)) + || new Set(patterns).size !== patterns.length + || !maxBytes + ) return undefined; + return { type: "filesystem", uri, patterns: [...patterns] as string[], max_bytes: maxBytes }; +} + +function copyHttpEvidence(value: unknown): EvidenceSource | undefined { + const source = exactRecord(value, [ + "type", "uris", "authentication", "connect_timeout_ms", "read_timeout_ms", "max_bytes", + "max_redirects", "allow_private_hosts", "max_cache_bytes", + ]); + const uris = source?.uris; + const authentication = oneOf(source?.authentication, ["none", "signed_urls_file"] as const); + const connectTimeout = positiveInteger(source?.connect_timeout_ms); + const readTimeout = positiveInteger(source?.read_timeout_ms); + const maxBytes = positiveInteger(source?.max_bytes); + const maxRedirects = nonnegativeInteger(source?.max_redirects); + const maxCacheBytes = positiveInteger(source?.max_cache_bytes); + if ( + source?.type !== "http" + || !Array.isArray(uris) + || uris.length === 0 + || !uris.every((uri) => typeof uri === "string" && parsePublicHttpUri(uri) !== undefined) + || new Set(uris.map((uri) => parsePublicHttpUri(uri as string)?.href)).size !== uris.length + || !authentication + || !connectTimeout + || !readTimeout + || !maxBytes + || maxRedirects === undefined + || typeof source.allow_private_hosts !== "boolean" + || !maxCacheBytes + ) return undefined; + return { + type: "http", + uris: [...uris] as string[], + authentication, + connect_timeout_ms: connectTimeout, + read_timeout_ms: readTimeout, + max_bytes: maxBytes, + max_redirects: maxRedirects, + allow_private_hosts: source.allow_private_hosts, + max_cache_bytes: maxCacheBytes, + }; +} + +function copyS3Evidence(value: unknown): EvidenceSource | undefined { + const source = exactRecord(value, [ + "type", "uri", "endpoint_url", "region", "credentials", "trusted_endpoint", + "allow_private_endpoint", "allow_insecure_endpoint", "max_bytes", "max_objects", + "max_pages", "page_size", + ]); + const uri = typeof source?.uri === "string" && isSafeS3Uri(source.uri) ? source.uri : undefined; + const endpoint = source?.endpoint_url === undefined + ? undefined + : typeof source.endpoint_url === "string" && isSafeS3Endpoint(source.endpoint_url) + ? source.endpoint_url + : null; + const region = source?.region === undefined ? undefined : text(source.region); + const credentials = oneOf(source?.credentials, ["ambient", "static_files"] as const); + const maxBytes = positiveInteger(source?.max_bytes); + const maxObjects = positiveInteger(source?.max_objects); + const maxPages = positiveInteger(source?.max_pages); + const pageSize = positiveInteger(source?.page_size, 1_000); + if ( + source?.type !== "s3" + || !uri + || endpoint === null + || (source.region !== undefined && !region) + || !credentials + || typeof source.trusted_endpoint !== "boolean" + || typeof source.allow_private_endpoint !== "boolean" + || typeof source.allow_insecure_endpoint !== "boolean" + || !maxBytes + || !maxObjects + || !maxPages + || !pageSize + || (endpoint === undefined && ( + source.trusted_endpoint || source.allow_private_endpoint || source.allow_insecure_endpoint + )) + || (endpoint !== undefined && !source.trusted_endpoint) + || (endpoint !== undefined && parsePublicHttpUri(endpoint)?.protocol === "http:" && !source.allow_insecure_endpoint) + ) return undefined; + return { + type: "s3", + uri, + ...(endpoint === undefined ? {} : { endpoint_url: endpoint }), + ...(region === undefined ? {} : { region }), + credentials, + trusted_endpoint: source.trusted_endpoint, + allow_private_endpoint: source.allow_private_endpoint, + allow_insecure_endpoint: source.allow_insecure_endpoint, + max_bytes: maxBytes, + max_objects: maxObjects, + max_pages: maxPages, + page_size: pageSize, + }; +} + +function copyEvidence(value: unknown, id: string): WorkspaceEvidence | undefined { + const source = exactRecord(value, ["source", "policy"]); + if (!source) return undefined; + const type = record(source.source)?.type; + const evidenceSource = type === "filesystem" + ? copyFilesystemEvidence(source.source, id) + : type === "http" + ? copyHttpEvidence(source.source) + : type === "s3" + ? copyS3Evidence(source.source) + : undefined; + const policy = copyEvidencePolicy(source.policy); + return evidenceSource && policy ? { source: evidenceSource, policy } : undefined; } function oneOf(value: unknown, choices: readonly T[]): T | undefined { @@ -217,7 +409,9 @@ function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined { /** Drops unknown fields before a server response can become a browser draft or conflict view. */ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | undefined { - const source = exactRecord(value, ["workspace", "dwh", "semantic_index", "llm_policy", "diagnostics"]); + const source = exactRecord(value, [ + "workspace", "dwh", "semantic_index", "llm_policy", "diagnostics", "evidence", + ]); const metadata = exactRecord(source?.workspace, ["schema_version", "id", "name", "description", "language"]); const dwh = exactRecord(source?.dwh, ["engine", "database", "schema", "port", "timeout_ms", "supported_transports"]); const semanticIndex = exactRecord(source?.semantic_index, ["vector_store", "embedding"]); @@ -227,6 +421,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics); if (!metadata || !dwh || !semanticIndex || !vectorStore || !embedding || !policy) return undefined; const id = workspaceId(metadata.id); + const evidence = id && source?.evidence !== undefined ? copyEvidence(source.evidence, id) : undefined; const name = text(metadata.name); const language = oneOf(metadata.language, ["en", "it"] as const); const description = metadata.description === undefined ? undefined : text(metadata.description); @@ -253,6 +448,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | || embeddingModel !== "qwen3-embedding:0.6b" ) return undefined; if (source?.diagnostics !== undefined && !diagnostics) return undefined; + if (source?.evidence !== undefined && !evidence) return undefined; if (diagnostics?.dwh_rest && !dwhTransports.includes("rest_api")) return undefined; return { workspace: { @@ -280,6 +476,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | ...(defaultModel ? { default: defaultModel } : {}), allowed: allowedModels, }, ...(diagnostics ? { diagnostics } : {}), + ...(evidence ? { evidence } : {}), }; }