Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions TRUST_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
123 changes: 123 additions & 0 deletions scripts/sidecar-security.mjs
Original file line number Diff line number Diff line change
@@ -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,
};
}
23 changes: 23 additions & 0 deletions scripts/sidecar-security.node-test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
32 changes: 16 additions & 16 deletions scripts/smart-search-sidecar.mjs
Original file line number Diff line number Diff line change
@@ -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 = [];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -207,19 +200,25 @@ 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);
const boundedRequest = sanitizeRequest(payload);
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,
Expand All @@ -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.");
});
Loading
Loading