@esparkman/pensieve 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # Pensieve
2
+
3
+ A persistent memory MCP server for Claude Code that remembers decisions, preferences, and context across conversation boundaries.
4
+
5
+ > *"I use the Pensieve. One simply siphons the excess thoughts from one's mind, pours them into the basin, and examines them at one's leisure."* — Albus Dumbledore
6
+
7
+ ## Problem
8
+
9
+ When Claude Code conversations are compacted or cleared:
10
+ - Agent "forgets" discovered patterns, decisions, and understanding
11
+ - User re-explains the same context repeatedly
12
+ - Agent may hallucinate or contradict previous decisions
13
+ - Momentum lost every few hours of deep work
14
+
15
+ ## Solution
16
+
17
+ Pensieve provides persistent storage via SQLite that Claude can access through native tool calls:
18
+ - `pensieve_remember` — Save decisions, preferences, discoveries, entities
19
+ - `pensieve_recall` — Query the knowledge base
20
+ - `pensieve_session_start` — Load context at conversation start
21
+ - `pensieve_session_end` — Persist learnings before ending
22
+
23
+ ## Installation
24
+
25
+ ### Option 1: Clone and Build (Recommended)
26
+
27
+ ```bash
28
+ # Clone the repository
29
+ git clone https://github.com/esparkman/pensieve.git ~/Development/pensieve
30
+ cd ~/Development/pensieve
31
+
32
+ # Install dependencies and build
33
+ npm install
34
+ npm run build
35
+
36
+ # Add to Claude Code
37
+ claude mcp add pensieve node ~/Development/pensieve/dist/index.js
38
+ ```
39
+
40
+ ### Option 2: npx (Coming Soon)
41
+
42
+ Once published to npm:
43
+
44
+ ```bash
45
+ claude mcp add pensieve npx @esparkman/pensieve
46
+ ```
47
+
48
+ ### Option 3: Docker
49
+
50
+ ```bash
51
+ # Clone the repository
52
+ git clone https://github.com/esparkman/pensieve.git
53
+ cd pensieve
54
+
55
+ # Build the Docker image
56
+ docker build -t pensieve .
57
+
58
+ # Add to Claude Code (mount your project for local database)
59
+ claude mcp add pensieve docker run -i --rm \
60
+ -v "$PWD/.pensieve:/app/.pensieve" \
61
+ -v "$HOME/.claude-pensieve:/root/.claude-pensieve" \
62
+ pensieve
63
+ ```
64
+
65
+ **Note:** The Docker approach mounts two volumes:
66
+ - `$PWD/.pensieve` — Project-local database (if in a git repo)
67
+ - `$HOME/.claude-pensieve` — Global fallback database
68
+
69
+ ### Verify Installation
70
+
71
+ After installing, restart Claude Code and check that Pensieve is loaded:
72
+
73
+ ```bash
74
+ # In a new Claude Code session, the tools should be available:
75
+ # pensieve_status, pensieve_remember, pensieve_recall, etc.
76
+ ```
77
+
78
+ ## Usage
79
+
80
+ After installing, Claude Code will have access to these tools:
81
+
82
+ ### Start a session
83
+
84
+ At the beginning of each conversation, Claude should call:
85
+
86
+ ```
87
+ pensieve_session_start()
88
+ ```
89
+
90
+ This loads the last session's summary, work in progress, key decisions, and preferences.
91
+
92
+ ### Remember things
93
+
94
+ ```
95
+ pensieve_remember({
96
+ type: "decision",
97
+ topic: "authentication",
98
+ decision: "Use Devise with magic links",
99
+ rationale: "Passwordless is more secure and user-friendly"
100
+ })
101
+
102
+ pensieve_remember({
103
+ type: "preference",
104
+ category: "testing",
105
+ key: "approach",
106
+ value: "system tests for UI flows"
107
+ })
108
+
109
+ pensieve_remember({
110
+ type: "entity",
111
+ name: "Customer",
112
+ description: "End user who places orders",
113
+ relationships: '{"belongs_to": ["Tenant"], "has_many": ["Orders"]}'
114
+ })
115
+
116
+ pensieve_remember({
117
+ type: "discovery",
118
+ category: "component",
119
+ name: "ButtonComponent",
120
+ location: "app/components/base/button_component.rb",
121
+ description: "Primary button component with variants"
122
+ })
123
+ ```
124
+
125
+ ### Recall things
126
+
127
+ ```
128
+ pensieve_recall({ query: "authentication" })
129
+ pensieve_recall({ type: "preferences" })
130
+ pensieve_recall({ type: "entities" })
131
+ pensieve_recall({ type: "session" })
132
+ pensieve_recall({ type: "questions" })
133
+ ```
134
+
135
+ ### End a session
136
+
137
+ Before ending a conversation:
138
+
139
+ ```
140
+ pensieve_session_end({
141
+ summary: "Completed invoice list component with filtering",
142
+ work_in_progress: "Invoice detail view partially designed",
143
+ next_steps: "Complete detail view, add PDF export",
144
+ key_files: ["app/components/invoices/invoice_list_component.rb"],
145
+ tags: ["invoices", "ui"]
146
+ })
147
+ ```
148
+
149
+ ## Database Location
150
+
151
+ Pensieve stores data in SQLite:
152
+
153
+ - **Project-local** (if `.git` or `.pensieve` exists): `.pensieve/memory.sqlite`
154
+ - **Global** (fallback): `~/.claude-pensieve/memory.sqlite`
155
+
156
+ This means each project gets its own memory, but you also have a global memory for general preferences.
157
+
158
+ ### Environment Variable Override
159
+
160
+ Set `PENSIEVE_DB_PATH` to explicitly specify the database location:
161
+
162
+ ```bash
163
+ PENSIEVE_DB_PATH=/custom/path/memory.sqlite claude mcp add pensieve ...
164
+ ```
165
+
166
+ ## Security
167
+
168
+ ### Secrets Detection
169
+
170
+ Pensieve automatically detects and **refuses to store** potential secrets including:
171
+ - API keys (AWS, GitHub, Stripe, etc.)
172
+ - Database connection strings with passwords
173
+ - Bearer tokens and private keys
174
+ - Credit card numbers and SSNs
175
+
176
+ If a secret is detected, the data is NOT saved and a warning is displayed.
177
+
178
+ ### Storage Limits
179
+
180
+ To prevent unbounded growth:
181
+ - **Decisions**: Max 1,000 (oldest auto-pruned)
182
+ - **Discoveries**: Max 500 (oldest auto-pruned)
183
+ - **Sessions**: Older than 90 days auto-deleted
184
+ - **Field length**: Max 10KB per field (truncated with warning)
185
+
186
+ ### Data Storage
187
+
188
+ Data is stored in plaintext SQLite. Do NOT store:
189
+ - Passwords or API keys
190
+ - Personal identifying information
191
+ - Financial credentials
192
+ - Any sensitive secrets
193
+
194
+ The database is local-only and never transmitted over the network.
195
+
196
+ ## Tools Reference
197
+
198
+ | Tool | Purpose |
199
+ |------|---------|
200
+ | `pensieve_remember` | Save decisions, preferences, discoveries, entities, or questions |
201
+ | `pensieve_recall` | Query the knowledge base |
202
+ | `pensieve_session_start` | Start a session and load prior context |
203
+ | `pensieve_session_end` | End a session and save a summary |
204
+ | `pensieve_resolve_question` | Mark an open question as resolved |
205
+ | `pensieve_status` | Get database location and counts |
206
+
207
+ ## Data Types
208
+
209
+ ### Decisions
210
+ Important choices with rationale. Searchable by topic.
211
+
212
+ ### Preferences
213
+ User conventions (coding style, testing approach, naming patterns).
214
+
215
+ ### Discoveries
216
+ Things found in the codebase (components, patterns, helpers).
217
+
218
+ ### Entities
219
+ Domain model understanding (models, relationships, attributes).
220
+
221
+ ### Sessions
222
+ Summaries of work sessions for continuity.
223
+
224
+ ### Open Questions
225
+ Unresolved blockers or questions to address.
226
+
227
+ ## Development
228
+
229
+ ```bash
230
+ cd ~/Development/pensieve
231
+ npm install
232
+ npm run dev # Run with tsx for development
233
+ npm run build # Build TypeScript
234
+ ```
235
+
236
+ ## License
237
+
238
+ MIT
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { MemoryDatabase, LIMITS } from '../database.js';
3
+ import { existsSync, rmSync, mkdirSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { tmpdir } from 'os';
6
+ describe('Database: Limits Configuration', () => {
7
+ it('exports expected limit values', () => {
8
+ expect(LIMITS.MAX_DECISIONS).toBe(1000);
9
+ expect(LIMITS.MAX_DISCOVERIES).toBe(500);
10
+ expect(LIMITS.MAX_ENTITIES).toBe(200);
11
+ expect(LIMITS.MAX_QUESTIONS).toBe(100);
12
+ expect(LIMITS.MAX_SESSIONS).toBe(100);
13
+ expect(LIMITS.SESSION_RETENTION_DAYS).toBe(90);
14
+ expect(LIMITS.MAX_FIELD_LENGTH).toBe(10000);
15
+ });
16
+ });
17
+ describe('Database: Field Truncation', () => {
18
+ let db;
19
+ let testDir;
20
+ beforeEach(() => {
21
+ // Create a temporary directory for tests
22
+ testDir = join(tmpdir(), `pensieve-test-${Date.now()}`);
23
+ mkdirSync(testDir, { recursive: true });
24
+ mkdirSync(join(testDir, '.pensieve'), { recursive: true });
25
+ db = new MemoryDatabase(testDir);
26
+ });
27
+ afterEach(() => {
28
+ db.close();
29
+ // Clean up test directory
30
+ if (existsSync(testDir)) {
31
+ rmSync(testDir, { recursive: true, force: true });
32
+ }
33
+ });
34
+ it('stores normal-length fields without modification', () => {
35
+ const decision = {
36
+ topic: 'test topic',
37
+ decision: 'A reasonable length decision',
38
+ rationale: 'Some rationale here',
39
+ };
40
+ db.addDecision(decision);
41
+ const results = db.searchDecisions('test topic');
42
+ expect(results).toHaveLength(1);
43
+ expect(results[0].topic).toBe('test topic');
44
+ expect(results[0].decision).toBe('A reasonable length decision');
45
+ expect(results[0].rationale).toBe('Some rationale here');
46
+ });
47
+ it('truncates fields that exceed MAX_FIELD_LENGTH', () => {
48
+ // Create a string that exceeds the limit
49
+ const longString = 'x'.repeat(LIMITS.MAX_FIELD_LENGTH + 1000);
50
+ const decision = {
51
+ topic: 'long field test',
52
+ decision: longString,
53
+ rationale: 'short',
54
+ };
55
+ db.addDecision(decision);
56
+ const results = db.searchDecisions('long field test');
57
+ expect(results).toHaveLength(1);
58
+ expect(results[0].decision.length).toBeLessThanOrEqual(LIMITS.MAX_FIELD_LENGTH + 20); // +20 for "... [truncated]"
59
+ expect(results[0].decision).toContain('... [truncated]');
60
+ });
61
+ it('truncates multiple long fields in the same record', () => {
62
+ const longTopic = 't'.repeat(LIMITS.MAX_FIELD_LENGTH + 500);
63
+ const longDecision = 'd'.repeat(LIMITS.MAX_FIELD_LENGTH + 500);
64
+ const longRationale = 'r'.repeat(LIMITS.MAX_FIELD_LENGTH + 500);
65
+ db.addDecision({
66
+ topic: longTopic,
67
+ decision: longDecision,
68
+ rationale: longRationale,
69
+ });
70
+ const results = db.getRecentDecisions(1);
71
+ expect(results).toHaveLength(1);
72
+ expect(results[0].topic).toContain('... [truncated]');
73
+ expect(results[0].decision).toContain('... [truncated]');
74
+ expect(results[0].rationale).toContain('... [truncated]');
75
+ });
76
+ it('handles null and undefined fields gracefully', () => {
77
+ const discovery = {
78
+ category: 'component',
79
+ name: 'TestComponent',
80
+ // location, description, metadata are undefined
81
+ };
82
+ const id = db.addDiscovery(discovery);
83
+ expect(id).toBeGreaterThan(0);
84
+ const results = db.searchDiscoveries('TestComponent');
85
+ expect(results).toHaveLength(1);
86
+ expect(results[0].location).toBeNull();
87
+ expect(results[0].description).toBeNull();
88
+ });
89
+ it('truncates discovery metadata when too long', () => {
90
+ const longMetadata = JSON.stringify({ data: 'x'.repeat(LIMITS.MAX_FIELD_LENGTH) });
91
+ db.addDiscovery({
92
+ category: 'pattern',
93
+ name: 'LongMetadata',
94
+ metadata: longMetadata,
95
+ });
96
+ const results = db.searchDiscoveries('LongMetadata');
97
+ expect(results).toHaveLength(1);
98
+ expect(results[0].metadata).toContain('... [truncated]');
99
+ });
100
+ it('truncates entity fields when too long', () => {
101
+ const longDescription = 'd'.repeat(LIMITS.MAX_FIELD_LENGTH + 100);
102
+ db.upsertEntity({
103
+ name: 'LongEntity',
104
+ description: longDescription,
105
+ });
106
+ const entity = db.getEntity('LongEntity');
107
+ expect(entity).toBeDefined();
108
+ expect(entity.description).toContain('... [truncated]');
109
+ });
110
+ it('truncates preference values when too long', () => {
111
+ const longValue = 'v'.repeat(LIMITS.MAX_FIELD_LENGTH + 100);
112
+ db.setPreference({
113
+ category: 'testing',
114
+ key: 'long_pref',
115
+ value: longValue,
116
+ });
117
+ const pref = db.getPreference('testing', 'long_pref');
118
+ expect(pref).toBeDefined();
119
+ expect(pref.value).toContain('... [truncated]');
120
+ });
121
+ it('truncates session summary when too long', () => {
122
+ const longSummary = 's'.repeat(LIMITS.MAX_FIELD_LENGTH + 100);
123
+ const sessionId = db.startSession();
124
+ db.endSession(sessionId, longSummary);
125
+ const session = db.getLastSession();
126
+ expect(session).toBeDefined();
127
+ expect(session.summary).toContain('... [truncated]');
128
+ });
129
+ it('truncates question fields when too long', () => {
130
+ const longQuestion = 'q'.repeat(LIMITS.MAX_FIELD_LENGTH + 100);
131
+ const longContext = 'c'.repeat(LIMITS.MAX_FIELD_LENGTH + 100);
132
+ db.addQuestion(longQuestion, longContext);
133
+ const questions = db.getOpenQuestions();
134
+ expect(questions).toHaveLength(1);
135
+ expect(questions[0].question).toContain('... [truncated]');
136
+ expect(questions[0].context).toContain('... [truncated]');
137
+ });
138
+ });
139
+ describe('Database: Storage Limits and Pruning', () => {
140
+ let db;
141
+ let testDir;
142
+ beforeEach(() => {
143
+ testDir = join(tmpdir(), `pensieve-test-${Date.now()}`);
144
+ mkdirSync(testDir, { recursive: true });
145
+ mkdirSync(join(testDir, '.pensieve'), { recursive: true });
146
+ db = new MemoryDatabase(testDir);
147
+ });
148
+ afterEach(() => {
149
+ db.close();
150
+ if (existsSync(testDir)) {
151
+ rmSync(testDir, { recursive: true, force: true });
152
+ }
153
+ });
154
+ it('maintains decision count at or below MAX_DECISIONS', () => {
155
+ // Add more decisions than the limit allows
156
+ // Note: This is a slow test, so we'll just test the mechanism with a smaller set
157
+ const testLimit = 10;
158
+ for (let i = 0; i < testLimit + 5; i++) {
159
+ db.addDecision({
160
+ topic: `topic-${i}`,
161
+ decision: `decision-${i}`,
162
+ });
163
+ }
164
+ // The actual pruning happens based on LIMITS.MAX_DECISIONS (1000)
165
+ // Since we only added 15, all should be present
166
+ const decisions = db.getRecentDecisions(100);
167
+ expect(decisions.length).toBe(testLimit + 5);
168
+ });
169
+ it('handles empty database gracefully', () => {
170
+ // Just verify no errors on empty db
171
+ expect(db.getRecentDecisions(10)).toEqual([]);
172
+ expect(db.getAllPreferences()).toEqual([]);
173
+ expect(db.getAllEntities()).toEqual([]);
174
+ expect(db.getOpenQuestions()).toEqual([]);
175
+ expect(db.getLastSession()).toBeUndefined();
176
+ });
177
+ it('search returns results across multiple fields', () => {
178
+ db.addDecision({
179
+ topic: 'authentication',
180
+ decision: 'Use Devise',
181
+ rationale: 'Well maintained gem',
182
+ });
183
+ db.addDecision({
184
+ topic: 'database',
185
+ decision: 'Use PostgreSQL for authentication data',
186
+ rationale: 'ACID compliance',
187
+ });
188
+ const results = db.searchDecisions('authentication');
189
+ expect(results.length).toBeGreaterThanOrEqual(2);
190
+ });
191
+ });
192
+ describe('Database: Path Resolution', () => {
193
+ it('uses PENSIEVE_DB_PATH environment variable when set', () => {
194
+ const testPath = join(tmpdir(), `pensieve-env-test-${Date.now()}`);
195
+ mkdirSync(testPath, { recursive: true });
196
+ const customDbPath = join(testPath, 'custom.sqlite');
197
+ process.env.PENSIEVE_DB_PATH = customDbPath;
198
+ try {
199
+ const db = new MemoryDatabase();
200
+ db.addDecision({ topic: 'test', decision: 'test' });
201
+ // Verify the database was created at the custom path
202
+ expect(existsSync(customDbPath)).toBe(true);
203
+ db.close();
204
+ }
205
+ finally {
206
+ delete process.env.PENSIEVE_DB_PATH;
207
+ rmSync(testPath, { recursive: true, force: true });
208
+ }
209
+ });
210
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,216 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { detectSecrets, checkFieldsForSecrets, formatSecretWarning } from '../security.js';
3
+ describe('Security: Secrets Detection', () => {
4
+ describe('AWS Keys', () => {
5
+ it('detects AWS Access Key ID', () => {
6
+ const result = detectSecrets('My key is AKIAIOSFODNN7EXAMPLE');
7
+ expect(result.containsSecret).toBe(true);
8
+ expect(result.warnings.some(w => w.includes('AWS'))).toBe(true);
9
+ });
10
+ it('detects AWS Access Key ID in context', () => {
11
+ const result = detectSecrets('aws_access_key_id = AKIAIOSFODNN7EXAMPLE');
12
+ expect(result.containsSecret).toBe(true);
13
+ });
14
+ it('detects potential AWS Secret Key (40 char base64-like)', () => {
15
+ const result = detectSecrets('secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
16
+ expect(result.containsSecret).toBe(true);
17
+ });
18
+ });
19
+ describe('GitHub Tokens', () => {
20
+ it('detects GitHub Personal Access Token (classic)', () => {
21
+ const result = detectSecrets('token: ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789');
22
+ expect(result.containsSecret).toBe(true);
23
+ expect(result.warnings.some(w => w.includes('GitHub'))).toBe(true);
24
+ });
25
+ it('detects GitHub Fine-grained PAT', () => {
26
+ const result = detectSecrets('GITHUB_TOKEN=github_pat_11ABCDEFG_abcdefghijklmnopqrstuvwxyz');
27
+ expect(result.containsSecret).toBe(true);
28
+ expect(result.warnings.some(w => w.includes('GitHub'))).toBe(true);
29
+ });
30
+ it('detects GitHub OAuth Token', () => {
31
+ const result = detectSecrets('oauth: gho_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789');
32
+ expect(result.containsSecret).toBe(true);
33
+ expect(result.warnings.some(w => w.includes('GitHub'))).toBe(true);
34
+ });
35
+ });
36
+ describe('Stripe Keys', () => {
37
+ // Build test keys dynamically to avoid GitHub secret scanning
38
+ const stripePrefix = 'sk_';
39
+ const liveKey = stripePrefix + 'live_51ABCdefGHIjklMNOpqrSTUvwxyz';
40
+ const testKey = stripePrefix + 'test_51ABCdefGHIjklMNOpqrSTUvwxyz';
41
+ it('detects Stripe live secret key', () => {
42
+ const result = detectSecrets(`STRIPE_SECRET_KEY=${liveKey}`);
43
+ expect(result.containsSecret).toBe(true);
44
+ expect(result.warnings.some(w => w.includes('Stripe'))).toBe(true);
45
+ });
46
+ it('detects Stripe test secret key', () => {
47
+ const result = detectSecrets(`stripe_key: ${testKey}`);
48
+ expect(result.containsSecret).toBe(true);
49
+ expect(result.warnings.some(w => w.includes('Stripe'))).toBe(true);
50
+ });
51
+ });
52
+ describe('Database Connection Strings', () => {
53
+ it('detects PostgreSQL connection string with password', () => {
54
+ const result = detectSecrets('DATABASE_URL=postgres://user:secretpassword@localhost:5432/mydb');
55
+ expect(result.containsSecret).toBe(true);
56
+ expect(result.warnings.some(w => w.includes('PostgreSQL'))).toBe(true);
57
+ });
58
+ it('detects PostgreSQL connection string (postgresql://)', () => {
59
+ const result = detectSecrets('postgresql://admin:p@ssw0rd@db.example.com/production');
60
+ expect(result.containsSecret).toBe(true);
61
+ });
62
+ it('detects MySQL connection string with password', () => {
63
+ const result = detectSecrets('mysql://root:supersecret@mysql.example.com:3306/app');
64
+ expect(result.containsSecret).toBe(true);
65
+ expect(result.warnings.some(w => w.includes('MySQL'))).toBe(true);
66
+ });
67
+ it('detects MongoDB connection string with password', () => {
68
+ const result = detectSecrets('mongodb://user:pass123@cluster.mongodb.net/db');
69
+ expect(result.containsSecret).toBe(true);
70
+ expect(result.warnings.some(w => w.includes('MongoDB'))).toBe(true);
71
+ });
72
+ it('detects MongoDB+srv connection string', () => {
73
+ const result = detectSecrets('mongodb+srv://admin:secret@cluster0.abc123.mongodb.net/mydb');
74
+ expect(result.containsSecret).toBe(true);
75
+ });
76
+ });
77
+ describe('Bearer Tokens', () => {
78
+ it('detects bearer token in Authorization header format', () => {
79
+ const result = detectSecrets('Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWI');
80
+ expect(result.containsSecret).toBe(true);
81
+ expect(result.warnings.some(w => w.includes('Bearer'))).toBe(true);
82
+ });
83
+ it('detects bearer token inline', () => {
84
+ const result = detectSecrets('Use bearer abc123def456ghi789jkl012mno345');
85
+ expect(result.containsSecret).toBe(true);
86
+ });
87
+ });
88
+ describe('Private Keys', () => {
89
+ it('detects RSA private key header', () => {
90
+ const result = detectSecrets('-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQ');
91
+ expect(result.containsSecret).toBe(true);
92
+ expect(result.warnings.some(w => w.includes('Private key'))).toBe(true);
93
+ });
94
+ it('detects generic private key header', () => {
95
+ const result = detectSecrets('-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMG');
96
+ expect(result.containsSecret).toBe(true);
97
+ });
98
+ it('detects OpenSSH private key header', () => {
99
+ const result = detectSecrets('-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1r');
100
+ expect(result.containsSecret).toBe(true);
101
+ expect(result.warnings.some(w => w.includes('SSH'))).toBe(true);
102
+ });
103
+ });
104
+ describe('Generic Secrets', () => {
105
+ it('detects password assignments', () => {
106
+ const result = detectSecrets('password = "mySuperSecretPass123"');
107
+ expect(result.containsSecret).toBe(true);
108
+ expect(result.warnings.some(w => w.includes('Password'))).toBe(true);
109
+ });
110
+ it('detects password with colon separator', () => {
111
+ const result = detectSecrets('password: verysecretpassword');
112
+ expect(result.containsSecret).toBe(true);
113
+ });
114
+ it('detects secret/token assignments', () => {
115
+ const result = detectSecrets('api_secret = "abcdef1234567890abcdef"');
116
+ expect(result.containsSecret).toBe(true);
117
+ });
118
+ it('detects API key patterns', () => {
119
+ const result = detectSecrets('My api_key is abc123def456ghi789jkl');
120
+ expect(result.containsSecret).toBe(true);
121
+ expect(result.warnings.some(w => w.includes('API key'))).toBe(true);
122
+ });
123
+ });
124
+ describe('Credit Card Numbers', () => {
125
+ it('detects Visa card number', () => {
126
+ const result = detectSecrets('Card: 4111111111111111');
127
+ expect(result.containsSecret).toBe(true);
128
+ expect(result.warnings.some(w => w.includes('Credit card'))).toBe(true);
129
+ });
130
+ it('detects Mastercard number', () => {
131
+ const result = detectSecrets('Payment with 5500000000000004');
132
+ expect(result.containsSecret).toBe(true);
133
+ });
134
+ it('detects American Express number', () => {
135
+ const result = detectSecrets('Amex: 378282246310005');
136
+ expect(result.containsSecret).toBe(true);
137
+ });
138
+ });
139
+ describe('Social Security Numbers', () => {
140
+ it('detects SSN format (XXX-XX-XXXX)', () => {
141
+ const result = detectSecrets('SSN: 123-45-6789');
142
+ expect(result.containsSecret).toBe(true);
143
+ expect(result.warnings.some(w => w.includes('Social Security'))).toBe(true);
144
+ });
145
+ });
146
+ describe('Safe Content (No False Positives)', () => {
147
+ it('does not flag normal text', () => {
148
+ const result = detectSecrets('We decided to use PostgreSQL for the database');
149
+ expect(result.containsSecret).toBe(false);
150
+ });
151
+ it('does not flag code patterns', () => {
152
+ const result = detectSecrets('function getUser(id: string) { return users[id]; }');
153
+ expect(result.containsSecret).toBe(false);
154
+ });
155
+ it('does not flag short strings', () => {
156
+ const result = detectSecrets('key = "abc"');
157
+ expect(result.containsSecret).toBe(false);
158
+ });
159
+ it('does not flag file paths', () => {
160
+ const result = detectSecrets('Located at /app/components/user_component.rb');
161
+ expect(result.containsSecret).toBe(false);
162
+ });
163
+ it('does not flag database connection without password', () => {
164
+ const result = detectSecrets('postgres://localhost:5432/mydb');
165
+ expect(result.containsSecret).toBe(false);
166
+ });
167
+ it('does not flag UUIDs', () => {
168
+ const result = detectSecrets('id: 550e8400-e29b-41d4-a716-446655440000');
169
+ expect(result.containsSecret).toBe(false);
170
+ });
171
+ });
172
+ });
173
+ describe('Security: checkFieldsForSecrets', () => {
174
+ // Build test key dynamically to avoid GitHub secret scanning
175
+ const stripeKey = 'sk_' + 'live_51ABCdefGHIjklMNOpqrSTUvwxyz';
176
+ it('checks multiple fields and reports which field contains secret', () => {
177
+ const result = checkFieldsForSecrets({
178
+ topic: 'authentication',
179
+ decision: `Use API key: ${stripeKey}`,
180
+ rationale: 'It works well',
181
+ });
182
+ expect(result.containsSecret).toBe(true);
183
+ expect(result.warnings.some(w => w.includes('decision'))).toBe(true);
184
+ });
185
+ it('returns no warnings for safe content', () => {
186
+ const result = checkFieldsForSecrets({
187
+ topic: 'database',
188
+ decision: 'Use PostgreSQL',
189
+ rationale: 'Better for our use case',
190
+ });
191
+ expect(result.containsSecret).toBe(false);
192
+ expect(result.warnings).toHaveLength(0);
193
+ });
194
+ it('handles undefined and null values', () => {
195
+ const result = checkFieldsForSecrets({
196
+ topic: 'test',
197
+ decision: undefined,
198
+ rationale: undefined,
199
+ });
200
+ expect(result.containsSecret).toBe(false);
201
+ });
202
+ });
203
+ describe('Security: formatSecretWarning', () => {
204
+ it('formats warning message with detected secrets', () => {
205
+ const result = detectSecrets('password: secret123456');
206
+ const message = formatSecretWarning(result);
207
+ expect(message).toContain('SECURITY WARNING');
208
+ expect(message).toContain('NOT saved');
209
+ expect(message).toContain('Password');
210
+ });
211
+ it('returns empty string for safe content', () => {
212
+ const result = detectSecrets('normal text here');
213
+ const message = formatSecretWarning(result);
214
+ expect(message).toBe('');
215
+ });
216
+ });