diff --git a/AGENTS.md b/AGENTS.md index b06fc9b2d6..11e52a6a8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -489,7 +489,7 @@ Request middleware including `promptInjectionGuard.ts`. ### Guardrails (`src/lib/guardrails/`) -Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open; per-request opt-out via header. See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md). +Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open. The `pii-masker` guardrail is registered and runs on every request, but its data-mutating logic is **opt-in** and OFF by default — it only redacts when `PII_REDACTION_ENABLED` (request) / `PII_RESPONSE_SANITIZATION` (response + streaming) are enabled (both `defaultValue: "false"`); with them off, payloads pass through untouched. A request can additionally opt OUT of any guardrail via header (`x-omniroute-disabled-guardrails`). Never make PII default-on (Hard Rule #20). See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md). ### Cloud Agents (`src/lib/cloudAgent/`) diff --git a/CLAUDE.md b/CLAUDE.md index 29dfab1eda..0ec5ba84ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -513,6 +513,7 @@ the stale-enforcement added in Fase 6A.3. 17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`. 18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree. 19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation". +20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`. --- diff --git a/tests/unit/pii-opt-in-default.test.ts b/tests/unit/pii-opt-in-default.test.ts new file mode 100644 index 0000000000..9af2f60b54 --- /dev/null +++ b/tests/unit/pii-opt-in-default.test.ts @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Regression guard for Hard Rule: PII redaction/sanitization is OPT-IN. +// OmniRoute proxies for self-hosted / local LLMs where the operator owns the +// data; mutating request/response payloads by default would silently corrupt +// that traffic. The two data-mutating PII feature flags MUST default to "false" +// so a vanilla chat request passes data through untouched. Flipping either +// default to "true" requires explicit operator approval — this test is the +// permanent guard against an accidental on-by-default regression. +// +// See docs/security/GUARDRAILS.md and the PII analysis: piiMasker (request), +// piiSanitizer (response), streamingPiiTransform (SSE) are ALL gated on these +// two flags; with both off the guardrail runs but never mutates payloads. + +// Isolate DB state so the resolution chain (DB override > env > default) reads +// a clean store and we exercise the definition default, not a leaked override. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-pii-default-")); +process.env.DATA_DIR = tmpDir; + +const { FEATURE_FLAG_DEFINITIONS } = await import( + "../../src/shared/constants/featureFlagDefinitions.ts" +); + +test("PII data-mutation flags are opt-in (default 'false')", async (t) => { + const def = (key: string) => FEATURE_FLAG_DEFINITIONS.find((d) => d.key === key); + + await t.test("PII_REDACTION_ENABLED definition default is 'false'", () => { + const d = def("PII_REDACTION_ENABLED"); + assert.ok(d, "PII_REDACTION_ENABLED definition must exist"); + assert.strictEqual( + d!.defaultValue, + "false", + "PII_REDACTION_ENABLED must default OFF — request-side masking is opt-in (operator owns the data)" + ); + }); + + await t.test("PII_RESPONSE_SANITIZATION definition default is 'false'", () => { + const d = def("PII_RESPONSE_SANITIZATION"); + assert.ok(d, "PII_RESPONSE_SANITIZATION definition must exist"); + assert.strictEqual( + d!.defaultValue, + "false", + "PII_RESPONSE_SANITIZATION must default OFF — response/streaming masking is opt-in" + ); + }); + + await t.test("effective runtime resolution is OFF with no override", async () => { + // No env var, no DB override → the definition default must win. + delete process.env.PII_REDACTION_ENABLED; + delete process.env.PII_RESPONSE_SANITIZATION; + const { clearAllFeatureFlagOverrides } = await import("@/lib/db/featureFlags"); + clearAllFeatureFlagOverrides(); + + const { isFeatureFlagEnabled } = await import("@/shared/utils/featureFlags"); + assert.strictEqual(isFeatureFlagEnabled("PII_REDACTION_ENABLED"), false); + assert.strictEqual(isFeatureFlagEnabled("PII_RESPONSE_SANITIZATION"), false); + }); + + await t.test("response data passes through untouched by default", async () => { + delete process.env.PII_RESPONSE_SANITIZATION; + const { clearAllFeatureFlagOverrides } = await import("@/lib/db/featureFlags"); + clearAllFeatureFlagOverrides(); + + const { sanitizePII, sanitizePIIResponse } = await import("@/lib/piiSanitizer"); + + const text = "contact me at jdoe@example.com or 123-45-6789"; + const result = sanitizePII(text); + assert.strictEqual(result.redacted, false, "must NOT redact when flag is off"); + assert.strictEqual(result.text, text, "PII text must pass through unchanged by default"); + + const body = { choices: [{ message: { content: "ssn 123-45-6789, email a@b.com" } }] }; + const out = sanitizePIIResponse(JSON.parse(JSON.stringify(body))); + assert.deepStrictEqual(out, body, "response object must pass through unchanged by default"); + }); +});