From c1fe54781b811060b7f395b43dc8588264cd678b Mon Sep 17 00:00:00 2001 From: Willem Date: Mon, 19 Oct 2026 10:10:39 +0200 Subject: [PATCH 1/2] Privacy gateway: redact secrets and personal data from outbound prompts (#295) A deterministic filter on every prompt Solenta hands a provider CLI: normal runs (prompt, hand-off, notes, PDF text, memory bootstrap), steers, Ask and /btw. Per-provider level in Settings > Advanced: off (default), secrets, or strict (adds emails, Luhn-valid card numbers, US SSNs and user regexes). Each redacted turn gets an event row naming the rules and counts, never the values. The #394 export redactor now shares the same rules. Co-Authored-By: Claude Opus 5.5 --- electron/privacyGateway.js | 177 +++++++++++++++ electron/runner-ask-runs.js | 19 +- electron/runner.js | 15 +- electron/store-normalize.js | 5 + electron/store-settings-methods.js | 12 ++ electron/test/privacy-gateway.test.js | 201 ++++++++++++++++++ electron/threadReport.js | 35 +-- src/components/SettingsModal.tsx | 5 +- src/components/settings/AdvancedPane.tsx | 10 + .../settings/PrivacyGatewayEditor.tsx | 139 ++++++++++++ src/dev/settings.ts | 7 + src/shared/ipc.ts | 16 ++ test/settingsModal.test.tsx | 39 ++++ 13 files changed, 644 insertions(+), 36 deletions(-) create mode 100644 electron/privacyGateway.js create mode 100644 electron/test/privacy-gateway.test.js create mode 100644 src/components/settings/PrivacyGatewayEditor.tsx diff --git a/electron/privacyGateway.js b/electron/privacyGateway.js new file mode 100644 index 000000000..c8c1e3dba --- /dev/null +++ b/electron/privacyGateway.js @@ -0,0 +1,177 @@ +"use strict"; + +/** + * Privacy gateway (#295): a deterministic filter, not a prompt, on the text + * Solenta hands a provider CLI. The level is per provider + * (settings.privacyGateway): + * off - send as typed. The default, so upgrades change nothing. + * secrets - this thread's secret-card values (#289), the guardrail secret + * shapes, bearer tokens and whole PEM private-key blocks. + * strict - secrets, plus emails, card numbers, US SSNs and the user's + * own patterns. + * Each hit becomes [redacted:]. The thread gets one event row naming + * the rules and counts, never the values. + * + * ponytail: covers what Solenta composes (prompt, hand-off, memory note, PDF + * text, folded queue, steers, Ask and /btw). Files and tool output the agent + * reads itself go from the CLI straight to its provider. Covering those needs + * an egress proxy in front of the CLI. + */ + +const { SECRET_RULES, PLACEHOLDER } = require("./guardrails.js"); +const threadSecrets = require("./threadSecrets.js"); + +const LEVELS = ["off", "secrets", "strict"]; + +/** Global copies: guardrails only needs the first hit, redaction needs all. */ +const RULES = [ + [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, "secret.private-key"], + [/\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi, "secret.bearer"], + ...SECRET_RULES.map(([re, rule]) => [new RegExp(re.source, re.flags + "g"), rule]), +]; + +const PII_RULES = [ + [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g, "pii.email"], + // ponytail: 3/4/5/6 lead + Luhn keeps ms timestamps (they start with 1) out. + [/\b[3-6]\d{3}(?:[ -]?\d){9,15}\b/g, "pii.card"], + [/\b\d{3}-\d{2}-\d{4}\b/g, "pii.ssn"], +]; + +/** threadSecrets.redact's placeholder. */ +const CARD = /\[secret:[A-Z0-9_]+\]/g; + +/** @param {string} s */ +function luhn(s) { + const digits = s.replace(/\D/g, ""); + let sum = 0; + for (let i = 0; i < digits.length; i++) { + let d = Number(digits[digits.length - 1 - i]); + if (i % 2) d = d * 2 > 9 ? d * 2 - 9 : d * 2; + sum += d; + } + return sum % 10 === 0; +} + +/** + * @param {string} threadId + * @param {{ pii?: boolean, patterns?: string[] }} [opts] + */ +function redactor(threadId, opts = {}) { + const rules = opts.pii + ? [ + ...RULES, + ...PII_RULES, + ...(opts.patterns || []).map((p) => [new RegExp(p, "g"), "pattern"]), + ] + : RULES; + /** @type {Record} */ + const counts = {}; + /** @param {unknown} value @returns {string} */ + const text = (value) => { + const raw = String(value ?? ""); + let s = threadSecrets.redact(threadId, raw); + if (s !== raw) { + const n = (s.match(CARD) || []).length - (raw.match(CARD) || []).length; + counts["secret.request-card"] = (counts["secret.request-card"] || 0) + n; + } + for (const [re, rule] of rules) { + s = s.replace(/** @type {RegExp} */ (re), (m) => { + if (!m) return m; + if (rule === "secret.assignment" && PLACEHOLDER.test(m)) return m; + if (rule === "pii.card" && !luhn(m)) return m; + counts[rule] = (counts[rule] || 0) + 1; + return `[redacted:${label(rule)}]`; + }); + } + return s; + }; + return { text, counts }; +} + +/** @param {string} rule */ +function label(rule) { + return rule.replace(/^(secret|pii)\./, ""); +} + +/** + * @param {{ privacyGateway?: { default?: string, providers?: Record } } | null | undefined} settings + * @param {string} provider + * @returns {"off" | "secrets" | "strict"} + */ +function levelFor(settings, provider) { + const pg = settings && settings.privacyGateway; + const level = (pg && pg.providers && pg.providers[provider]) || (pg && pg.default); + return LEVELS.includes(String(level)) ? /** @type {any} */ (level) : "off"; +} + +/** + * Filter one outbound prompt. `note` is the per-thread log line, null when + * nothing matched or the provider's level is off. + * @param {any} settings + * @param {string} threadId + * @param {string} provider + * @param {string} text + * @returns {{ text: string, note: string | null }} + */ +function filterOutbound(settings, threadId, provider, text) { + const level = levelFor(settings, provider); + if (level === "off") return { text, note: null }; + const r = redactor(threadId, { + pii: level === "strict", + patterns: settings.privacyGateway.patterns, + }); + const out = r.text(text); + const tally = Object.entries(r.counts).filter(([, n]) => n > 0); + if (!tally.length) return { text, note: null }; + const total = tally.reduce((n, [, c]) => n + c, 0); + const parts = tally.map(([rule, n]) => `${label(rule)} ×${n}`).join(", "); + return { + text: out, + note: `Privacy gateway (${level}, ${provider}) redacted ${total} item${total === 1 ? "" : "s"} before sending: ${parts}.`, + }; +} + +/** + * Heal or validate the settings slice. strict (settings:set) throws on a bad + * level or pattern; lenient (load) drops it. + * @param {unknown} raw + * @param {boolean} [strict] + * @returns {{ default: "off" | "secrets" | "strict", providers: Record, patterns: string[] }} + */ +function normalizePrivacyGateway(raw, strict = false) { + /** @type {{ default: any, providers: Record, patterns: string[] }} */ + const out = { default: "off", providers: {}, patterns: [] }; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + if (strict && raw != null) throw new Error("privacyGateway must be an object"); + return out; + } + const obj = /** @type {{ default?: unknown, providers?: unknown, patterns?: unknown }} */ (raw); + /** @param {unknown} v @param {string} what */ + const level = (v, what) => { + if (LEVELS.includes(String(v))) return v; + if (strict) throw new Error(`${what} must be off, secrets or strict`); + return null; + }; + if (obj.default != null) out.default = level(obj.default, "privacyGateway.default") || "off"; + if (obj.providers && typeof obj.providers === "object" && !Array.isArray(obj.providers)) { + for (const [id, v] of Object.entries(obj.providers)) { + const l = level(v, `privacyGateway level for ${id}`); + if (id && l) out.providers[id] = l; + } + } + if (Array.isArray(obj.patterns)) { + for (const p of obj.patterns) { + const s = typeof p === "string" ? p.trim() : ""; + if (!s) continue; + try { + new RegExp(s, "g"); + out.patterns.push(s); + } catch { + if (strict) throw new Error(`Invalid privacy pattern: ${s}`); + } + } + } + return out; +} + +module.exports = { redactor, levelFor, filterOutbound, normalizePrivacyGateway, LEVELS }; diff --git a/electron/runner-ask-runs.js b/electron/runner-ask-runs.js index fe52dcb6c..fdacdd073 100644 --- a/electron/runner-ask-runs.js +++ b/electron/runner-ask-runs.js @@ -7,6 +7,7 @@ const { randomUUID } = require("node:crypto"); const services = require("./services.js"); const ask = require("./ask.js"); const btw = require("./btw.js"); +const privacyGateway = require("./privacyGateway.js"); const { sanitizeAttachments } = require("./runnerHelpers.js"); /** @@ -164,7 +165,14 @@ function createAskRuns(ctx) { digestNote, matchNote, }; - const askPrompt = ask.buildAskPrompt(pack); + const gated = privacyGateway.filterOutbound( + store.getSettings(), + threadId, + resolveProvider(thread), + ask.buildAskPrompt(pack), + ); + if (gated.note) appendMessage(threadId, "event", gated.note, runId); + const askPrompt = gated.text; let answer = ""; let source = "retrieval"; @@ -309,7 +317,14 @@ function createAskRuns(ctx) { digestNote, matchNote, }; - const askPrompt = btw.buildBtwPrompt(pack); + const gated = privacyGateway.filterOutbound( + store.getSettings(), + threadId, + resolveProvider(thread), + btw.buildBtwPrompt(pack), + ); + if (gated.note) appendMessage(threadId, "event", gated.note); + const askPrompt = gated.text; let answer = ""; let source = "retrieval"; diff --git a/electron/runner.js b/electron/runner.js index 2b7fcd19c..8ca38a50d 100644 --- a/electron/runner.js +++ b/electron/runner.js @@ -21,6 +21,7 @@ const { createTurnSetup } = require("./runner-turn-setup.js"); const { createSubagents } = require("./runner-subagents.js"); const { createSessionRecording } = require("./runner-session-recording.js"); const threadSecrets = require("./threadSecrets.js"); +const privacyGateway = require("./privacyGateway.js"); const { createClaudeSessions } = require("./runner-claude-sessions.js"); const { createVerifyGate } = require("./runner-verify-gate.js"); const { createQuotaWait } = require("./runner-quota-wait.js"); @@ -2347,13 +2348,17 @@ function createRunner(opts) { // Native `/compact` goes out bare: Claude reads any trailing text as // summary instructions, and Codex swaps the turn for thread/compact/start. - const dispatchPrompt = isNativeCompactTurn( + const outbound = isNativeCompactTurn( provider, rawPrompt, dispatchThread.sessionId, ) ? "/compact" : promptPrefix + pdfNote + bootNote; + // Last stop before the CLI (#295): everything above is in `outbound`. + const { text: dispatchPrompt, note: privacyNote } = + privacyGateway.filterOutbound(store.getSettings(), threadId, provider, outbound); + if (privacyNote) appendMessage(threadId, "event", privacyNote, runId); const name = workflowNameFromThreadId(threadId); @@ -2524,7 +2529,12 @@ function createRunner(opts) { provider, attachments, }); - const text = prompt + attachmentPromptSection(attachments) + pdfNote; + const { text, note: privacyNote } = privacyGateway.filterOutbound( + store.getSettings(), + threadId, + provider, + prompt + attachmentPromptSection(attachments) + pdfNote, + ); // PDF extraction can outlast the run. if (active.get(threadId) !== entry || entry.stopping) { throw new Error("The run ended before the steer landed"); @@ -2549,6 +2559,7 @@ function createRunner(opts) { attachments, { steer: true, pdfText: Boolean(pdfNote) }, ); + if (privacyNote) appendMessage(threadId, "event", privacyNote, entry.runId); store.save(); pushDetail(threadId, entry.claudeState || null); return { runId: entry.runId }; diff --git a/electron/store-normalize.js b/electron/store-normalize.js index 72cb143e9..c95bc32d5 100644 --- a/electron/store-normalize.js +++ b/electron/store-normalize.js @@ -7,6 +7,7 @@ const { getProvider, honouredEfforts } = require("./providers.js"); const { normalizeMcpServers } = require("./mcp.js"); const { normalizeProviderInstances } = require("./providerInstances.js"); const { normalizeModelPrices } = require("./modelPrices.js"); +const { normalizePrivacyGateway } = require("./privacyGateway.js"); const { parsePolicy } = require("./governance.js"); /** @@ -433,6 +434,7 @@ function normalizeSettings(raw) { webhook: { url: null, onDone: true, onFailed: true, onWaiting: true }, modelPrices: {}, worktreeRoot: null, + privacyGateway: normalizePrivacyGateway(null), }; if (!raw || typeof raw !== "object") return settings; const obj = /** @type {{ dailyBudgetUsd?: unknown, orchestrationBudgetUsd?: unknown, autoSettleAfterDays?: unknown, mcpServers?: unknown }} */ ( @@ -620,6 +622,9 @@ function normalizeSettings(raw) { const root = /** @type {{ worktreeRoot?: unknown }} */ (obj).worktreeRoot; settings.worktreeRoot = typeof root === "string" && path.isAbsolute(root.trim()) ? root.trim() : null; + settings.privacyGateway = normalizePrivacyGateway( + /** @type {{ privacyGateway?: unknown }} */ (obj).privacyGateway, + ); return settings; } diff --git a/electron/store-settings-methods.js b/electron/store-settings-methods.js index 26266ab28..b8c6bd336 100644 --- a/electron/store-settings-methods.js +++ b/electron/store-settings-methods.js @@ -7,6 +7,7 @@ const { clampUiScale } = require("./zoom.js"); const { validateMcpServers, mergeMcpSettingsPatch } = require("./mcp.js"); const { validateProviderInstances } = require("./providerInstances.js"); const { validateModelPrices } = require("./modelPrices.js"); +const { normalizePrivacyGateway } = require("./privacyGateway.js"); const { isHttpUrl, validateAgentProfiles, @@ -106,6 +107,7 @@ class StoreSettingsMethods { webhook: n.webhook, modelPrices: n.modelPrices, worktreeRoot: n.worktreeRoot, + privacyGateway: n.privacyGateway, }; } @@ -486,6 +488,16 @@ class StoreSettingsMethods { // Whole-map replace: the editor sends every row, so a dropped row clears. this.data.settings.modelPrices = validateModelPrices(patch.modelPrices); } + if (Object.prototype.hasOwnProperty.call(patch, "privacyGateway")) { + const v = patch.privacyGateway; + if (!v || typeof v !== "object" || Array.isArray(v)) { + throw new Error("privacyGateway must be an object"); + } + this.data.settings.privacyGateway = normalizePrivacyGateway( + { ...this.data.settings.privacyGateway, ...patch.privacyGateway }, + true, + ); + } return this.getSettings(); } } diff --git a/electron/test/privacy-gateway.test.js b/electron/test/privacy-gateway.test.js new file mode 100644 index 000000000..65e641473 --- /dev/null +++ b/electron/test/privacy-gateway.test.js @@ -0,0 +1,201 @@ +/** + * Privacy gateway (#295): per-provider redaction of outbound prompts, and the + * runner join (the CLI sees the redacted prompt, the transcript keeps the raw + * one plus an event row naming what went). + * + * Run: npm run test:electron -- electron/test/privacy-gateway.test.js + */ +const { describe, it, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); +const { execFileSync } = require("node:child_process"); +const { + filterOutbound, + levelFor, + normalizePrivacyGateway, +} = require("../privacyGateway.js"); +const threadSecrets = require("../threadSecrets.js"); +const { Store } = require("../store.js"); +const services = require("../services.js"); +const { createRunner } = require("../runner.js"); +const { writeFakeBin } = require("./support/fakeBin.js"); +const { rmTree } = require("./support/rmTree.js"); + +const GH = "ghp_" + "a".repeat(36); +const settingsWith = (privacyGateway) => ({ privacyGateway }); + +describe("privacy gateway filter", () => { + it("is off unless a level is set, and per-provider beats default", () => { + assert.equal(levelFor({}, "claude"), "off"); + const s = settingsWith({ default: "secrets", providers: { opencode: "off" }, patterns: [] }); + assert.equal(levelFor(s, "claude"), "secrets"); + assert.equal(levelFor(s, "opencode"), "off"); + const off = filterOutbound(s, "t1", "opencode", `token ${GH}`); + assert.deepEqual(off, { text: `token ${GH}`, note: null }); + }); + + it("secrets level redacts token shapes but leaves personal data", () => { + const s = settingsWith({ default: "secrets", providers: {}, patterns: [] }); + const r = filterOutbound(s, "t1", "claude", `use ${GH} and mail ann@example.org`); + assert.equal(r.text, "use [redacted:github-token] and mail ann@example.org"); + assert.equal( + r.note, + "Privacy gateway (secrets, claude) redacted 1 item before sending: github-token ×1.", + ); + }); + + it("strict adds emails, Luhn-valid cards, SSNs and custom patterns", () => { + const s = settingsWith({ default: "strict", providers: {}, patterns: ["ACME-\\d{4}", "x*"] }); + const r = filterOutbound( + s, + "t1", + "codex", + "ann@example.org card 4111 1111 1111 1111, not 4111111111111112, ts 1760000000000, ssn 123-45-6789, ticket ACME-1234", + ); + assert.equal( + r.text, + "[redacted:email] card [redacted:card], not 4111111111111112, ts 1760000000000, ssn [redacted:ssn], ticket [redacted:pattern]", + ); + assert.match(r.note, /redacted 4 items before sending: email ×1, card ×1, ssn ×1, pattern ×1\./); + }); + + it("redacts this thread's secret-card values", () => { + threadSecrets.set("t-card", "DB_PASSWORD", "hunter2hunter2"); + try { + const s = settingsWith({ default: "secrets", providers: {}, patterns: [] }); + const r = filterOutbound(s, "t-card", "claude", "pw is hunter2hunter2"); + assert.equal(r.text, "pw is [secret:DB_PASSWORD]"); + assert.match(r.note, /request-card ×1/); + } finally { + threadSecrets.clear("t-card"); + } + }); + + it("settings heal on load and reject junk on set", () => { + assert.deepEqual(normalizePrivacyGateway({ default: "loud", providers: { a: "x", b: "strict" }, patterns: ["(", "ok"] }), { + default: "off", + providers: { b: "strict" }, + patterns: ["ok"], + }); + assert.throws(() => normalizePrivacyGateway({ default: "loud" }, true), /off, secrets or strict/); + assert.throws(() => normalizePrivacyGateway({ patterns: ["("] }, true), /Invalid privacy pattern/); + }); +}); + +describe("store settings round-trip", () => { + let tmpDir; + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-privacy-store-")); + }); + afterEach(async () => { + await rmTree(tmpDir); + }); + + it("defaults off, merges patches, persists", () => { + const file = path.join(tmpDir, "store.json"); + const store = new Store(file); + assert.deepEqual(store.getSettings().privacyGateway, { default: "off", providers: {}, patterns: [] }); + store.setSettings({ privacyGateway: { providers: { grok: "strict" } } }); + store.setSettings({ privacyGateway: { patterns: ["ACME-\\d+"] } }); + assert.throws(() => store.setSettings({ privacyGateway: "strict" }), /must be an object/); + store.saveNow(); + assert.deepEqual(new Store(file).getSettings().privacyGateway, { + default: "off", + providers: { grok: "strict" }, + patterns: ["ACME-\\d+"], + }); + }); +}); + +function waitFor(predicate, { timeoutMs = 15000, intervalMs = 20 } = {}) { + return new Promise((resolve, reject) => { + const start = Date.now(); + const tick = () => { + if (predicate()) return resolve(); + if (Date.now() - start > timeoutMs) return reject(new Error("waitFor timed out")); + setTimeout(tick, intervalMs); + }; + tick(); + }); +} + +describe("runner sends the redacted prompt", () => { + let tmpDir; + let store; + let runner; + let argvFile; + const prevEnv = {}; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-privacy-run-")); + argvFile = path.join(tmpDir, "argv.json"); + for (const k of ["CODER_SIMULATE", "CODER_GROK_BIN", "CODER_FAKE_GROK_ARGV_FILE", "HOME"]) { + prevEnv[k] = process.env[k]; + } + delete process.env.CODER_SIMULATE; + process.env.HOME = tmpDir; + process.env.CODER_FAKE_GROK_ARGV_FILE = argvFile; + process.env.CODER_GROK_BIN = writeFakeBin( + path.join(tmpDir, "fake-grok"), + `#!/usr/bin/env node +"use strict"; +require("fs").writeFileSync(process.env.CODER_FAKE_GROK_ARGV_FILE, JSON.stringify(process.argv.slice(1)), "utf8"); +function emit(o){process.stdout.write(JSON.stringify(o)+"\\n");} +emit({type:"system",subtype:"init",session_id:"g-priv-1",model:"grok-4.6"}); +emit({type:"assistant",message:{content:[{type:"text",text:"ok"}]}}); +emit({type:"result",subtype:"success",is_error:false,result:"ok",usage:{input_tokens:1,output_tokens:2},total_cost_usd:0,num_turns:1,session_id:"g-priv-1"}); +process.exit(0); +`, + ); + store = new Store(path.join(tmpDir, "store.json")); + const core = await import(pathToFileURL(path.join(__dirname, "../../core/dist/index.js")).href); + runner = createRunner({ store, core, pushFn: () => {}, tickMs: 15, userDataPath: tmpDir }); + const repo = path.join(tmpDir, "app"); + fs.mkdirSync(repo); + for (const args of [ + ["init"], + ["config", "user.email", "test@example.com"], + ["config", "user.name", "Test"], + ["commit", "--allow-empty", "-m", "init"], + ]) { + execFileSync("git", args, { cwd: repo, stdio: "ignore" }); + } + const project = await services.addProject(store, repo); + const thread = services.createThread(store, { projectId: project.id, title: "Priv" }); + services.setProvider(store, { threadId: thread.id, provider: "grok" }); + store.setSettings({ privacyGateway: { providers: { grok: "strict" } } }); + store.saveNow(); + }); + + afterEach(async () => { + if (runner) runner.stopAll(); + runner = null; + await rmTree(tmpDir); + for (const [k, v] of Object.entries(prevEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + it("redacts the -p prompt, keeps the raw transcript, logs an event", async () => { + const thread = store.getThreads()[0]; + const prompt = `deploy with ${GH} and ping ann@example.org`; + await runner.startRun({ threadId: thread.id, prompt }); + await waitFor(() => store.getThread(thread.id).status === "done"); + + const argv = JSON.parse(fs.readFileSync(argvFile, "utf8")); + const sent = String(argv[argv.indexOf("-p") + 1]); + assert.match(sent, /deploy with \[redacted:github-token\] and ping \[redacted:email\]/); + assert.ok(!sent.includes(GH) && !sent.includes("ann@example.org")); + + const msgs = store.getMessages(thread.id); + assert.ok(msgs.some((m) => m.role === "user" && m.text === prompt)); + const event = msgs.find((m) => m.role === "event" && /^Privacy gateway/.test(m.text)); + assert.ok(event, "expected a privacy gateway event row"); + assert.match(event.text, /\(strict, grok\).*github-token ×1.*email ×1/); + assert.ok(!event.text.includes(GH)); + }); +}); diff --git a/electron/threadReport.js b/electron/threadReport.js index 10ca799ec..5aa4e8618 100644 --- a/electron/threadReport.js +++ b/electron/threadReport.js @@ -8,51 +8,26 @@ * Redaction runs on every string that reaches the file: this thread's * secret-card values (#289), the guardrail secret shapes, bearer tokens, * whole PEM private-key blocks, and the home directory (it names the user). - * ponytail: the privacy gateway (#295) is not built yet; this reuses the - * guardrail rules it will share. Point redactor() at it when it lands. + * The secret rules are the privacy gateway's (#295) "secrets" level. */ const fs = require("node:fs"); const os = require("node:os"); const { execFile } = require("node:child_process"); -const { SECRET_RULES, PLACEHOLDER } = require("./guardrails.js"); -const threadSecrets = require("./threadSecrets.js"); - -/** Global copies: guardrails only needs the first hit, the export needs all. */ -const RULES = [ - [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, "secret.private-key"], - [/\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi, "secret.bearer"], - ...SECRET_RULES.map(([re, rule]) => [new RegExp(re.source, re.flags + "g"), rule]), -]; - -/** threadSecrets.redact's placeholder. */ -const CARD = /\[secret:[A-Z0-9_]+\]/g; +const privacyGateway = require("./privacyGateway.js"); /** * @param {string} threadId * @param {string} [home] */ function redactor(threadId, home = os.homedir()) { - /** @type {Record} */ - const counts = {}; + const r = privacyGateway.redactor(threadId); /** @param {unknown} value @returns {string} */ const text = (value) => { - const raw = String(value ?? ""); - let s = threadSecrets.redact(threadId, raw); - if (s !== raw) { - const n = (s.match(CARD) || []).length - (raw.match(CARD) || []).length; - counts["secret.request-card"] = (counts["secret.request-card"] || 0) + n; - } - for (const [re, rule] of RULES) { - s = s.replace(re, (m) => { - if (rule === "secret.assignment" && PLACEHOLDER.test(m)) return m; - counts[rule] = (counts[rule] || 0) + 1; - return `[redacted:${rule.replace(/^secret\./, "")}]`; - }); - } + const s = r.text(value); return home && home.length > 1 ? s.split(home).join("~") : s; }; - return { text, counts }; + return { text, counts: r.counts }; } /** @param {string} s */ diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index e8cb0e784..24a4beb49 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -120,8 +120,8 @@ const PANE_META: Record< }, advanced: { label: "Advanced", - hint: "Guardrails, telemetry export, and importing Vibe Kanban boards.", - keywords: "guardrails safety security protection tools secrets injection otel opentelemetry otlp traces headers vibe kanban import", + hint: "Guardrails, the privacy gateway, telemetry export, and importing Vibe Kanban boards.", + keywords: "guardrails safety security protection tools secrets injection privacy gateway redact redaction pii personal data email otel opentelemetry otlp traces headers vibe kanban import", }, }; @@ -756,6 +756,7 @@ export function SettingsModal({ {pane === "advanced" && ( ): string { @@ -34,6 +36,7 @@ export function parseOtelHeaders(text: string): Record { export function AdvancedPane({ settings, + providers, saving, error, setError, @@ -48,6 +51,7 @@ export function AdvancedPane({ onSaveSettings, }: { settings: AppSettings | null; + providers: ProviderInfo[]; saving: boolean; error: string | null; setError: (error: string | null) => void; @@ -116,6 +120,12 @@ export function AdvancedPane({ + +

OpenTelemetry

diff --git a/src/components/settings/PrivacyGatewayEditor.tsx b/src/components/settings/PrivacyGatewayEditor.tsx new file mode 100644 index 000000000..28613bafa --- /dev/null +++ b/src/components/settings/PrivacyGatewayEditor.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import type { + AppSettings, + PrivacyGatewaySettings, + PrivacyLevel, + ProviderInfo, +} from "../../shared/ipc"; +import styles from "../SettingsModal.module.css"; + +const LEVELS: { value: PrivacyLevel; label: string }[] = [ + { value: "off", label: "Off" }, + { value: "secrets", label: "Secrets" }, + { value: "strict", label: "Secrets and personal data" }, +]; + +const EMPTY: PrivacyGatewaySettings = { default: "off", providers: {}, patterns: [] }; + +/** Privacy gateway (#295): per-provider redaction of outbound prompts. */ +export function PrivacyGatewayEditor({ + settings, + providers, + onSaveSettings, +}: { + settings: AppSettings | null; + providers: ProviderInfo[]; + onSaveSettings: (patch: Partial) => Promise; +}) { + const pg = settings?.privacyGateway ?? EMPTY; + const [patternsText, setPatternsText] = useState(() => pg.patterns.join("\n")); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const save = async (patch: Partial) => { + setSaving(true); + setError(null); + try { + const saved = await onSaveSettings({ privacyGateway: { ...pg, ...patch } }); + setPatternsText((saved.privacyGateway ?? EMPTY).patterns.join("\n")); + } catch (err) { + setError(err instanceof Error && err.message ? err.message : "Failed to save settings"); + } finally { + setSaving(false); + } + }; + + const setProvider = (id: string, value: string) => { + const next = { ...pg.providers }; + if (value) next[id] = value as PrivacyLevel; + else delete next[id]; + void save({ providers: next }); + }; + + const defaultLabel = LEVELS.find((l) => l.value === pg.default)?.label ?? "Off"; + + return ( +
+

Privacy gateway

+
+ + +
+ {providers.map((p) => ( +
+ + +
+ ))} +
+ +