@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 +238 -0
- package/dist/__tests__/database.test.d.ts +1 -0
- package/dist/__tests__/database.test.js +210 -0
- package/dist/__tests__/security.test.d.ts +1 -0
- package/dist/__tests__/security.test.js +216 -0
- package/dist/database.d.ts +106 -0
- package/dist/database.js +344 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +524 -0
- package/dist/security.d.ts +20 -0
- package/dist/security.js +79 -0
- package/package.json +45 -0
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
|
+
});
|