mirror of https://github.com/garrytan/gstack.git
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) <noreply@anthropic.com>
This commit is contained in:
parent
9607a37616
commit
40e5dcf57d
|
|
@ -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<string> {
|
||||
// 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<string>): 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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<typeof import('../bin/gstack-brain-cache')> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue