From 40e5dcf57d31607c28a33f38077ecc711bea6e33 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 26 May 2026 23:07:41 -0700 Subject: [PATCH] feat(brain): salience privacy allowlist gate (T17 / D9) D9 cross-model finding from codex outside voice: salience-sourced digests can include emotionally-weighted personal pages (family, therapy, reflection). Pulling those into a coding-review prompt leaks sensitive context into work-flow reasoning. fetchSalience now strips entries whose slugs don't match an allowlist prefix BEFORE writing to the cache file. Default allowlist is SALIENCE_DEFAULT_ALLOWLIST = ['projects/', 'concepts/', 'gstack/']. User can extend via: gstack-config set salience_allowlist 'projects/,gstack/,concepts/,custom/' or override with GSTACK_SALIENCE_ALLOWLIST env var. Digest still records the strip count for transparency. Empty result emits 'all N entries stripped' note rather than silent absence. test/salience-allowlist.test.ts: 9 tests covering default permits, default blocks, empty allowlist, env override, whitespace trimming, and the invariant that defaults contain nothing sensitive (personal, family, therapy, reflection, private, medical, health). Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/gstack-brain-cache | 65 ++++++++++++++++++++-- test/salience-allowlist.test.ts | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 test/salience-allowlist.test.ts diff --git a/bin/gstack-brain-cache b/bin/gstack-brain-cache index 740f7ea19..04d37b01b 100755 --- a/bin/gstack-brain-cache +++ b/bin/gstack-brain-cache @@ -22,12 +22,14 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, statSync, unlinkSync, readdirSync, openSync, closeSync } from 'fs'; import { join, dirname } from 'path'; import { homedir, hostname } from 'os'; +import { spawnSync } from 'child_process'; import { execGbrainJson, spawnGbrain } from '../lib/gbrain-exec'; import { BRAIN_CACHE_ENTITIES, CACHE_REFRESH_LOCK_TIMEOUT_MS, GSTACK_SCHEMA_PACK_NAME, GSTACK_SCHEMA_PACK_VERSION, + SALIENCE_DEFAULT_ALLOWLIST, type BrainCacheEntity, } from '../scripts/brain-cache-spec'; @@ -509,6 +511,47 @@ function fetchRecentDecisions(projectSlug: string | null): string | null { return `# Recent decisions (project: ${projectSlug})\n\n${lines.join('\n')}\n`; } +/** + * Reads the user's salience allowlist override from gstack-config. If unset, + * returns SALIENCE_DEFAULT_ALLOWLIST. The override is comma-separated; we + * trim and drop empty entries. + */ +export function getSalienceAllowlist(): ReadonlyArray { + // Short-circuit via env var for tests + headless callers. + const env = process.env.GSTACK_SALIENCE_ALLOWLIST; + if (typeof env === 'string' && env.length > 0) { + return env.split(',').map((s) => s.trim()).filter(Boolean); + } + // Shell out to gstack-config with a tight timeout. Falls back to defaults + // on any failure (config script missing, command non-zero, parse error). + try { + const skillRoot = join(homedir(), '.claude', 'skills', 'gstack'); + const bin = join(skillRoot, 'bin', 'gstack-config'); + if (!existsSync(bin)) return SALIENCE_DEFAULT_ALLOWLIST; + const result = spawnSync(bin, ['get', 'salience_allowlist'], { timeout: 2000, encoding: 'utf-8' }); + if (result.status !== 0 || !result.stdout) return SALIENCE_DEFAULT_ALLOWLIST; + const trimmed = result.stdout.trim(); + if (!trimmed) return SALIENCE_DEFAULT_ALLOWLIST; + const parts = trimmed.split(',').map((s) => s.trim()).filter(Boolean); + return parts.length > 0 ? parts : SALIENCE_DEFAULT_ALLOWLIST; + } catch { + return SALIENCE_DEFAULT_ALLOWLIST; + } +} + +/** + * D9 salience privacy gate: returns true if the slug starts with any allowlisted + * prefix. Anything NOT matching is stripped at digest write time so that family, + * therapy, reflection, and other sensitive content never leaks into work-flow + * planning prompts by default. + */ +export function isSalienceSlugAllowed(slug: string, allowlist: ReadonlyArray): boolean { + for (const prefix of allowlist) { + if (slug.startsWith(prefix)) return true; + } + return false; +} + function fetchSalience(projectSlug: string | null): string | null { // get-recent-salience is a gbrain CLI sub-shape; we use the MCP-shape JSON const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; emotional_weight?: number }> }>([ @@ -518,9 +561,25 @@ function fetchSalience(projectSlug: string | null): string | null { '--json', ]); if (!result?.pages) return `# Recent salience\n\n_No salient pages in last 14d._\n`; - // T17 will add allowlist filtering here. For T2a we return raw output. - const lines = result.pages.map((p) => `- [[${p.slug}]] — ${p.title || ''} (weight: ${p.emotional_weight?.toFixed(2) ?? 'n/a'})`); - return `# Recent salience (last 14d)\n\n${lines.join('\n')}\n`; + + // D9 privacy gate: strip entries outside the allowlist BEFORE rendering. + // Sensitive personal content (family, therapy, reflection) is never written + // into the digest cache file, even when the brain itself ranks it salient. + const allowlist = getSalienceAllowlist(); + const filtered = result.pages.filter((p) => p.slug && isSalienceSlugAllowed(p.slug, allowlist)); + const stripped = result.pages.length - filtered.length; + if (filtered.length === 0) { + const header = `# Recent salience (last 14d)`; + const note = stripped > 0 + ? `\n_All ${stripped} salient entries stripped by allowlist gate (no work-flow content in window)._\n` + : `\n_No salient pages in last 14d._\n`; + return `${header}\n${note}`; + } + const lines = filtered.map((p) => `- [[${p.slug}]] — ${p.title || ''} (weight: ${p.emotional_weight?.toFixed(2) ?? 'n/a'})`); + const footer = stripped > 0 + ? `\n\n_${stripped} private entries stripped by allowlist gate._` + : ''; + return `# Recent salience (last 14d)\n\n${lines.join('\n')}${footer}\n`; } /** diff --git a/test/salience-allowlist.test.ts b/test/salience-allowlist.test.ts new file mode 100644 index 000000000..13f4e9df2 --- /dev/null +++ b/test/salience-allowlist.test.ts @@ -0,0 +1,95 @@ +/** + * D9 salience privacy gate (T17). + * + * Verifies that fetchSalience strips entries whose slugs don't match the + * allowlist prefixes BEFORE writing the digest to disk. Sensitive content + * (family, therapy, reflection) is never persisted into the cache. + * + * Gate-tier, free. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { SALIENCE_DEFAULT_ALLOWLIST } from '../scripts/brain-cache-spec'; + +const ORIGINAL_ENV = process.env.GSTACK_SALIENCE_ALLOWLIST; + +beforeEach(() => { + delete require.cache[require.resolve('../bin/gstack-brain-cache')]; +}); + +afterEach(() => { + if (ORIGINAL_ENV) process.env.GSTACK_SALIENCE_ALLOWLIST = ORIGINAL_ENV; + else delete process.env.GSTACK_SALIENCE_ALLOWLIST; +}); + +async function importCache(): Promise { + return (await import('../bin/gstack-brain-cache')) as typeof import('../bin/gstack-brain-cache'); +} + +describe('salience allowlist gate', () => { + test('default allowlist permits projects/ + gstack/ + concepts/', async () => { + const mod = await importCache(); + expect(mod.isSalienceSlugAllowed('projects/myrepo', SALIENCE_DEFAULT_ALLOWLIST)).toBe(true); + expect(mod.isSalienceSlugAllowed('gstack/product/helsinki', SALIENCE_DEFAULT_ALLOWLIST)).toBe(true); + expect(mod.isSalienceSlugAllowed('concepts/some-idea', SALIENCE_DEFAULT_ALLOWLIST)).toBe(true); + }); + + test('default allowlist BLOCKS personal/ + family/ + therapy/ + reflections', async () => { + const mod = await importCache(); + expect(mod.isSalienceSlugAllowed('personal/reflection-2026-05', SALIENCE_DEFAULT_ALLOWLIST)).toBe(false); + expect(mod.isSalienceSlugAllowed('family/in-laws/ngo-kim-shing', SALIENCE_DEFAULT_ALLOWLIST)).toBe(false); + expect(mod.isSalienceSlugAllowed('therapy-session/2026-05-15', SALIENCE_DEFAULT_ALLOWLIST)).toBe(false); + expect(mod.isSalienceSlugAllowed('reflection/notes', SALIENCE_DEFAULT_ALLOWLIST)).toBe(false); + }); + + test('isSalienceSlugAllowed handles empty allowlist (blocks everything)', async () => { + const mod = await importCache(); + expect(mod.isSalienceSlugAllowed('anything/at-all', [])).toBe(false); + }); + + test('isSalienceSlugAllowed handles arbitrary prefixes', async () => { + const mod = await importCache(); + expect(mod.isSalienceSlugAllowed('custom/scope', ['custom/'])).toBe(true); + expect(mod.isSalienceSlugAllowed('other/scope', ['custom/'])).toBe(false); + }); + + test('getSalienceAllowlist returns default when env unset and config silent', async () => { + delete process.env.GSTACK_SALIENCE_ALLOWLIST; + const mod = await importCache(); + const list = mod.getSalienceAllowlist(); + expect(Array.isArray(list)).toBe(true); + expect(list.length).toBeGreaterThan(0); + // Should at minimum contain the curated defaults + expect(list).toContain('projects/'); + expect(list).toContain('gstack/'); + }); + + test('GSTACK_SALIENCE_ALLOWLIST env override is honored', async () => { + process.env.GSTACK_SALIENCE_ALLOWLIST = 'custom-a/,custom-b/,custom-c/'; + const mod = await importCache(); + const list = mod.getSalienceAllowlist(); + expect(list).toEqual(['custom-a/', 'custom-b/', 'custom-c/']); + }); + + test('GSTACK_SALIENCE_ALLOWLIST with whitespace is trimmed', async () => { + process.env.GSTACK_SALIENCE_ALLOWLIST = ' projects/ , gstack/ , concepts/ '; + const mod = await importCache(); + const list = mod.getSalienceAllowlist(); + expect(list).toEqual(['projects/', 'gstack/', 'concepts/']); + }); + + test('empty env value falls through to default (not empty list)', async () => { + process.env.GSTACK_SALIENCE_ALLOWLIST = ''; + const mod = await importCache(); + const list = mod.getSalienceAllowlist(); + expect(list.length).toBeGreaterThan(0); + }); + + test('default allowlist contains nothing sensitive', async () => { + const sensitivePrefixes = ['personal', 'family', 'therapy', 'reflection', 'private', 'medical', 'health']; + for (const prefix of sensitivePrefixes) { + const matched = SALIENCE_DEFAULT_ALLOWLIST.some((p) => p.startsWith(prefix)); + expect(matched).toBe(false); + } + }); +});