grov 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/LICENSE +190 -0
- package/README.md +211 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +106 -0
- package/dist/commands/capture.d.ts +6 -0
- package/dist/commands/capture.js +324 -0
- package/dist/commands/drift-test.d.ts +7 -0
- package/dist/commands/drift-test.js +177 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +27 -0
- package/dist/commands/inject.d.ts +5 -0
- package/dist/commands/inject.js +88 -0
- package/dist/commands/prompt-inject.d.ts +4 -0
- package/dist/commands/prompt-inject.js +451 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +51 -0
- package/dist/commands/unregister.d.ts +1 -0
- package/dist/commands/unregister.js +22 -0
- package/dist/lib/anchor-extractor.d.ts +30 -0
- package/dist/lib/anchor-extractor.js +296 -0
- package/dist/lib/correction-builder.d.ts +10 -0
- package/dist/lib/correction-builder.js +226 -0
- package/dist/lib/debug.d.ts +24 -0
- package/dist/lib/debug.js +34 -0
- package/dist/lib/drift-checker.d.ts +66 -0
- package/dist/lib/drift-checker.js +341 -0
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +258 -0
- package/dist/lib/jsonl-parser.d.ts +87 -0
- package/dist/lib/jsonl-parser.js +281 -0
- package/dist/lib/llm-extractor.d.ts +50 -0
- package/dist/lib/llm-extractor.js +408 -0
- package/dist/lib/session-parser.d.ts +44 -0
- package/dist/lib/session-parser.js +256 -0
- package/dist/lib/store.d.ts +248 -0
- package/dist/lib/store.js +793 -0
- package/dist/lib/utils.d.ts +31 -0
- package/dist/lib/utils.js +76 -0
- package/package.json +67 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// Drift detection logic for anti-drift system
|
|
2
|
+
// Uses Claude Haiku 4.5 for LLM-based drift scoring
|
|
3
|
+
//
|
|
4
|
+
// CRITICAL: We check Claude's ACTIONS, NOT user prompts.
|
|
5
|
+
// User can explore freely. We monitor what CLAUDE DOES.
|
|
6
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
7
|
+
import { isAnthropicAvailable, getDriftModel } from './llm-extractor.js';
|
|
8
|
+
import { getRelevantSteps, getRecentSteps } from './store.js';
|
|
9
|
+
import { extractFilesFromActions, extractFoldersFromActions } from './session-parser.js';
|
|
10
|
+
import { debugInject } from './debug.js';
|
|
11
|
+
// ============================================
|
|
12
|
+
// CONFIGURATION
|
|
13
|
+
// ============================================
|
|
14
|
+
export const DRIFT_CONFIG = {
|
|
15
|
+
SCORE_NO_INJECTION: 8, // >= 8: no correction
|
|
16
|
+
SCORE_NUDGE: 7, // 7: nudge
|
|
17
|
+
SCORE_CORRECT: 5, // 5-6: correct
|
|
18
|
+
SCORE_INTERVENE: 3, // 3-4: intervene
|
|
19
|
+
SCORE_HALT: 1, // 1-2: halt
|
|
20
|
+
MAX_WARNINGS_BEFORE_FLAG: 3,
|
|
21
|
+
AVG_SCORE_THRESHOLD: 6,
|
|
22
|
+
MAX_ESCALATION: 3,
|
|
23
|
+
};
|
|
24
|
+
// ============================================
|
|
25
|
+
// MAIN FUNCTIONS
|
|
26
|
+
// ============================================
|
|
27
|
+
/**
|
|
28
|
+
* Build input for drift check from Claude's ACTIONS and session state.
|
|
29
|
+
*
|
|
30
|
+
* CRITICAL: We check ACTIONS, not user prompts.
|
|
31
|
+
*/
|
|
32
|
+
export function buildDriftCheckInput(claudeActions, sessionId, sessionState) {
|
|
33
|
+
// Extract files/folders from current actions for retrieval
|
|
34
|
+
const currentFiles = extractFilesFromActions(claudeActions);
|
|
35
|
+
const currentFolders = extractFoldersFromActions(claudeActions);
|
|
36
|
+
return {
|
|
37
|
+
originalGoal: sessionState.original_goal || '',
|
|
38
|
+
expectedScope: sessionState.expected_scope,
|
|
39
|
+
constraints: sessionState.constraints,
|
|
40
|
+
keywords: sessionState.keywords,
|
|
41
|
+
driftHistory: sessionState.drift_history.map(h => ({
|
|
42
|
+
score: h.score,
|
|
43
|
+
level: h.level
|
|
44
|
+
})),
|
|
45
|
+
escalationCount: sessionState.escalation_count,
|
|
46
|
+
// Claude's ACTIONS
|
|
47
|
+
claudeActions,
|
|
48
|
+
// 4-query retrieval for context
|
|
49
|
+
retrievedSteps: getRelevantSteps(sessionId, currentFiles, currentFolders, sessionState.keywords, 10),
|
|
50
|
+
lastNSteps: getRecentSteps(sessionId, 5)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Check drift using LLM or fallback
|
|
55
|
+
*/
|
|
56
|
+
export async function checkDrift(input) {
|
|
57
|
+
// Try LLM if available
|
|
58
|
+
if (isAnthropicAvailable()) {
|
|
59
|
+
try {
|
|
60
|
+
return await checkDriftWithLLM(input);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
debugInject('checkDrift LLM failed, using fallback: %O', error);
|
|
64
|
+
return checkDriftBasic(input);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Fallback to basic detection
|
|
68
|
+
return checkDriftBasic(input);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* LLM-based drift detection using Claude Haiku 4.5.
|
|
72
|
+
*
|
|
73
|
+
* CRITICAL: Analyzes Claude's ACTIONS, not user prompts.
|
|
74
|
+
*/
|
|
75
|
+
async function checkDriftWithLLM(input) {
|
|
76
|
+
const anthropic = new Anthropic();
|
|
77
|
+
const model = getDriftModel();
|
|
78
|
+
// Format Claude's actions for the prompt
|
|
79
|
+
const actionsText = input.claudeActions.length > 0
|
|
80
|
+
? input.claudeActions.map(a => {
|
|
81
|
+
if (a.type === 'bash')
|
|
82
|
+
return `- ${a.type}: ${a.command?.substring(0, 100) || 'no command'}`;
|
|
83
|
+
return `- ${a.type}: ${a.files.join(', ') || 'no files'}`;
|
|
84
|
+
}).join('\n')
|
|
85
|
+
: 'No actions yet';
|
|
86
|
+
// Format recent steps for context
|
|
87
|
+
const recentStepsText = input.lastNSteps.length > 0
|
|
88
|
+
? input.lastNSteps.map(s => `- ${s.action_type}: ${s.files.slice(0, 2).join(', ')} (score: ${s.drift_score})`).join('\n')
|
|
89
|
+
: 'No previous steps';
|
|
90
|
+
const driftContext = input.driftHistory.length > 0
|
|
91
|
+
? `Previous drift events: ${input.driftHistory.map(h => `score=${h.score}`).join(', ')}`
|
|
92
|
+
: 'No previous drift events';
|
|
93
|
+
const response = await anthropic.messages.create({
|
|
94
|
+
model,
|
|
95
|
+
max_tokens: 1024,
|
|
96
|
+
messages: [
|
|
97
|
+
{
|
|
98
|
+
role: 'user',
|
|
99
|
+
content: `You are a drift detection system. Analyze if Claude's ACTIONS align with the original goal.
|
|
100
|
+
|
|
101
|
+
IMPORTANT: We monitor Claude's ACTIONS, not user prompts. Users can ask anything - we check what Claude DOES.
|
|
102
|
+
|
|
103
|
+
ORIGINAL GOAL:
|
|
104
|
+
${input.originalGoal}
|
|
105
|
+
|
|
106
|
+
EXPECTED SCOPE (files/components Claude should touch):
|
|
107
|
+
${input.expectedScope.length > 0 ? input.expectedScope.join(', ') : 'Not specified'}
|
|
108
|
+
|
|
109
|
+
CONSTRAINTS:
|
|
110
|
+
${input.constraints.length > 0 ? input.constraints.join(', ') : 'None specified'}
|
|
111
|
+
|
|
112
|
+
KEY TERMS:
|
|
113
|
+
${input.keywords.join(', ')}
|
|
114
|
+
|
|
115
|
+
${driftContext}
|
|
116
|
+
Current escalation level: ${input.escalationCount}
|
|
117
|
+
|
|
118
|
+
CLAUDE'S RECENT ACTIONS:
|
|
119
|
+
${actionsText}
|
|
120
|
+
|
|
121
|
+
PREVIOUS STEPS IN SESSION:
|
|
122
|
+
${recentStepsText}
|
|
123
|
+
|
|
124
|
+
CHECK FOR:
|
|
125
|
+
1. Files OUTSIDE expected scope (editing unrelated files)
|
|
126
|
+
2. Repetition patterns (same file edited 3+ times without progress)
|
|
127
|
+
3. Tangential work (styling when goal is auth)
|
|
128
|
+
4. New features not requested
|
|
129
|
+
5. "While I'm here" patterns (scope creep)
|
|
130
|
+
|
|
131
|
+
LEGITIMATE (NOT drift):
|
|
132
|
+
- Editing utility files imported by main files
|
|
133
|
+
- Fixing bugs discovered while working
|
|
134
|
+
- Updating tests for modified code
|
|
135
|
+
- Reading ANY file (exploration is OK)
|
|
136
|
+
|
|
137
|
+
Rate 1-10:
|
|
138
|
+
- 10: Actions directly advance the goal
|
|
139
|
+
- 8-9: Minor deviation but related (e.g., helper file)
|
|
140
|
+
- 5-7: Moderate drift, tangentially related
|
|
141
|
+
- 3-4: Significant drift, unrelated files
|
|
142
|
+
- 1-2: Critical drift, completely off-track
|
|
143
|
+
|
|
144
|
+
Return ONLY valid JSON:
|
|
145
|
+
{
|
|
146
|
+
"score": <1-10>,
|
|
147
|
+
"type": "aligned|minor|moderate|severe|critical",
|
|
148
|
+
"diagnostic": "Brief explanation of drift based on ACTIONS (1 sentence)",
|
|
149
|
+
"recovery_steps": [{"file": "optional/path", "action": "what to do"}],
|
|
150
|
+
"boundaries": ["Things that should NOT be done"],
|
|
151
|
+
"verification": "How to confirm we're back on track"
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
Return ONLY valid JSON.`
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
});
|
|
158
|
+
// Extract text content
|
|
159
|
+
const content = response.content[0];
|
|
160
|
+
if (content.type !== 'text') {
|
|
161
|
+
throw new Error('Unexpected response type');
|
|
162
|
+
}
|
|
163
|
+
// Strip markdown code blocks if present
|
|
164
|
+
let jsonText = content.text.trim();
|
|
165
|
+
if (jsonText.startsWith('```')) {
|
|
166
|
+
jsonText = jsonText.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
|
|
167
|
+
}
|
|
168
|
+
debugInject('LLM raw response: %s', jsonText.substring(0, 200));
|
|
169
|
+
const parsed = JSON.parse(jsonText);
|
|
170
|
+
// Handle score as string or number
|
|
171
|
+
const rawScore = typeof parsed.score === 'string' ? parseInt(parsed.score, 10) : parsed.score;
|
|
172
|
+
const score = Math.min(10, Math.max(1, rawScore || 5));
|
|
173
|
+
debugInject('LLM parsed score: raw=%s, final=%d', parsed.score, score);
|
|
174
|
+
return {
|
|
175
|
+
score,
|
|
176
|
+
type: mapScoreToType(score),
|
|
177
|
+
diagnostic: parsed.diagnostic || 'Unable to determine drift status',
|
|
178
|
+
recoveryPlan: parsed.recovery_steps ? { steps: parsed.recovery_steps } : undefined,
|
|
179
|
+
boundaries: parsed.boundaries || [],
|
|
180
|
+
verification: parsed.verification || 'Complete the original task'
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Basic drift detection without LLM.
|
|
185
|
+
*
|
|
186
|
+
* CRITICAL: Checks Claude's ACTIONS, not user prompts.
|
|
187
|
+
* - Read actions are ALWAYS OK (exploration is not drift)
|
|
188
|
+
* - Edit/Write actions outside scope = drift
|
|
189
|
+
* - Repetition patterns = drift
|
|
190
|
+
*/
|
|
191
|
+
export function checkDriftBasic(input) {
|
|
192
|
+
const issues = [];
|
|
193
|
+
// Filter to modifying actions only (read is always OK)
|
|
194
|
+
const modifyingActions = input.claudeActions.filter(a => a.type !== 'read' && a.type !== 'grep' && a.type !== 'glob');
|
|
195
|
+
// No modifying actions = no drift possible
|
|
196
|
+
if (modifyingActions.length === 0) {
|
|
197
|
+
return {
|
|
198
|
+
score: 10,
|
|
199
|
+
type: 'aligned',
|
|
200
|
+
diagnostic: 'No modifying actions - exploration only',
|
|
201
|
+
boundaries: [],
|
|
202
|
+
verification: `Continue with: ${input.originalGoal.substring(0, 50)}`
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// Count files in-scope vs out-of-scope
|
|
206
|
+
let inScopeCount = 0;
|
|
207
|
+
let outOfScopeCount = 0;
|
|
208
|
+
const allFiles = [];
|
|
209
|
+
for (const action of modifyingActions) {
|
|
210
|
+
for (const file of action.files) {
|
|
211
|
+
if (!file)
|
|
212
|
+
continue;
|
|
213
|
+
allFiles.push(file);
|
|
214
|
+
// If no scope defined, assume all files are OK
|
|
215
|
+
if (input.expectedScope.length === 0) {
|
|
216
|
+
inScopeCount++;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// Check if file is in scope
|
|
220
|
+
const inScope = input.expectedScope.some(scope => {
|
|
221
|
+
// file contains scope pattern (e.g., "src/auth/token.ts" contains "src/auth/")
|
|
222
|
+
if (file.includes(scope))
|
|
223
|
+
return true;
|
|
224
|
+
// scope contains the file name (e.g., "src/lib/token.ts" contains "token.ts")
|
|
225
|
+
const fileName = file.split('/').pop() || '';
|
|
226
|
+
if (scope.includes(fileName) && fileName.length > 0)
|
|
227
|
+
return true;
|
|
228
|
+
return false;
|
|
229
|
+
});
|
|
230
|
+
if (inScope) {
|
|
231
|
+
inScopeCount++;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
outOfScopeCount++;
|
|
235
|
+
issues.push(`File outside scope: ${file.split('/').pop()}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Calculate score based on in-scope ratio
|
|
240
|
+
let score;
|
|
241
|
+
const totalFiles = inScopeCount + outOfScopeCount;
|
|
242
|
+
if (totalFiles === 0) {
|
|
243
|
+
score = 10;
|
|
244
|
+
}
|
|
245
|
+
else if (outOfScopeCount === 0) {
|
|
246
|
+
// All files in scope = perfect
|
|
247
|
+
score = 10;
|
|
248
|
+
}
|
|
249
|
+
else if (inScopeCount === 0) {
|
|
250
|
+
// All files out of scope = critical drift
|
|
251
|
+
score = Math.max(1, 4 - outOfScopeCount); // 1-4 depending on how many
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
// Mixed: some in, some out
|
|
255
|
+
const ratio = inScopeCount / totalFiles;
|
|
256
|
+
// ratio 1.0 = 10, ratio 0.5 = 6, ratio 0.0 = 2
|
|
257
|
+
score = Math.round(2 + ratio * 8);
|
|
258
|
+
// Additional penalty for each out-of-scope file
|
|
259
|
+
score = Math.max(1, score - outOfScopeCount);
|
|
260
|
+
}
|
|
261
|
+
// Check for repetition patterns (same file edited 3+ times)
|
|
262
|
+
// Use unique files to avoid double-counting
|
|
263
|
+
const recentFiles = input.lastNSteps.flatMap(s => s.files);
|
|
264
|
+
const uniqueCurrentFiles = [...new Set(allFiles)];
|
|
265
|
+
for (const file of uniqueCurrentFiles) {
|
|
266
|
+
const timesEdited = recentFiles.filter(f => f === file).length;
|
|
267
|
+
if (timesEdited >= 3) {
|
|
268
|
+
score = Math.max(1, score - 2);
|
|
269
|
+
issues.push(`Repetition: ${file.split('/').pop()} edited ${timesEdited}+ times`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Clamp score
|
|
273
|
+
score = Math.max(1, Math.min(10, score));
|
|
274
|
+
// Determine diagnostic
|
|
275
|
+
let diagnostic;
|
|
276
|
+
if (score >= 8) {
|
|
277
|
+
diagnostic = 'Actions align with original goal';
|
|
278
|
+
}
|
|
279
|
+
else if (score >= 5) {
|
|
280
|
+
diagnostic = issues.length > 0 ? issues[0] : 'Actions partially relate to goal';
|
|
281
|
+
}
|
|
282
|
+
else if (score >= 3) {
|
|
283
|
+
diagnostic = issues.length > 0 ? issues.join('; ') : 'Actions deviate from goal';
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
diagnostic = issues.length > 0 ? issues.join('; ') : 'Actions do not relate to original goal';
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
score,
|
|
290
|
+
type: mapScoreToType(score),
|
|
291
|
+
diagnostic,
|
|
292
|
+
recoveryPlan: score < 5 ? { steps: [{ action: `Return to: ${input.originalGoal.substring(0, 50)}` }] } : undefined,
|
|
293
|
+
boundaries: [],
|
|
294
|
+
verification: `Continue with: ${input.originalGoal.substring(0, 50)}`
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Infer action type from prompt
|
|
299
|
+
*/
|
|
300
|
+
export function inferAction(prompt) {
|
|
301
|
+
const lower = prompt.toLowerCase();
|
|
302
|
+
if (lower.includes('fix') || lower.includes('bug') || lower.includes('error')) {
|
|
303
|
+
return 'fix';
|
|
304
|
+
}
|
|
305
|
+
if (lower.includes('add') || lower.includes('create') || lower.includes('implement')) {
|
|
306
|
+
return 'add';
|
|
307
|
+
}
|
|
308
|
+
if (lower.includes('refactor') || lower.includes('improve') || lower.includes('clean')) {
|
|
309
|
+
return 'refactor';
|
|
310
|
+
}
|
|
311
|
+
if (lower.includes('test') || lower.includes('spec')) {
|
|
312
|
+
return 'test';
|
|
313
|
+
}
|
|
314
|
+
if (lower.includes('doc') || lower.includes('comment') || lower.includes('readme')) {
|
|
315
|
+
return 'document';
|
|
316
|
+
}
|
|
317
|
+
if (lower.includes('update') || lower.includes('change') || lower.includes('modify')) {
|
|
318
|
+
return 'update';
|
|
319
|
+
}
|
|
320
|
+
if (lower.includes('remove') || lower.includes('delete')) {
|
|
321
|
+
return 'remove';
|
|
322
|
+
}
|
|
323
|
+
return 'unknown';
|
|
324
|
+
}
|
|
325
|
+
// ============================================
|
|
326
|
+
// HELPERS
|
|
327
|
+
// ============================================
|
|
328
|
+
/**
|
|
329
|
+
* Map numeric score to drift type
|
|
330
|
+
*/
|
|
331
|
+
function mapScoreToType(score) {
|
|
332
|
+
if (score >= 8)
|
|
333
|
+
return 'aligned';
|
|
334
|
+
if (score >= 6)
|
|
335
|
+
return 'minor';
|
|
336
|
+
if (score >= 4)
|
|
337
|
+
return 'moderate';
|
|
338
|
+
if (score >= 2)
|
|
339
|
+
return 'severe';
|
|
340
|
+
return 'critical';
|
|
341
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
interface HookCommand {
|
|
2
|
+
type: 'command';
|
|
3
|
+
command: string;
|
|
4
|
+
}
|
|
5
|
+
interface HookEntry {
|
|
6
|
+
matcher?: Record<string, unknown>;
|
|
7
|
+
hooks: HookCommand[];
|
|
8
|
+
}
|
|
9
|
+
interface ClaudeSettings {
|
|
10
|
+
hooks?: {
|
|
11
|
+
Stop?: HookEntry[];
|
|
12
|
+
SessionStart?: HookEntry[];
|
|
13
|
+
[key: string]: HookEntry[] | undefined;
|
|
14
|
+
};
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
export declare function readClaudeSettings(): ClaudeSettings;
|
|
18
|
+
export declare function writeClaudeSettings(settings: ClaudeSettings): void;
|
|
19
|
+
export declare function registerGrovHooks(): {
|
|
20
|
+
added: string[];
|
|
21
|
+
alreadyExists: string[];
|
|
22
|
+
};
|
|
23
|
+
export declare function unregisterGrovHooks(): {
|
|
24
|
+
removed: string[];
|
|
25
|
+
};
|
|
26
|
+
export declare function getSettingsPath(): string;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Helper to read/write ~/.claude/settings.json
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join, resolve, dirname } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
const CLAUDE_DIR = join(homedir(), '.claude');
|
|
7
|
+
const SETTINGS_PATH = join(CLAUDE_DIR, 'settings.json');
|
|
8
|
+
// Cache for grov path to avoid repeated file system checks
|
|
9
|
+
let cachedGrovPath = null;
|
|
10
|
+
// SECURITY: Pattern for safe grov executable paths (no shell metacharacters)
|
|
11
|
+
// Allows: alphanumeric, /, -, _, ., space, and quotes (for paths with spaces)
|
|
12
|
+
const SAFE_PATH_PATTERN = /^[a-zA-Z0-9/\-_. "]+$/;
|
|
13
|
+
/**
|
|
14
|
+
* Validate that a path doesn't contain shell metacharacters.
|
|
15
|
+
* SECURITY: Prevents command injection via malicious path names.
|
|
16
|
+
*/
|
|
17
|
+
function isPathSafe(path) {
|
|
18
|
+
// Must match safe pattern
|
|
19
|
+
if (!SAFE_PATH_PATTERN.test(path)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
// Must not contain shell dangerous sequences
|
|
23
|
+
const dangerousPatterns = [';', '|', '&', '`', '$', '(', ')', '<', '>', '\n', '\r'];
|
|
24
|
+
return !dangerousPatterns.some(p => path.includes(p));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Safe locations where grov executable can be found.
|
|
28
|
+
* We only check these known paths - no shell commands are executed.
|
|
29
|
+
*/
|
|
30
|
+
const SAFE_GROV_LOCATIONS = [
|
|
31
|
+
'/opt/homebrew/bin/grov', // macOS ARM (Homebrew)
|
|
32
|
+
'/usr/local/bin/grov', // macOS Intel / Linux
|
|
33
|
+
'/usr/bin/grov', // System-wide Linux
|
|
34
|
+
join(homedir(), '.npm-global/bin/grov'), // Custom npm prefix
|
|
35
|
+
join(homedir(), '.local/bin/grov'), // Local user bin
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Get nvm-based paths for current and common Node versions.
|
|
39
|
+
* Returns paths without executing any shell commands.
|
|
40
|
+
*/
|
|
41
|
+
function getNvmPaths() {
|
|
42
|
+
const nvmDir = join(homedir(), '.nvm/versions/node');
|
|
43
|
+
const paths = [];
|
|
44
|
+
// Add current Node version path
|
|
45
|
+
paths.push(join(nvmDir, process.version, 'bin/grov'));
|
|
46
|
+
// Try common LTS versions
|
|
47
|
+
const ltsVersions = ['v18', 'v20', 'v22'];
|
|
48
|
+
for (const ver of ltsVersions) {
|
|
49
|
+
try {
|
|
50
|
+
const versionDir = join(nvmDir, ver);
|
|
51
|
+
if (existsSync(versionDir)) {
|
|
52
|
+
// Find actual version directories
|
|
53
|
+
const entries = readdirSync(versionDir);
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
paths.push(join(nvmDir, ver, entry, 'bin/grov'));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Skip if can't read directory
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return paths;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Find the absolute path to the grov executable.
|
|
67
|
+
* SECURITY: Only checks known safe locations - no shell command execution.
|
|
68
|
+
* SECURITY: Validates paths don't contain shell metacharacters.
|
|
69
|
+
* OPTIMIZED: Caches result to avoid repeated file system checks.
|
|
70
|
+
*/
|
|
71
|
+
function findGrovPath() {
|
|
72
|
+
// Return cached path if available
|
|
73
|
+
if (cachedGrovPath) {
|
|
74
|
+
return cachedGrovPath;
|
|
75
|
+
}
|
|
76
|
+
// Check safe locations first
|
|
77
|
+
for (const p of SAFE_GROV_LOCATIONS) {
|
|
78
|
+
// SECURITY: Validate path is safe before using
|
|
79
|
+
if (existsSync(p) && isPathSafe(p)) {
|
|
80
|
+
cachedGrovPath = p;
|
|
81
|
+
return p;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Check nvm locations
|
|
85
|
+
for (const p of getNvmPaths()) {
|
|
86
|
+
// SECURITY: Validate path is safe before using
|
|
87
|
+
if (existsSync(p) && isPathSafe(p)) {
|
|
88
|
+
cachedGrovPath = p;
|
|
89
|
+
return p;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Check if running from source (development mode)
|
|
93
|
+
try {
|
|
94
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
95
|
+
const __dirname = dirname(__filename);
|
|
96
|
+
const localCli = resolve(__dirname, '../../dist/cli.js');
|
|
97
|
+
// SECURITY: Validate development path is safe before using
|
|
98
|
+
if (existsSync(localCli) && isPathSafe(localCli)) {
|
|
99
|
+
cachedGrovPath = `node "${localCli}"`;
|
|
100
|
+
return cachedGrovPath;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// ESM import.meta not available, skip
|
|
105
|
+
}
|
|
106
|
+
// Fallback to just 'grov' - will work if it's in PATH
|
|
107
|
+
// Note: 'grov' is inherently safe (no special chars)
|
|
108
|
+
cachedGrovPath = 'grov';
|
|
109
|
+
return cachedGrovPath;
|
|
110
|
+
}
|
|
111
|
+
export function readClaudeSettings() {
|
|
112
|
+
if (!existsSync(SETTINGS_PATH)) {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const content = readFileSync(SETTINGS_PATH, 'utf-8');
|
|
117
|
+
return JSON.parse(content);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
console.error('Warning: Could not parse ~/.claude/settings.json');
|
|
121
|
+
return {};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function writeClaudeSettings(settings) {
|
|
125
|
+
// Ensure .claude directory exists with restricted permissions
|
|
126
|
+
if (!existsSync(CLAUDE_DIR)) {
|
|
127
|
+
mkdirSync(CLAUDE_DIR, { recursive: true, mode: 0o700 });
|
|
128
|
+
}
|
|
129
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
130
|
+
}
|
|
131
|
+
export function registerGrovHooks() {
|
|
132
|
+
const settings = readClaudeSettings();
|
|
133
|
+
const added = [];
|
|
134
|
+
const alreadyExists = [];
|
|
135
|
+
// Get absolute path to grov executable
|
|
136
|
+
const grovPath = findGrovPath();
|
|
137
|
+
// Initialize hooks object if it doesn't exist
|
|
138
|
+
if (!settings.hooks) {
|
|
139
|
+
settings.hooks = {};
|
|
140
|
+
}
|
|
141
|
+
// Helper to check if a grov command already exists (check for both relative and absolute paths)
|
|
142
|
+
const hasGrovCommand = (entries, commandSuffix) => {
|
|
143
|
+
if (!entries)
|
|
144
|
+
return false;
|
|
145
|
+
return entries.some(entry => entry.hooks.some(h => h.type === 'command' && h.command.endsWith(commandSuffix)));
|
|
146
|
+
};
|
|
147
|
+
// Register Stop hook for capture
|
|
148
|
+
// Note: Stop/SessionStart hooks don't use matcher (only tool-specific hooks do)
|
|
149
|
+
const stopCommand = `${grovPath} capture`;
|
|
150
|
+
if (!settings.hooks.Stop) {
|
|
151
|
+
settings.hooks.Stop = [];
|
|
152
|
+
}
|
|
153
|
+
if (!hasGrovCommand(settings.hooks.Stop, 'grov capture') && !hasGrovCommand(settings.hooks.Stop, stopCommand)) {
|
|
154
|
+
settings.hooks.Stop.push({
|
|
155
|
+
hooks: [{ type: 'command', command: stopCommand }]
|
|
156
|
+
});
|
|
157
|
+
added.push(`Stop → ${stopCommand}`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
alreadyExists.push('Stop → grov capture');
|
|
161
|
+
}
|
|
162
|
+
// Register SessionStart hook for inject
|
|
163
|
+
const sessionStartCommand = `${grovPath} inject`;
|
|
164
|
+
if (!settings.hooks.SessionStart) {
|
|
165
|
+
settings.hooks.SessionStart = [];
|
|
166
|
+
}
|
|
167
|
+
if (!hasGrovCommand(settings.hooks.SessionStart, 'grov inject') && !hasGrovCommand(settings.hooks.SessionStart, sessionStartCommand)) {
|
|
168
|
+
settings.hooks.SessionStart.push({
|
|
169
|
+
hooks: [{ type: 'command', command: sessionStartCommand }]
|
|
170
|
+
});
|
|
171
|
+
added.push(`SessionStart → ${sessionStartCommand}`);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
alreadyExists.push('SessionStart → grov inject');
|
|
175
|
+
}
|
|
176
|
+
// Register UserPromptSubmit hook for continuous context injection
|
|
177
|
+
const promptInjectCommand = `${grovPath} prompt-inject`;
|
|
178
|
+
if (!settings.hooks.UserPromptSubmit) {
|
|
179
|
+
settings.hooks.UserPromptSubmit = [];
|
|
180
|
+
}
|
|
181
|
+
if (!hasGrovCommand(settings.hooks.UserPromptSubmit, 'grov prompt-inject') && !hasGrovCommand(settings.hooks.UserPromptSubmit, promptInjectCommand)) {
|
|
182
|
+
settings.hooks.UserPromptSubmit.push({
|
|
183
|
+
hooks: [{ type: 'command', command: promptInjectCommand }]
|
|
184
|
+
});
|
|
185
|
+
added.push(`UserPromptSubmit → ${promptInjectCommand}`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
alreadyExists.push('UserPromptSubmit → grov prompt-inject');
|
|
189
|
+
}
|
|
190
|
+
writeClaudeSettings(settings);
|
|
191
|
+
return { added, alreadyExists };
|
|
192
|
+
}
|
|
193
|
+
export function unregisterGrovHooks() {
|
|
194
|
+
const settings = readClaudeSettings();
|
|
195
|
+
const removed = [];
|
|
196
|
+
// Helper to find and remove grov command entries (handles both relative and absolute paths)
|
|
197
|
+
const removeGrovCommands = (entries, commandSuffix) => {
|
|
198
|
+
if (!entries)
|
|
199
|
+
return undefined;
|
|
200
|
+
// Filter out entries that contain any grov command ending with the suffix
|
|
201
|
+
const filtered = entries.filter(entry => {
|
|
202
|
+
const hasCommand = entry.hooks.some(h => h.type === 'command' && h.command.endsWith(commandSuffix));
|
|
203
|
+
return !hasCommand;
|
|
204
|
+
});
|
|
205
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
206
|
+
};
|
|
207
|
+
// Also handle old string format for cleanup
|
|
208
|
+
const removeOldFormat = (entries, commandSuffix) => {
|
|
209
|
+
return entries.filter(entry => {
|
|
210
|
+
if (typeof entry === 'string') {
|
|
211
|
+
return !entry.endsWith(commandSuffix);
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
if (settings.hooks?.Stop) {
|
|
217
|
+
const originalLength = settings.hooks.Stop.length;
|
|
218
|
+
// Remove new format (handles both 'grov capture' and '/path/to/grov capture')
|
|
219
|
+
const newFormatFiltered = removeGrovCommands(settings.hooks.Stop, 'grov capture');
|
|
220
|
+
// Also clean up old string format if present
|
|
221
|
+
const cleaned = removeOldFormat(newFormatFiltered || [], 'grov capture');
|
|
222
|
+
if (cleaned.length < originalLength) {
|
|
223
|
+
removed.push('Stop → grov capture');
|
|
224
|
+
}
|
|
225
|
+
settings.hooks.Stop = cleaned.length > 0 ? cleaned : undefined;
|
|
226
|
+
}
|
|
227
|
+
if (settings.hooks?.SessionStart) {
|
|
228
|
+
const originalLength = settings.hooks.SessionStart.length;
|
|
229
|
+
// Remove new format (handles both 'grov inject' and '/path/to/grov inject')
|
|
230
|
+
const newFormatFiltered = removeGrovCommands(settings.hooks.SessionStart, 'grov inject');
|
|
231
|
+
// Also clean up old string format if present
|
|
232
|
+
const cleaned = removeOldFormat(newFormatFiltered || [], 'grov inject');
|
|
233
|
+
if (cleaned.length < originalLength) {
|
|
234
|
+
removed.push('SessionStart → grov inject');
|
|
235
|
+
}
|
|
236
|
+
settings.hooks.SessionStart = cleaned.length > 0 ? cleaned : undefined;
|
|
237
|
+
}
|
|
238
|
+
if (settings.hooks?.UserPromptSubmit) {
|
|
239
|
+
const originalLength = settings.hooks.UserPromptSubmit.length;
|
|
240
|
+
// Remove new format (handles both 'grov prompt-inject' and '/path/to/grov prompt-inject')
|
|
241
|
+
const newFormatFiltered = removeGrovCommands(settings.hooks.UserPromptSubmit, 'grov prompt-inject');
|
|
242
|
+
// Also clean up old string format if present
|
|
243
|
+
const cleaned = removeOldFormat(newFormatFiltered || [], 'grov prompt-inject');
|
|
244
|
+
if (cleaned.length < originalLength) {
|
|
245
|
+
removed.push('UserPromptSubmit → grov prompt-inject');
|
|
246
|
+
}
|
|
247
|
+
settings.hooks.UserPromptSubmit = cleaned.length > 0 ? cleaned : undefined;
|
|
248
|
+
}
|
|
249
|
+
// Clean up empty hooks object
|
|
250
|
+
if (settings.hooks && Object.keys(settings.hooks).every(k => !settings.hooks[k])) {
|
|
251
|
+
delete settings.hooks;
|
|
252
|
+
}
|
|
253
|
+
writeClaudeSettings(settings);
|
|
254
|
+
return { removed };
|
|
255
|
+
}
|
|
256
|
+
export function getSettingsPath() {
|
|
257
|
+
return SETTINGS_PATH;
|
|
258
|
+
}
|