From ea8e0feaa6d896744bd0e87cd1216a3a6e344309 Mon Sep 17 00:00:00 2001 From: Xyloth Date: Mon, 31 Aug 2026 10:19:52 -0400 Subject: [PATCH] Close local sidecar and plaintext persistence gaps --- .env.example | 9 ++ README.md | 22 ++++ TRUST_MODEL.md | 15 +++ package.json | 2 +- scripts/sidecar-security.mjs | 123 +++++++++++++++++++++++ scripts/sidecar-security.node-test.mjs | 23 +++++ scripts/smart-search-sidecar.mjs | 32 +++--- scripts/speech-transcription-sidecar.mjs | 33 +++--- src/App.tsx | 105 +++++++++++++------ src/sourcestack/index.ts | 1 + src/sourcestack/workspacePrivacy.test.ts | 76 ++++++++++++++ src/sourcestack/workspacePrivacy.ts | 108 ++++++++++++++++++++ 12 files changed, 482 insertions(+), 67 deletions(-) create mode 100644 scripts/sidecar-security.mjs create mode 100644 scripts/sidecar-security.node-test.mjs create mode 100644 src/sourcestack/workspacePrivacy.test.ts create mode 100644 src/sourcestack/workspacePrivacy.ts diff --git a/.env.example b/.env.example index 78ff844..6f66715 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,12 @@ # # Example (Windows): SOURCEDECK_DECK_PATH=C:\path\to\your-casedeck.json SOURCEDECK_DECK_PATH= + +# Optional comma-separated browser origins for local sidecars beyond SourceDeck's shipped URL and +# loopback development/Electron origins. Exact origins only; do not use wildcards. +# Example: SOURCEDECK_ALLOWED_ORIGINS=https://sourcedeck-git-feature.example.vercel.app +SOURCEDECK_ALLOWED_ORIGINS= + +# Optional fixed sidecar capability for controlled automation. If omitted, each sidecar generates +# a fresh random capability at startup and the trusted SourceDeck origin obtains it from /session. +SOURCEDECK_SIDECAR_TOKEN= diff --git a/README.md b/README.md index dffbbf8..83947b6 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ pull up the source-backed quote or question while the conversation is happening. packets, and encrypted workspace JSON. - Local-first privacy posture: sensitive records are processed locally and are not committed to this repository. +- Verbatim extracted text is session-only and removed before browser + `localStorage` serialization; encrypted workspace export is the durable path. ## Case Folder Importer @@ -87,9 +89,23 @@ Image-only PDFs, chart-only DOCX files, and scanned records are marked as - Mammoth for DOCX extraction - `word-extractor` for local legacy DOC preloading - Browser localStorage for the current workspace prototype +- Session-only extracted source text plus encrypted source-byte custody in IndexedDB - Web Crypto PBKDF2/AES-GCM for encrypted workspace export/import - Vercel deployment +## Local Sidecar Security + +The optional speech and CLI-intelligence sidecars bind to `127.0.0.1`. Browser +operations accept only the shipped SourceDeck origin or loopback development and +Electron origins, then require a per-process bearer capability. There is no +wildcard CORS access. Additional exact origins can be configured through +`SOURCEDECK_ALLOWED_ORIGINS`; see `.env.example`. + +These controls prevent an unrelated web page from invoking a local model command +or reading its output. They are a browser boundary, not an operating-system +sandbox: processes running as the same local user remain in the local trust +boundary. + ## Run Locally ```powershell @@ -129,6 +145,12 @@ medical, HR, legal, financial, or other private records to a public repository. SourceDeck's product direction is local-first because the target documents are often sensitive. +The current browser prototype still persists derived workspace fields such as +evidence-card quotes and meeting notes in plaintext `localStorage`. Use a trusted +browser profile, reset the workspace after sensitive sessions, and use encrypted +workspace export when durable custody is required. The full boundary is stated +in [TRUST_MODEL.md](TRUST_MODEL.md). + ## Product Rule AI can prepare the deck, organize issues, suggest evidence cards, draft clean diff --git a/TRUST_MODEL.md b/TRUST_MODEL.md index dbb23f7..c4042f1 100644 --- a/TRUST_MODEL.md +++ b/TRUST_MODEL.md @@ -52,6 +52,10 @@ verification gate allows it. - **Source bytes are encrypted at rest.** Original files and rendered page images are AES-GCM encrypted (PBKDF2-SHA256) before they touch IndexedDB, gated on a workspace passphrase. With no passphrase the app refuses to persist plaintext bytes at all. One key derivation per import. +- **Extracted source text is session-only.** Verbatim extracted text, per-page text, the durable text + artifact, and case-store artifact payloads are removed before browser `localStorage` serialization. + They remain usable in memory during the active session and can be retained through an explicit + encrypted workspace export. Reloading requires re-import or encrypted restore. - **KDF policy.** Production derives at 600k PBKDF2 iterations; decrypt and encrypt reject anything below 100k (downgrade) or above 10M (decrypt-time DoS). - **Signing-key custody.** Private signing keys are passphrase-wrapped (AES-GCM); custody @@ -68,6 +72,10 @@ verification gate allows it. - **Model output is gated.** Candidate evidence from a model is rejected unless it structurally resolves to a real source span; nothing a model emits writes directly to the graph. Privacy mode is a hard ceiling on which model lanes may run. +- **Loopback sidecars reject arbitrary web origins.** Speech and CLI-intelligence operations bind to + loopback, use an explicit origin allowlist instead of wildcard CORS, and require a per-process + bearer capability obtained by the trusted SourceDeck origin. This prevents an unrelated browser + page from spending model resources or reading sidecar output. ## 5. Honest limits (what is NOT yet guaranteed) @@ -81,6 +89,13 @@ verification gate allows it. - **No real model runtime or OCR engine.** The model router/gates and OCR pipeline are typed, gated scaffolding; no live frontier model or OCR worker is wired in yet. - **Local-first only.** No collaboration, zero-knowledge sync, or cross-device chain-of-custody. +- **Derived workspace state remains a local prototype.** Evidence-card quotes, meeting notes, issue + labels, and other user-created or derived workspace fields still use plaintext browser + `localStorage`. SourceDeck therefore requires a trusted local browser profile; encrypted at-rest + persistence for the entire workspace is not yet implemented. +- **Sidecar capabilities are not an OS sandbox.** Origin checks and per-process capabilities defend + the browser boundary; another process running as the same local user remains inside the local + trust boundary. - **Not legal advice.** SourceDeck organizes a user's own records; it does not render legal conclusions. The legal-boundary language should be reviewed by counsel before commercialization. diff --git a/package.json b/package.json index 57e0715..8e719e9 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "speech:sidecar": "node scripts/speech-transcription-sidecar.mjs", "lint": "eslint .", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run && node --test scripts/*.node-test.mjs" }, "dependencies": { "clsx": "^2.1.1", diff --git a/scripts/sidecar-security.mjs b/scripts/sidecar-security.mjs new file mode 100644 index 0000000..8f2f748 --- /dev/null +++ b/scripts/sidecar-security.mjs @@ -0,0 +1,123 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; + +const shippedBrowserOrigins = new Set(["https://sourcedeck.vercel.app"]); +const tokenHeader = "x-sourcedeck-token"; + +function parseConfiguredOrigins(value) { + return String(value ?? "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); +} + +export function isTrustedSidecarOrigin(origin, configuredOrigins = []) { + if (!origin || origin === "null") return false; + if (shippedBrowserOrigins.has(origin) || configuredOrigins.includes(origin)) return true; + try { + const parsed = new URL(origin); + return ( + (parsed.protocol === "http:" || parsed.protocol === "https:") && + ["127.0.0.1", "localhost", "[::1]"].includes(parsed.hostname) + ); + } catch { + return false; + } +} + +export function tokensMatch(actual, expected) { + if (typeof actual !== "string" || typeof expected !== "string") return false; + const actualBytes = Buffer.from(actual); + const expectedBytes = Buffer.from(expected); + return ( + actualBytes.length === expectedBytes.length && + timingSafeEqual(actualBytes, expectedBytes) + ); +} + +/** + * Protects a loopback HTTP sidecar from arbitrary web pages. + * + * Browser callers must come from the shipped app or a loopback development/Electron origin, obtain + * a per-process capability from /session, and send it with every operation. The token is not an + * account credential; it is a CSRF-style capability that prevents an unrelated page from spending + * model resources or reading sidecar output. A fixed value is supported for controlled automation. + */ +export function createSidecarSecurity(env = process.env) { + const configuredOrigins = parseConfiguredOrigins(env.SOURCEDECK_ALLOWED_ORIGINS); + const sessionToken = + String(env.SOURCEDECK_SIDECAR_TOKEN ?? "").trim() || randomBytes(32).toString("base64url"); + + function originFor(request) { + return typeof request.headers.origin === "string" ? request.headers.origin : ""; + } + + function responseHeaders(request, extraHeaders = {}) { + const origin = originFor(request); + const headers = { + "Access-Control-Allow-Methods": "POST, OPTIONS, GET", + "Access-Control-Allow-Headers": + "Content-Type, X-SourceDeck-File, X-SourceDeck-Token", + "Cache-Control": "no-store", + "Content-Type": "application/json", + Vary: "Origin", + ...extraHeaders, + }; + if (isTrustedSidecarOrigin(origin, configuredOrigins)) { + headers["Access-Control-Allow-Origin"] = origin; + } + return headers; + } + + function writeJson(request, response, status, payload, extraHeaders = {}) { + response.writeHead(status, responseHeaders(request, extraHeaders)); + response.end(JSON.stringify(payload)); + } + + function requireTrustedOrigin(request, response) { + const origin = originFor(request); + if (isTrustedSidecarOrigin(origin, configuredOrigins)) return true; + writeJson(request, response, 403, { + ok: false, + error: "untrusted_origin", + detail: + "Open SourceDeck from its shipped origin or configure SOURCEDECK_ALLOWED_ORIGINS.", + }); + return false; + } + + function handlePreflight(request, response) { + if (request.method !== "OPTIONS") return false; + if (!requireTrustedOrigin(request, response)) return true; + writeJson(request, response, 204, {}); + return true; + } + + function issueSession(request, response) { + if (!requireTrustedOrigin(request, response)) return false; + writeJson(request, response, 200, { + ok: true, + format: "sourcedeck.sidecar-session.v1", + token: sessionToken, + }); + return true; + } + + function authorizeOperation(request, response) { + if (!requireTrustedOrigin(request, response)) return false; + const actualToken = request.headers[tokenHeader]; + if (tokensMatch(actualToken, sessionToken)) return true; + writeJson(request, response, 401, { + ok: false, + error: "invalid_sidecar_session", + detail: "Refresh the SourceDeck sidecar session and retry.", + }); + return false; + } + + return { + authorizeOperation, + handlePreflight, + issueSession, + writeJson, + }; +} diff --git a/scripts/sidecar-security.node-test.mjs b/scripts/sidecar-security.node-test.mjs new file mode 100644 index 0000000..91d8036 --- /dev/null +++ b/scripts/sidecar-security.node-test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isTrustedSidecarOrigin, tokensMatch } from "./sidecar-security.mjs"; + +test("accepts shipped and loopback SourceDeck origins", () => { + assert.equal(isTrustedSidecarOrigin("https://sourcedeck.vercel.app"), true); + assert.equal(isTrustedSidecarOrigin("http://127.0.0.1:5173"), true); + assert.equal(isTrustedSidecarOrigin("http://localhost:4318"), true); + assert.equal(isTrustedSidecarOrigin("https://preview.example", ["https://preview.example"]), true); +}); + +test("rejects arbitrary, opaque, and lookalike origins", () => { + assert.equal(isTrustedSidecarOrigin("https://attacker.example"), false); + assert.equal(isTrustedSidecarOrigin("null"), false); + assert.equal(isTrustedSidecarOrigin("http://127.0.0.1.attacker.example"), false); + assert.equal(isTrustedSidecarOrigin(undefined), false); +}); + +test("compares sidecar capability tokens without prefix acceptance", () => { + assert.equal(tokensMatch("correct-token", "correct-token"), true); + assert.equal(tokensMatch("correct", "correct-token"), false); + assert.equal(tokensMatch(undefined, "correct-token"), false); +}); diff --git a/scripts/smart-search-sidecar.mjs b/scripts/smart-search-sidecar.mjs index f3af7f8..bab06b1 100644 --- a/scripts/smart-search-sidecar.mjs +++ b/scripts/smart-search-sidecar.mjs @@ -1,11 +1,13 @@ import http from "node:http"; import { spawn } from "node:child_process"; +import { createSidecarSecurity } from "./sidecar-security.mjs"; const host = process.env.SOURCEDECK_SMART_SEARCH_HOST ?? "127.0.0.1"; const port = Number(process.env.SOURCEDECK_SMART_SEARCH_PORT ?? 4318); const command = process.env.SOURCEDECK_SMART_SEARCH_COMMAND ?? "codex"; const timeoutMs = Number(process.env.SOURCEDECK_SMART_SEARCH_TIMEOUT_MS ?? 45000); const maxBodyBytes = 2_000_000; +const sidecarSecurity = createSidecarSecurity(); function parseCommandArgs(value) { const args = []; @@ -39,16 +41,6 @@ function parseCommandArgs(value) { const args = parseCommandArgs(process.env.SOURCEDECK_SMART_SEARCH_ARGS ?? ""); -function writeJson(response, status, payload) { - response.writeHead(status, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS, GET", - "Access-Control-Allow-Headers": "Content-Type", - "Content-Type": "application/json", - }); - response.end(JSON.stringify(payload, null, 2)); -} - function readBody(request) { return new Promise((resolve, reject) => { let size = 0; @@ -193,12 +185,13 @@ function runCommand(prompt) { } const server = http.createServer(async (request, response) => { - if (request.method === "OPTIONS") { - writeJson(response, 200, { ok: true }); + if (sidecarSecurity.handlePreflight(request, response)) return; + if (request.method === "GET" && request.url === "/session") { + sidecarSecurity.issueSession(request, response); return; } if (request.method === "GET" && request.url === "/health") { - writeJson(response, 200, { + sidecarSecurity.writeJson(request, response, 200, { ok: true, command, args, @@ -207,9 +200,10 @@ const server = http.createServer(async (request, response) => { return; } if (request.method !== "POST" || request.url !== "/smart-search") { - writeJson(response, 404, { ok: false, error: "not found" }); + sidecarSecurity.writeJson(request, response, 404, { ok: false, error: "not found" }); return; } + if (!sidecarSecurity.authorizeOperation(request, response)) return; try { const body = await readBody(request); const payload = JSON.parse(body); @@ -217,9 +211,14 @@ const server = http.createServer(async (request, response) => { const prompt = buildPrompt(boundedRequest); const outputText = await runCommand(prompt); const modelOutput = extractJsonObject(outputText); - writeJson(response, 200, validateResponse(modelOutput, boundedRequest)); + sidecarSecurity.writeJson( + request, + response, + 200, + validateResponse(modelOutput, boundedRequest), + ); } catch (error) { - writeJson(response, 503, { + sidecarSecurity.writeJson(request, response, 503, { ok: false, error: error instanceof Error ? error.message : "smart-search sidecar failed", command, @@ -231,5 +230,6 @@ const server = http.createServer(async (request, response) => { server.listen(port, host, () => { console.log(`SourceDeck smart-search sidecar listening on http://${host}:${port}`); console.log(`Command: ${command} ${args.join(" ")}`.trim()); + console.log("Browser operations require a trusted Origin and a per-process sidecar session."); console.log("Set SOURCEDECK_SMART_SEARCH_COMMAND and SOURCEDECK_SMART_SEARCH_ARGS to change CLI custody."); }); diff --git a/scripts/speech-transcription-sidecar.mjs b/scripts/speech-transcription-sidecar.mjs index 7371895..495539a 100644 --- a/scripts/speech-transcription-sidecar.mjs +++ b/scripts/speech-transcription-sidecar.mjs @@ -1,10 +1,12 @@ import { createServer } from "node:http"; +import { createSidecarSecurity } from "./sidecar-security.mjs"; const host = process.env.SOURCEDECK_SPEECH_HOST ?? "127.0.0.1"; const port = Number(process.env.SOURCEDECK_SPEECH_PORT ?? 4317); const model = process.env.OPENAI_TRANSCRIBE_MODEL ?? "gpt-4o-transcribe"; const apiKey = process.env.OPENAI_API_KEY; const maxBodyBytes = Number(process.env.SOURCEDECK_SPEECH_MAX_BYTES ?? 25_000_000); +const sidecarSecurity = createSidecarSecurity(); function readBody(request) { return new Promise((resolve, reject) => { @@ -24,23 +26,14 @@ function readBody(request) { }); } -function writeJson(response, status, payload) { - response.writeHead(status, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS, GET", - "Access-Control-Allow-Headers": "Content-Type, X-SourceDeck-File", - "Content-Type": "application/json", - }); - response.end(JSON.stringify(payload)); -} - const server = createServer(async (request, response) => { - if (request.method === "OPTIONS") { - writeJson(response, 204, {}); + if (sidecarSecurity.handlePreflight(request, response)) return; + if (request.method === "GET" && request.url === "/session") { + sidecarSecurity.issueSession(request, response); return; } if (request.method === "GET" && request.url === "/health") { - writeJson(response, 200, { + sidecarSecurity.writeJson(request, response, 200, { ok: true, format: "sourcedeck.speech-sidecar.v1", model, @@ -50,11 +43,12 @@ const server = createServer(async (request, response) => { return; } if (request.url !== "/transcribe" || request.method !== "POST") { - writeJson(response, 404, { error: "not_found" }); + sidecarSecurity.writeJson(request, response, 404, { error: "not_found" }); return; } + if (!sidecarSecurity.authorizeOperation(request, response)) return; if (!apiKey) { - writeJson(response, 503, { + sidecarSecurity.writeJson(request, response, 503, { error: "missing_openai_api_key", detail: "Set OPENAI_API_KEY before running npm run speech:sidecar.", }); @@ -63,7 +57,7 @@ const server = createServer(async (request, response) => { try { const body = await readBody(request); if (!body.byteLength) { - writeJson(response, 400, { error: "empty_audio" }); + sidecarSecurity.writeJson(request, response, 400, { error: "empty_audio" }); return; } const contentType = request.headers["content-type"] || "audio/webm"; @@ -81,16 +75,16 @@ const server = createServer(async (request, response) => { }); const text = await upstream.text(); if (!upstream.ok) { - writeJson(response, upstream.status, { + sidecarSecurity.writeJson(request, response, upstream.status, { error: "transcription_failed", detail: text, }); return; } const payload = JSON.parse(text); - writeJson(response, 200, { text: payload.text ?? "" }); + sidecarSecurity.writeJson(request, response, 200, { text: payload.text ?? "" }); } catch (error) { - writeJson(response, 500, { + sidecarSecurity.writeJson(request, response, 500, { error: "speech_sidecar_error", detail: error instanceof Error ? error.message : "unknown error", }); @@ -101,4 +95,5 @@ server.listen(port, host, () => { console.log( `SourceDeck speech sidecar listening on http://${host}:${port}/transcribe using ${model}`, ); + console.log("Browser operations require a trusted Origin and a per-process sidecar session."); }); diff --git a/src/App.tsx b/src/App.tsx index e2c2719..ccd3492 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,7 +57,6 @@ import { promotionCertificateToRecordedEvent, proofSnapshotHash, parseSearchCommand, - redactSourceVaultManifestPayloads, buildSignoffReviewQueue, splitEvidenceCard, mergeEvidenceCards, @@ -79,8 +78,9 @@ import { routeModelJob, signPacketManifestWithStoredKey, SOURCE_ARTIFACT_CASE_STORE_MEDIA_TYPE, - SOURCE_VAULT_REDACTED_PAYLOAD_REASON, sourceVaultManifestHasPayloads, + sanitizeCaseStoreForLocalStorage, + sanitizeWorkspaceDocumentsForLocalStorage, sourceVaultPayloadBytes, unwrapPacketSigningKey, verifyEncryptedPacketSigningKey, @@ -177,22 +177,6 @@ type SourceDocument = { sourceVaultFailure?: string; }; -function sanitizeSourceDocumentForLocalStorage(document: SourceDocument): SourceDocument { - if (!document.sourceVaultManifest || !sourceVaultManifestHasPayloads(document.sourceVaultManifest)) { - return document; - } - return { - ...document, - sourceVaultManifest: redactSourceVaultManifestPayloads(document.sourceVaultManifest), - sourceVaultVerified: false, - sourceVaultFailure: document.sourceVaultFailure ?? SOURCE_VAULT_REDACTED_PAYLOAD_REASON, - }; -} - -function sanitizeSourceDocumentsForLocalStorage(documents: SourceDocument[]) { - return documents.map(sanitizeSourceDocumentForLocalStorage); -} - type EvidenceCard = { id: string; title: string; @@ -597,6 +581,15 @@ const seedDocuments: SourceDocument[] = [ }, ]; +// Demo source text is compiled from this fictional seed, never recovered from localStorage. This +// keeps the public worked example intact while proving that persisted document records are textless. +seedDocuments.forEach((document) => { + seedSourceTextByDocumentId[document.id] = { + extractedText: document.extractedText, + pageTexts: document.pageTexts, + }; +}); + const seedEvidence: EvidenceCard[] = [ { id: "ev_nondelivery_admission", @@ -1118,6 +1111,42 @@ function useLocalState( return [value, update] as const; } +const sidecarSessionTokens = new Map(); + +async function fetchSidecarSessionToken(baseUrl: string) { + const response = await fetch(`${baseUrl}/session`, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`sidecar session returned ${response.status}`); + } + const payload = (await response.json()) as { + format?: string; + token?: string; + }; + if (payload.format !== "sourcedeck.sidecar-session.v1" || !payload.token) { + throw new Error("sidecar did not issue a valid session capability"); + } + sidecarSessionTokens.set(baseUrl, payload.token); + return payload.token; +} + +async function fetchAuthorizedSidecar( + baseUrl: string, + path: string, + init: RequestInit, + allowSessionRefresh = true, +) { + const token = + sidecarSessionTokens.get(baseUrl) ?? (await fetchSidecarSessionToken(baseUrl)); + const headers = new Headers(init.headers); + headers.set("X-SourceDeck-Token", token); + const response = await fetch(`${baseUrl}${path}`, { ...init, headers }); + if (response.status === 401 && allowSessionRefresh) { + sidecarSessionTokens.delete(baseUrl); + return fetchAuthorizedSidecar(baseUrl, path, init, false); + } + return response; +} + function compactDate(value: string) { if (!value) return "Undated"; return new Intl.DateTimeFormat(undefined, { @@ -1434,8 +1463,8 @@ function toArrayBuffer(bytes: Uint8Array) { function App() { const [documents, setDocuments] = useLocalState("sourcedeck.documents", seedDocuments, { deserialize: (stored) => - sanitizeSourceDocumentsForLocalStorage(stored as SourceDocument[]), - serialize: sanitizeSourceDocumentsForLocalStorage, + sanitizeWorkspaceDocumentsForLocalStorage(stored as SourceDocument[]), + serialize: sanitizeWorkspaceDocumentsForLocalStorage, }); const [caseProfile, setCaseProfile] = useLocalState( "sourcedeck.caseProfile", @@ -1459,6 +1488,11 @@ function App() { const [trustStore, setTrustStore] = useLocalState( "sourcedeck.trustStore", null, + { + deserialize: (stored) => + sanitizeCaseStoreForLocalStorage(stored as ContentAddressedCaseStore | null), + serialize: sanitizeCaseStoreForLocalStorage, + }, ); // Tracks which bundled deck version has been applied, so a newer public/casedeck.json auto-refreshes. const [deckVersion, setDeckVersion] = useLocalState("sourcedeck.deckVersion", ""); @@ -3007,7 +3041,7 @@ function App() { } async function transcribeWithSidecar(audio: Blob) { - const response = await fetch("http://127.0.0.1:4317/transcribe", { + const response = await fetchAuthorizedSidecar("http://127.0.0.1:4317", "/transcribe", { method: "POST", headers: { "Content-Type": audio.type || "audio/webm", @@ -3107,7 +3141,7 @@ function App() { ); setIntelligenceStatus("Calling local CLI intelligence sidecar..."); try { - const response = await fetch("http://127.0.0.1:4318/smart-search", { + const response = await fetchAuthorizedSidecar("http://127.0.0.1:4318", "/smart-search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), @@ -3807,19 +3841,21 @@ function App() { } function createWorkspaceSnapshot( - options: { redactSourceVaultPayloads?: boolean } = {}, + options: { redactSensitiveSourceMaterial?: boolean } = {}, ): WorkspaceSnapshot { return { caseProfile, - documents: options.redactSourceVaultPayloads - ? sanitizeSourceDocumentsForLocalStorage(documents) + documents: options.redactSensitiveSourceMaterial + ? sanitizeWorkspaceDocumentsForLocalStorage(documents) : documents, evidence, issues, timeline, missingRecords, meetingNotes, - trustStore, + trustStore: options.redactSensitiveSourceMaterial + ? sanitizeCaseStoreForLocalStorage(trustStore) + : trustStore, trustRegistry, battleCard: battleCard ?? undefined, }; @@ -3827,6 +3863,9 @@ function App() { function exportPlainWorkspace() { const artifactCount = documents.filter((document) => document.sourceArtifact).length; + const caseArtifactCount = Object.values(trustStore?.artifacts ?? {}).filter( + (artifact) => artifact.payload.data, + ).length; const vaultCount = documents.filter((document) => document.sourceVaultManifest).length; const vaultPageImageCount = documents.reduce( (sum, document) => sum + (document.sourceVaultManifest?.pageImages.length ?? 0), @@ -3834,17 +3873,19 @@ function App() { ); downloadText( "sourcedeck-data.json", - JSON.stringify(createWorkspaceSnapshot({ redactSourceVaultPayloads: true }), null, 2), + JSON.stringify(createWorkspaceSnapshot({ redactSensitiveSourceMaterial: true }), null, 2), "application/json", ); setPrivacyStatus( - `Plain workspace JSON exported with source text${ - artifactCount ? ` and ${artifactCount} artifact payload${artifactCount === 1 ? "" : "s"}` : "" + `Plain workspace JSON exported without extracted source text${ + artifactCount || caseArtifactCount + ? `; ${artifactCount + caseArtifactCount} plaintext artifact payload${artifactCount + caseArtifactCount === 1 ? " was" : "s were"} redacted` + : "" }${ vaultCount ? ` plus ${vaultCount} source-vault custody reference${vaultCount === 1 ? "" : "s"} (${vaultPageImageCount} rendered page image reference${vaultPageImageCount === 1 ? "" : "s"}; payloads redacted)` : "" - }. Use encrypted export for payload-bearing private records.`, + }. Use encrypted export to retain private source material.`, ); void recordTrustEvent({ type: "security_finding", @@ -3853,9 +3894,11 @@ function App() { payload: { plainWorkspaceExport: true, artifactCount, + caseArtifactCount, vaultCount, vaultPageImageCount, - includesSourceText: true, + includesSourceText: false, + sourceArtifactsRedacted: artifactCount + caseArtifactCount > 0, includesSourceVaultPayloads: false, sourceVaultPayloadsRedacted: vaultCount > 0, }, diff --git a/src/sourcestack/index.ts b/src/sourcestack/index.ts index 517c4a7..a93cd9d 100644 --- a/src/sourcestack/index.ts +++ b/src/sourcestack/index.ts @@ -31,3 +31,4 @@ export * from "./trustRegistry"; export * from "./types"; export * from "./verification"; export * from "./workbench"; +export * from "./workspacePrivacy"; diff --git a/src/sourcestack/workspacePrivacy.test.ts b/src/sourcestack/workspacePrivacy.test.ts new file mode 100644 index 0000000..5e12f37 --- /dev/null +++ b/src/sourcestack/workspacePrivacy.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + PLAINTEXT_SOURCE_TEXT_NOT_PERSISTED_REASON, + sanitizeCaseStoreForLocalStorage, + sanitizeWorkspaceDocumentForLocalStorage, +} from "./workspacePrivacy"; + +describe("browser workspace privacy", () => { + it("removes extracted text, page text, and the plaintext durable artifact", () => { + const sanitized = sanitizeWorkspaceDocumentForLocalStorage({ + id: "doc-1", + extractedText: "private contract text", + pageTexts: [{ page: 1, text: "private contract text" }], + sourceArtifact: { payload: { data: "private contract text" } }, + sourceArtifactVerified: true, + }); + + expect(sanitized.extractedText).toBeUndefined(); + expect(sanitized.pageTexts).toBeUndefined(); + expect(sanitized.sourceArtifact).toBeUndefined(); + expect(sanitized.sourceArtifactVerified).toBe(false); + expect(sanitized.sourceArtifactFailure).toBe( + PLAINTEXT_SOURCE_TEXT_NOT_PERSISTED_REASON, + ); + expect(JSON.stringify(sanitized)).not.toContain("private contract text"); + }); + + it("does not mutate the live in-memory document", () => { + const document = { extractedText: "session text", pageTexts: [{ text: "session text" }] }; + + sanitizeWorkspaceDocumentForLocalStorage(document); + + expect(document.extractedText).toBe("session text"); + expect(document.pageTexts).toEqual([{ text: "session text" }]); + }); + + it("does not duplicate the session-only warning on repeated sanitization", () => { + const once = sanitizeWorkspaceDocumentForLocalStorage({ + extractedText: "session text", + warning: "Existing warning.", + }); + const twice = sanitizeWorkspaceDocumentForLocalStorage({ + ...once, + extractedText: "session text restored in memory", + }); + + expect( + twice.warning?.split(PLAINTEXT_SOURCE_TEXT_NOT_PERSISTED_REASON).length, + ).toBe(2); + }); + + it("redacts plaintext artifacts from the persisted case store", () => { + const store = { + caseId: "case-1", + createdAt: "2026-08-31T00:00:00.000Z", + artifacts: { + "artifact-1": { + id: "artifact-1", + contentHash: `sha256:${"a".repeat(64)}` as const, + mediaType: "application/json", + byteLength: 14, + createdAt: "2026-08-31T00:00:00.000Z", + payload: { encoding: "utf8" as const, data: "private source" }, + metadata: {}, + }, + }, + events: [], + }; + + const sanitized = sanitizeCaseStoreForLocalStorage(store); + + expect(sanitized?.artifacts["artifact-1"].payload.data).toBe(""); + expect(JSON.stringify(sanitized)).not.toContain("private source"); + expect(store.artifacts["artifact-1"].payload.data).toBe("private source"); + }); +}); diff --git a/src/sourcestack/workspacePrivacy.ts b/src/sourcestack/workspacePrivacy.ts new file mode 100644 index 0000000..155738f --- /dev/null +++ b/src/sourcestack/workspacePrivacy.ts @@ -0,0 +1,108 @@ +import type { SourceVaultManifest } from "./sourceVault"; +import type { ContentAddressedCaseStore } from "./caseStore"; +import { + SOURCE_VAULT_REDACTED_PAYLOAD_REASON, + redactSourceVaultManifestPayloads, + sourceVaultManifestHasPayloads, +} from "./sourceVaultPrivacy"; + +export const PLAINTEXT_SOURCE_TEXT_NOT_PERSISTED_REASON = + "Extracted document text is session-only and is not persisted in browser localStorage. Re-import the source or restore an encrypted workspace after reload."; + +export const CASE_STORE_PAYLOAD_NOT_PERSISTED_REASON = + "Case-artifact payloads are redacted from browser localStorage; restore an encrypted workspace or re-import source material to verify payload-bearing artifacts."; + +type LocalWorkspaceDocument = { + extractedText?: string; + pageTexts?: unknown[]; + sourceArtifact?: unknown; + sourceArtifactVerified?: boolean; + sourceArtifactFailure?: string; + sourceVaultManifest?: SourceVaultManifest; + sourceVaultVerified?: boolean; + sourceVaultFailure?: string; + warning?: string; +}; + +function appendNotice(existing: string | undefined, notice: string) { + if (existing?.includes(notice)) return existing; + return [existing?.trim(), notice].filter(Boolean).join(" "); +} + +/** + * Produces the only shape that may enter browser localStorage. + * + * Imported source text and the durable text artifact both contain verbatim record content. They + * stay available in the active in-memory session, and they are included only when the user + * explicitly creates an encrypted workspace export. Original bytes/page images remain governed by + * the encrypted IndexedDB vault; only their custody metadata may be serialized here. + */ +export function sanitizeWorkspaceDocumentForLocalStorage( + document: T, +): T & LocalWorkspaceDocument { + const containsPlaintextSource = Boolean( + document.extractedText || document.pageTexts?.length || document.sourceArtifact, + ); + const containsVaultPayloads = Boolean( + document.sourceVaultManifest && + sourceVaultManifestHasPayloads(document.sourceVaultManifest), + ); + + if (!containsPlaintextSource && !containsVaultPayloads) return document; + + return { + ...document, + ...(containsPlaintextSource + ? { + extractedText: undefined, + pageTexts: undefined, + sourceArtifact: undefined, + sourceArtifactVerified: false, + sourceArtifactFailure: PLAINTEXT_SOURCE_TEXT_NOT_PERSISTED_REASON, + warning: appendNotice( + document.warning, + PLAINTEXT_SOURCE_TEXT_NOT_PERSISTED_REASON, + ), + } + : {}), + ...(containsVaultPayloads && document.sourceVaultManifest + ? { + sourceVaultManifest: redactSourceVaultManifestPayloads( + document.sourceVaultManifest, + ), + sourceVaultVerified: false, + sourceVaultFailure: + document.sourceVaultFailure ?? SOURCE_VAULT_REDACTED_PAYLOAD_REASON, + } + : {}), + }; +} + +export function sanitizeWorkspaceDocumentsForLocalStorage( + documents: T[], +) { + return documents.map(sanitizeWorkspaceDocumentForLocalStorage); +} + +export function sanitizeCaseStoreForLocalStorage( + store: ContentAddressedCaseStore | null, +): ContentAddressedCaseStore | null { + if (!store) return store; + const artifacts = Object.fromEntries( + Object.entries(store.artifacts).map(([id, artifact]) => [ + id, + artifact.payload.data + ? { + ...artifact, + payload: { ...artifact.payload, data: "" }, + metadata: { + ...artifact.metadata, + localStoragePayloadRedacted: true, + localStoragePayloadRedactionReason: CASE_STORE_PAYLOAD_NOT_PERSISTED_REASON, + }, + } + : artifact, + ]), + ); + return { ...store, artifacts }; +}