mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 10:40:32 +01:00
Stop learnings, timeline events and brain sync from storing Stripe, Slack, npm and database secrets (GST-2196) (#4247)
* Add failing tests: four HIGH secret families pass brain sync and the learnings and timeline writers (GST-2196) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Refuse HIGH-tier secrets in learnings and timeline rows and hold four more secret families in brain sync (GST-2196) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Give the shadow-bin test fixtures the lib directory the timeline logger now imports (GST-2196) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Copy lib/ into the four agent test fixtures that run the timeline and learnings loggers (GST-2196) The loggers now import lib/redact-engine.ts, so the bws operational-learning, session-intelligence, context-skills and skillify E2E fixtures need lib/ beside their bin/. A copy, not a symlink, because a live agent runs inside each fixture. Review-Finding: p1/specialist-batched#1 reader=specialist-batched Review-Finding: p1/specialist-batched#2 reader=specialist-batched Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
76e2d3f987
commit
97b796e5ae
13 files changed
+196
-16
No files matched your search
@@ -379,6 +379,11 @@ describe('gstack-brain-sync secret scan', () => {
|
||||
['pem-block', '-----BEGIN PRIVATE KEY-----'],
|
||||
['jwt', 'eyJ0eXAiOiJKV1QiLCJh.eyJzdWIiOiIxMjM0NTY3.SflKxwRJSMeKKF30oGTbU'],
|
||||
['bearer-json', '"authorization":"Bearer abcdef1234567890abcdef1234567890"'],
|
||||
// GST-2196: redact-engine rates these HIGH; the scanner used to push them.
|
||||
['stripe-live-key', 'sk_' + 'live_' + 'Q7d2Lm9Xr4Tb8Vn1Kp3Wz6Hc'],
|
||||
['slack-token', 'xox' + 'b-' + '2384710293-4829103847-Zr8Qm2Lx7Vn4Tb'],
|
||||
['db-url-password', 'DATABASE_URL=postgres://app:' + 'Vq8zR2' + 'mLp7@db.internal.example.com:5432/prod'],
|
||||
['npm-token', 'npm_' + 'a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8'],
|
||||
];
|
||||
|
||||
for (const [name, content] of SECRETS) {
|
||||
@@ -406,6 +411,27 @@ describe('gstack-brain-sync secret scan', () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('prose about tokens and passwords and placeholder database urls still sync', () => {
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||
const rows = [
|
||||
{ insight: 'Rotate the npm token before release; the Slack bot token and the password live in the vault' },
|
||||
{ insight: 'DATABASE_URL=postgres://app:PASSWORD@host/db or postgres://app:${DB_PASSWORD}@db/prod' },
|
||||
{ insight: 'local dev uses postgres://postgres:postgres@localhost:5432/app' },
|
||||
{ insight: 'cache is redis://localhost:6379', owner: 'ops@team.example.com' },
|
||||
{ insight: 'test keys look like sk_' + 'live_' + 'EXAMPLEEXAMPLEEXAMPLEEXAMPLE' },
|
||||
];
|
||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'),
|
||||
rows.map((r) => JSON.stringify(r)).join('\n') + '\n');
|
||||
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||
const status = JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
|
||||
expect(status.held_count ?? 0).toBe(0);
|
||||
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(log.stdout).toMatch(/sync: 1 file/);
|
||||
});
|
||||
|
||||
test('--skip-file unblocks specific file', () => {
|
||||
run(['gstack-artifacts-init', '--remote', bareRemote]);
|
||||
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
|
||||
|
||||
@@ -458,6 +458,7 @@ describe('gstack-skill-start behavior', () => {
|
||||
const log = path.join(tmp, 'egress.log');
|
||||
const sinks = ['gstack-telemetry-log', 'gstack-brain-sync', 'gstack-update-check'];
|
||||
fs.mkdirSync(shadow); fs.mkdirSync(gh);
|
||||
fs.symlinkSync(path.join(ROOT, 'lib'), path.join(tmp, 'lib'));
|
||||
for (const name of fs.readdirSync(path.join(ROOT, 'bin'))) {
|
||||
if (!sinks.includes(name)) fs.symlinkSync(path.join(ROOT, 'bin', name), path.join(shadow, name));
|
||||
}
|
||||
|
||||
@@ -345,3 +345,48 @@ describe('G3: learnings-log temp file and TMPDIR', () => {
|
||||
expect(stderr).toContain('TMPDIR');
|
||||
});
|
||||
});
|
||||
|
||||
// The four secret families brain sync's scanner used to miss (GST-2196).
|
||||
// Built by concatenation so this file never holds a scannable literal.
|
||||
const SECRET_FAMILIES: [string, string][] = [
|
||||
['stripe live key', 'sk_' + 'live_' + 'Q7d2Lm9Xr4Tb8Vn1Kp3Wz6Hc'],
|
||||
['slack bot token', 'xox' + 'b-' + '2384710293-4829103847-Zr8Qm2Lx7Vn4Tb'],
|
||||
['database url with a password', 'postgres://app:' + 'Vq8zR2' + 'mLp7@db.internal.example.com:5432/prod'],
|
||||
['npm token', 'npm_' + 'a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8'],
|
||||
];
|
||||
|
||||
describe('gstack-learnings-log refuses HIGH-tier secrets', () => {
|
||||
for (const [name, secret] of SECRET_FAMILIES) {
|
||||
test(`rejects a ${name} in the insight and writes nothing`, () => {
|
||||
const input = JSON.stringify({ skill: 'review', type: 'operational', key: 'env-setup', insight: `DATABASE_URL and keys: ${secret}`, confidence: 7, source: 'observed' });
|
||||
const result = runLog(input, { expectFail: true });
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stdout).toContain('HIGH-tier secret');
|
||||
expect(result.stdout).not.toContain(secret);
|
||||
expect(findLearningsFile()).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
test('scans every stored field, not just the insight', () => {
|
||||
const input = JSON.stringify({ skill: 'review', type: 'tool', key: 'npm-publish', insight: 'publish needs a token', confidence: 6, source: 'observed', files: [SECRET_FAMILIES[3][1]] });
|
||||
const result = runLog(input, { expectFail: true });
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(findLearningsFile()).toBeNull();
|
||||
});
|
||||
|
||||
test('prose about tokens and passwords still writes', () => {
|
||||
const insight = 'Rotate the npm token before release; the password field and the Slack bot token live in the vault, never in the repo';
|
||||
runLog(JSON.stringify({ skill: 'review', type: 'operational', key: 'secret-handling', insight, confidence: 8, source: 'observed' }));
|
||||
const f = findLearningsFile();
|
||||
expect(f).not.toBeNull();
|
||||
expect(JSON.parse(fs.readFileSync(f!, 'utf-8').trim()).insight).toBe(insight);
|
||||
});
|
||||
|
||||
test('placeholder database urls still write', () => {
|
||||
const insight = 'Set DATABASE_URL=postgres://app:PASSWORD@host/db, or postgres://app:${DB_PASSWORD}@db/prod in CI';
|
||||
runLog(JSON.stringify({ skill: 'review', type: 'operational', key: 'db-url-shape', insight, confidence: 8, source: 'observed' }));
|
||||
const f = findLearningsFile();
|
||||
expect(f).not.toBeNull();
|
||||
expect(JSON.parse(fs.readFileSync(f!, 'utf-8').trim()).insight).toBe(insight);
|
||||
});
|
||||
});
|
||||
@@ -223,13 +223,12 @@ Report the exact output — either "READY: <path>" or "NEEDS_SETUP".`,
|
||||
fs.copyFileSync(path.join(ROOT, 'bin', script), path.join(binDir, script));
|
||||
fs.chmodSync(path.join(binDir, script), 0o755);
|
||||
}
|
||||
// gstack-learnings-log imports $SCRIPT_DIR/../lib/jsonl-store.ts (shared
|
||||
// injection patterns, since v1.57.5.0) — a real install always ships bin/
|
||||
// and lib/ together, so the fixture must too. Without it the bin exits 1
|
||||
// before writing anything and the test fails on every attempt.
|
||||
const libDir = path.join(opDir, 'lib');
|
||||
fs.mkdirSync(libDir, { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, 'lib', 'jsonl-store.ts'), path.join(libDir, 'jsonl-store.ts'));
|
||||
// gstack-learnings-log imports $SCRIPT_DIR/../lib/jsonl-store.ts and
|
||||
// lib/redact-engine.ts (which imports further lib modules) — a real install
|
||||
// always ships bin/ and lib/ together, so the fixture must too. Without it
|
||||
// the bin exits 1 before writing anything and the test fails on every
|
||||
// attempt. A copy, not a symlink: the agent runs inside this directory.
|
||||
fs.cpSync(path.join(ROOT, 'lib'), path.join(opDir, 'lib'), { recursive: true });
|
||||
|
||||
// gstack-learnings-log will create the project dir automatically via gstack-slug
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ function setupWorkdir(suffix: string): { workDir: string; gstackHome: string; sl
|
||||
fs.chmodSync(path.join(binDir, script), 0o755);
|
||||
}
|
||||
}
|
||||
// gstack-timeline-log and gstack-learnings-log import ../lib/redact-engine.ts
|
||||
// from beside bin/. A copy, not a symlink: the agent runs inside workDir.
|
||||
fs.cpSync(path.join(ROOT, 'lib'), path.join(workDir, 'lib'), { recursive: true });
|
||||
|
||||
// Routing CLAUDE.md: the ## Skill routing section gstack ships.
|
||||
fs.writeFileSync(path.join(workDir, 'CLAUDE.md'), `# Project Instructions
|
||||
|
||||
@@ -44,6 +44,9 @@ describeIfSelected('Session Intelligence E2E', [
|
||||
// gstack-skill-start and gstack-context-recovery, which call further bin
|
||||
// scripts, so ./bin/ is the whole runtime bin directory.
|
||||
fs.symlinkSync(path.join(ROOT, 'bin'), path.join(workDir, 'bin'), 'dir');
|
||||
// gstack-timeline-log and gstack-learnings-log import ../lib/redact-engine.ts
|
||||
// from beside ./bin/. A copy, not a symlink: the agent runs inside workDir.
|
||||
fs.cpSync(path.join(ROOT, 'lib'), path.join(workDir, 'lib'), { recursive: true });
|
||||
|
||||
// Compute slug (same logic as gstack-slug without git remote)
|
||||
slug = path.basename(workDir).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
|
||||
@@ -109,6 +109,9 @@ function setupSkillifyWorkdir(suffix: string, installSkills: string[] = ['scrape
|
||||
fs.chmodSync(path.join(binDir, script), 0o755);
|
||||
}
|
||||
}
|
||||
// gstack-timeline-log and gstack-learnings-log import ../lib/redact-engine.ts
|
||||
// from beside bin/. A copy, not a symlink: the agent runs inside workDir.
|
||||
fs.cpSync(path.join(ROOT, 'lib'), path.join(workDir, 'lib'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(workDir, 'CLAUDE.md'), `# Project Instructions
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ describe('skill-end completion durability', () => {
|
||||
|
||||
test('logger returns only after a delayed enqueue makes its spool record durable', () => {
|
||||
const shadow = path.join(fixture, 'bin'); fs.mkdirSync(shadow);
|
||||
fs.symlinkSync(path.resolve(BIN, '../lib'), path.join(fixture, 'lib'));
|
||||
for (const f of fs.readdirSync(BIN)) { fs.symlinkSync(path.join(BIN, f), path.join(shadow, f)); }
|
||||
for (const f of ['gstack-timeline-log', 'gstack-brain-enqueue']) { fs.unlinkSync(path.join(shadow, f)); }
|
||||
fs.copyFileSync(path.join(BIN, 'gstack-timeline-log'), path.join(shadow, 'gstack-timeline-log'));
|
||||
|
||||
@@ -283,3 +283,33 @@ describe('gstack-timeline-read', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The four secret families brain sync's scanner used to miss (GST-2196).
|
||||
// Built by concatenation so this file never holds a scannable literal.
|
||||
const SECRET_FAMILIES: [string, string][] = [
|
||||
['stripe live key', 'sk_' + 'live_' + 'Q7d2Lm9Xr4Tb8Vn1Kp3Wz6Hc'],
|
||||
['slack bot token', 'xox' + 'b-' + '2384710293-4829103847-Zr8Qm2Lx7Vn4Tb'],
|
||||
['database url with a password', 'postgres://app:' + 'Vq8zR2' + 'mLp7@db.internal.example.com:5432/prod'],
|
||||
['npm token', 'npm_' + 'a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8'],
|
||||
];
|
||||
|
||||
describe('gstack-timeline-log refuses HIGH-tier secrets', () => {
|
||||
for (const [name, secret] of SECRET_FAMILIES) {
|
||||
test(`rejects a ${name} in any field and writes nothing`, () => {
|
||||
const input = JSON.stringify({ skill: 'ship', event: 'completed', branch: 'main', outcome: `deploy failed: ${secret}` });
|
||||
const result = runLog(input, { expectFail: true });
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stdout).toContain('HIGH-tier secret');
|
||||
expect(result.stdout).not.toContain(secret);
|
||||
expect(findTimelineFile()).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
test('ordinary events with token and password words still write', () => {
|
||||
const input = '{"skill":"review","event":"completed","branch":"fix-token-refresh","outcome":"password reset flow reviewed; token rotation ok"}';
|
||||
expect(runLog(input).exitCode).toBe(0);
|
||||
const f = findTimelineFile();
|
||||
expect(f).not.toBeNull();
|
||||
expect(JSON.parse(fs.readFileSync(f!, 'utf-8').trim()).outcome).toBe('password reset flow reviewed; token rotation ok');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user