From b79a5281aa90a90151d0e3ce87c0e54164d7c8be Mon Sep 17 00:00:00 2001 From: Jayesh Betala Date: Wed, 27 May 2026 13:25:26 +0530 Subject: [PATCH 1/2] fix(learnings): fail closed when cross-project row lacks trusted field The --cross-project trust gate used a denylist (e.trusted === false), so rows with no trusted field (legacy rows written before the field existed in #988, hand-edited rows, or rows from other tools) were admitted because undefined === false is false. Switch to an allowlist (e.trusted !== true) to match the documented intent: cross-project learnings load only when explicitly trusted. Current-format rows are unaffected. --- bin/gstack-learnings-search | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index 665be6fc1..a8756e61a 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -90,10 +90,13 @@ for (const taggedLine of lines) { const isCrossProject = sourceTag === 'cross'; e._crossProject = isCrossProject; - // Trust gate: cross-project learnings only loaded if trusted (user-stated) - // This prevents prompt injection from one project's AI-generated learnings - // silently influencing reviews in another project. - if (isCrossProject && e.trusted === false) continue; + // Trust gate: cross-project learnings only loaded if explicitly trusted + // (user-stated). This prevents prompt injection from one project's + // AI-generated learnings silently influencing reviews in another project. + // Fail closed: rows missing the trusted field (legacy entries written + // before the field existed, hand-edited rows, or rows from other tools) + // are treated as untrusted rather than admitted by default. + if (isCrossProject && e.trusted !== true) continue; entries.push(e); } catch {} From 956ade3d431fb6738f1e008920a846f3a5f129e7 Mon Sep 17 00:00:00 2001 From: Jayesh Betala Date: Wed, 27 May 2026 13:25:26 +0530 Subject: [PATCH 2/2] test(learnings): cover cross-project rows missing the trusted field --- test/gstack-learnings-search.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/gstack-learnings-search.test.ts b/test/gstack-learnings-search.test.ts index bef562598..7a2a56e69 100644 --- a/test/gstack-learnings-search.test.ts +++ b/test/gstack-learnings-search.test.ts @@ -33,6 +33,9 @@ beforeAll(() => { const otherEntries = [ { ts: '2026-05-04T00:00:00Z', skill: 'test', type: 'pattern', key: 'foreign-observed', insight: 'A foreign observed insight', confidence: 8, source: 'observed', trusted: false, files: [] }, { ts: '2026-05-05T00:00:00Z', skill: 'test', type: 'pattern', key: 'foreign-user', insight: 'A foreign user-stated insight', confidence: 8, source: 'user-stated', trusted: true, files: [] }, + // Legacy / hand-written / third-party row written before the trusted field + // existed: no `trusted` key at all. Must NOT be admitted cross-project. + { ts: '2026-05-06T00:00:00Z', skill: 'test', type: 'pattern', key: 'foreign-legacy', insight: 'A foreign legacy insight', confidence: 8, source: 'observed', files: [] }, ]; fs.writeFileSync(path.join(projDir, 'learnings.jsonl'), entries.map(e => JSON.stringify(e)).join('\n') + '\n'); fs.writeFileSync(path.join(otherProjDir, 'learnings.jsonl'), otherEntries.map(e => JSON.stringify(e)).join('\n') + '\n'); @@ -79,4 +82,11 @@ describe('gstack-learnings-search cross-project trust gating', () => { expect(out).toContain('[cross-project]'); expect(out).not.toContain('foreign-observed'); }); + + test('cross-project mode rejects foreign rows missing the trusted field (fail closed)', () => { + const out = run(['--cross-project', '--query', 'foreign']); + // Legacy/hand-written rows with no `trusted` field must be treated as + // untrusted, not admitted by default — otherwise the trust gate fails open. + expect(out).not.toContain('foreign-legacy'); + }); });