@naarang/ccc 1.0.5

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.
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "hooks",
3
+ "version": "1.0.0",
4
+ "main": "permissions_hook.js",
5
+ "scripts": {
6
+ "test": "echo \"Error: no test specified\" && exit 1"
7
+ },
8
+ "keywords": [],
9
+ "author": "",
10
+ "license": "ISC",
11
+ "description": "",
12
+ "dependencies": {
13
+ "mqtt": "^5.14.1",
14
+ "dotenv": "^17.2.3"
15
+ }
16
+ }
@@ -0,0 +1,581 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PreToolUse Hook for Claude Code
5
+ *
6
+ * This hook intercepts tool calls and requests user permission via MQTT
7
+ * before allowing Claude to execute them.
8
+ *
9
+ * Communication Flow:
10
+ * 1. Receives tool call details from Claude via stdin
11
+ * 2. Reads session-to-project mapping from sessions.json
12
+ * 3. Publishes permission request to MQTT (permissions/{projectId}/request)
13
+ * 4. Waits for user response from frontend (permissions/{projectId}/response)
14
+ * 5. Returns decision to Claude via stdout
15
+ */
16
+
17
+ const mqtt = require('mqtt');
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ // Load environment variables from .env file in hooks folder (silent mode)
22
+ require('dotenv').config({ path: path.join(__dirname, '.env'), quiet: true });
23
+
24
+ // ============================================================================
25
+ // Configuration
26
+ // ============================================================================
27
+
28
+ const MQTT_CONFIG = {
29
+ host: process.env.MQTT_HOST || 'localhost',
30
+ port: parseInt(process.env.MQTT_PORT || '8883', 10),
31
+ username: process.env.MQTT_USERNAME || undefined,
32
+ password: process.env.MQTT_PASSWORD || undefined,
33
+ clientId: `claude-hook-${Math.random().toString(16).substring(2, 10)}`,
34
+ qos: parseInt(process.env.MQTT_QOS || '1', 10),
35
+ };
36
+
37
+ const TIMEOUT_MS = 300000; // 5 minutes default timeout
38
+ // Sessions.json is in .claude folder (same folder as hooks folder)
39
+ // Hook runs from $CLAUDE_PROJECT_DIR/.claude/hooks, so go up 1 level to .claude
40
+ const SESSION_MAPPING_FILE = path.join(__dirname, '..', 'sessions.json');
41
+ const SESSION_CONFIG_FILE = path.join(__dirname, '..', 'session-config.json');
42
+
43
+ // Tools that modify code/files - only these should trigger acceptEdits mode
44
+ const CODE_MODIFYING_TOOLS = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit'];
45
+
46
+ // ============================================================================
47
+ // Helper Functions
48
+ // ============================================================================
49
+
50
+ /**
51
+ * Logs to stderr (so it doesn't interfere with stdout JSON output)
52
+ */
53
+ function log(message, data = null) {
54
+ const timestamp = new Date().toISOString();
55
+ const logMsg = data
56
+ ? `[${timestamp}] [PermissionsHook] ${message}: ${JSON.stringify(data)}`
57
+ : `[${timestamp}] [PermissionsHook] ${message}`;
58
+ console.error(logMsg);
59
+ }
60
+
61
+ /**
62
+ * Reads session-to-project mapping from sessions.json
63
+ * Returns projectId for given sessionId
64
+ */
65
+ function getProjectIdFromSession(sessionId) {
66
+ try {
67
+ if (!fs.existsSync(SESSION_MAPPING_FILE)) {
68
+ log('Session mapping file not found', { path: SESSION_MAPPING_FILE });
69
+ return null;
70
+ }
71
+
72
+ const data = fs.readFileSync(SESSION_MAPPING_FILE, 'utf8');
73
+ const mapping = JSON.parse(data);
74
+
75
+ if (mapping[sessionId]) {
76
+ log('Found project ID for session', { sessionId, projectId: mapping[sessionId] });
77
+ return mapping[sessionId];
78
+ }
79
+
80
+ log('Session ID not found in mapping', { sessionId });
81
+ return null;
82
+
83
+ } catch (error) {
84
+ log('Error reading session mapping', { error: error.message });
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Extracts project ID from the current working directory as fallback
91
+ * Uses the directory name as project identifier
92
+ */
93
+ function extractProjectIdFromCwd(cwd) {
94
+ return path.basename(cwd);
95
+ }
96
+
97
+ /**
98
+ * Reads permission mode for given session from session-config.json
99
+ * Returns permission mode ('default', 'acceptEdits', 'plan', 'bypassPermissions')
100
+ */
101
+ function getPermissionModeForSession(sessionId) {
102
+ try {
103
+ if (!fs.existsSync(SESSION_CONFIG_FILE)) {
104
+ log('Session config file not found, using default mode', { path: SESSION_CONFIG_FILE });
105
+ return 'default';
106
+ }
107
+
108
+ const data = fs.readFileSync(SESSION_CONFIG_FILE, 'utf8');
109
+ const config = JSON.parse(data);
110
+
111
+ if (config[sessionId] && config[sessionId].permissionMode) {
112
+ const mode = config[sessionId].permissionMode;
113
+ log('Found permission mode for session', { sessionId, permissionMode: mode });
114
+ return mode;
115
+ }
116
+
117
+ log('Session ID not found in config, using default mode', { sessionId });
118
+ return 'default';
119
+
120
+ } catch (error) {
121
+ log('Error reading session config, using default mode', { error: error.message });
122
+ return 'default';
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Reads JSON input from stdin
128
+ */
129
+ function readStdin() {
130
+ return new Promise((resolve, reject) => {
131
+ let data = '';
132
+
133
+ process.stdin.setEncoding('utf8');
134
+
135
+ process.stdin.on('data', (chunk) => {
136
+ data += chunk;
137
+ });
138
+
139
+ process.stdin.on('end', () => {
140
+ try {
141
+ const parsed = JSON.parse(data);
142
+ resolve(parsed);
143
+ } catch (error) {
144
+ reject(new Error(`Failed to parse stdin JSON: ${error.message}`));
145
+ }
146
+ });
147
+
148
+ process.stdin.on('error', (error) => {
149
+ reject(new Error(`Error reading stdin: ${error.message}`));
150
+ });
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Outputs the decision to stdout in the format Claude expects
156
+ */
157
+ function outputDecision(decision, reason = '') {
158
+ const output = {
159
+ hookSpecificOutput: {
160
+ hookEventName: 'PreToolUse',
161
+ permissionDecision: decision,
162
+ permissionDecisionReason: reason
163
+ }
164
+ };
165
+
166
+ console.log(JSON.stringify(output));
167
+ }
168
+
169
+ /**
170
+ * Generates a permission string for Claude Code's settings.local.json format
171
+ * Format: "ToolName(parameter)" - e.g., "Bash(npm install)", "Edit(/path/to/file.ts)"
172
+ */
173
+ function generatePermissionString(toolName, toolInput) {
174
+ try {
175
+ if (!toolInput) {
176
+ return toolName;
177
+ }
178
+
179
+ switch (toolName) {
180
+ case 'Bash':
181
+ return toolInput.command ? `Bash(${toolInput.command})` : 'Bash';
182
+ case 'Edit':
183
+ return toolInput.file_path ? `Edit(${toolInput.file_path})` : 'Edit';
184
+ case 'Write':
185
+ return toolInput.file_path ? `Write(${toolInput.file_path})` : 'Write';
186
+ case 'Read':
187
+ return toolInput.file_path ? `Read(${toolInput.file_path})` : 'Read';
188
+ case 'WebFetch':
189
+ return toolInput.url ? `WebFetch(${toolInput.url})` : 'WebFetch';
190
+ case 'WebSearch':
191
+ return toolInput.query ? `WebSearch(${toolInput.query})` : 'WebSearch';
192
+ case 'NotebookEdit':
193
+ return toolInput.notebook_path ? `NotebookEdit(${toolInput.notebook_path})` : 'NotebookEdit';
194
+ case 'BashOutput':
195
+ return 'BashOutput';
196
+ default:
197
+ return toolName;
198
+ }
199
+ } catch (error) {
200
+ log('Error generating permission string', { error: error.message });
201
+ return toolName;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Checks if a tool+command is in the allowed list (settings.local.json)
207
+ * Returns true if permission is granted via allowed list
208
+ */
209
+ function checkAllowedTools(toolName, toolInput) {
210
+ try {
211
+ const settingsPath = path.join(__dirname, '..', 'settings.local.json');
212
+
213
+ if (!fs.existsSync(settingsPath)) {
214
+ return false;
215
+ }
216
+
217
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
218
+
219
+ if (!settings.permissions || !settings.permissions.allow) {
220
+ return false;
221
+ }
222
+
223
+ // Generate the same permission string format used when adding
224
+ const permissionString = generatePermissionString(toolName, toolInput);
225
+
226
+ // Check if this exact permission exists
227
+ const isAllowed = settings.permissions.allow.includes(permissionString);
228
+
229
+ if (isAllowed) {
230
+ log('Tool+command found in allowed list', { permissionString });
231
+ return true;
232
+ }
233
+
234
+ return false;
235
+ } catch (error) {
236
+ log('Error checking allowed tools', { error: error.message });
237
+ return false;
238
+ }
239
+ }
240
+
241
+ // ============================================================================
242
+ // MQTT Communication
243
+ // ============================================================================
244
+
245
+ /**
246
+ * Connects to MQTT broker and waits for permission response
247
+ */
248
+ async function requestPermission(hookInput, projectId) {
249
+ const { tool_name, tool_input, session_id, cwd } = hookInput;
250
+
251
+ log('Processing permission request', { tool_name, projectId, session_id });
252
+
253
+ return new Promise((resolve, reject) => {
254
+ // Use WebSocket protocol for MQTT connection (embedded broker uses ws://)
255
+ const connectUrl = `ws://${MQTT_CONFIG.host}:${MQTT_CONFIG.port}`;
256
+
257
+ const connectOptions = {
258
+ clientId: MQTT_CONFIG.clientId,
259
+ clean: true,
260
+ reconnectPeriod: 0, // Don't auto-reconnect (we need fast failure)
261
+ connectTimeout: 10000,
262
+ };
263
+
264
+ // Add credentials if provided
265
+ if (MQTT_CONFIG.username && MQTT_CONFIG.password) {
266
+ connectOptions.username = MQTT_CONFIG.username;
267
+ connectOptions.password = MQTT_CONFIG.password;
268
+ }
269
+
270
+ log('Connecting to MQTT broker', { host: MQTT_CONFIG.host, port: MQTT_CONFIG.port });
271
+
272
+ const client = mqtt.connect(connectUrl, connectOptions);
273
+ let resolved = false;
274
+ let timeoutHandle = null;
275
+
276
+ // Topics for communication
277
+ const requestTopic = `permissions/${projectId}/request`;
278
+ const responseTopic = `permissions/${projectId}/response`;
279
+
280
+ /**
281
+ * Cleanup and resolve/reject helper
282
+ */
283
+ function cleanup(callback) {
284
+ if (resolved) return;
285
+ resolved = true;
286
+
287
+ if (timeoutHandle) {
288
+ clearTimeout(timeoutHandle);
289
+ }
290
+
291
+ // Clean disconnect
292
+ client.end(true, {}, () => {
293
+ callback();
294
+ });
295
+ }
296
+
297
+ // Set timeout for user response
298
+ timeoutHandle = setTimeout(() => {
299
+ log('Permission request timed out', { timeout: TIMEOUT_MS });
300
+ cleanup(() => {
301
+ resolve({
302
+ decision: 'deny',
303
+ reason: `Permission request timed out after ${TIMEOUT_MS / 1000} seconds. No response from user.`
304
+ });
305
+ });
306
+ }, TIMEOUT_MS);
307
+
308
+ // Handle connection
309
+ client.on('connect', () => {
310
+ log('Connected to MQTT broker');
311
+
312
+ // Subscribe to response topic
313
+ client.subscribe(responseTopic, { qos: MQTT_CONFIG.qos }, (err) => {
314
+ if (err) {
315
+ log('Failed to subscribe to response topic', { error: err.message });
316
+ cleanup(() => {
317
+ reject(new Error(`Failed to subscribe: ${err.message}`));
318
+ });
319
+ return;
320
+ }
321
+
322
+ log('Subscribed to response topic', { topic: responseTopic });
323
+
324
+ // Publish permission request
325
+ const permissionRequest = {
326
+ tool_name,
327
+ tool_input,
328
+ session_id,
329
+ cwd,
330
+ timestamp: Date.now(),
331
+ project_id: projectId,
332
+ };
333
+
334
+ const payload = JSON.stringify(permissionRequest);
335
+
336
+ client.publish(requestTopic, payload, { qos: MQTT_CONFIG.qos, retain: false }, (err) => {
337
+ if (err) {
338
+ log('Failed to publish permission request', { error: err.message });
339
+ cleanup(() => {
340
+ reject(new Error(`Failed to publish: ${err.message}`));
341
+ });
342
+ return;
343
+ }
344
+
345
+ log('Published permission request', { topic: requestTopic });
346
+ });
347
+ });
348
+ });
349
+
350
+ // Handle incoming messages (response from frontend)
351
+ client.on('message', (topic, payload) => {
352
+ if (topic !== responseTopic) return;
353
+
354
+ try {
355
+ const response = JSON.parse(payload.toString());
356
+ log('Received permission response', response);
357
+
358
+ // Validate response format
359
+ if (!response.decision || !['allow', 'deny'].includes(response.decision)) {
360
+ log('Invalid response format', response);
361
+ cleanup(() => {
362
+ resolve({
363
+ decision: 'deny',
364
+ reason: 'Invalid response format from frontend'
365
+ });
366
+ });
367
+ return;
368
+ }
369
+
370
+ // If permission mode is updated in response, save it
371
+ if (response.permissionMode) {
372
+ log('Updating permission mode from response', {
373
+ sessionId: session_id,
374
+ newMode: response.permissionMode
375
+ });
376
+
377
+ try {
378
+ const configPath = path.join(__dirname, '..', 'session-config.json');
379
+ let config = {};
380
+
381
+ if (fs.existsSync(configPath)) {
382
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
383
+ }
384
+
385
+ config[session_id] = { permissionMode: response.permissionMode };
386
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
387
+
388
+ log('Permission mode updated successfully', { sessionId: session_id });
389
+ } catch (error) {
390
+ log('Failed to update permission mode', { error: error.message });
391
+ }
392
+ }
393
+
394
+ // If user clicked "don't ask again", add to allowed tools in settings.local.json
395
+ if (response.addToAllowedTools) {
396
+ // Generate permission string from the tool_name and tool_input we received earlier
397
+ const permissionString = generatePermissionString(tool_name, tool_input);
398
+
399
+ log('Adding permission to settings.local.json', {
400
+ toolName: tool_name,
401
+ permissionString
402
+ });
403
+
404
+ try {
405
+ const settingsPath = path.join(__dirname, '..', 'settings.local.json');
406
+ let settings = {};
407
+
408
+ // Read existing settings
409
+ if (fs.existsSync(settingsPath)) {
410
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
411
+ }
412
+
413
+ // Initialize permissions structure if needed
414
+ if (!settings.permissions) {
415
+ settings.permissions = {};
416
+ }
417
+ if (!settings.permissions.allow) {
418
+ settings.permissions.allow = [];
419
+ }
420
+
421
+ // Add permission string if not already present
422
+ if (!settings.permissions.allow.includes(permissionString)) {
423
+ settings.permissions.allow.push(permissionString);
424
+
425
+ // Write back atomically
426
+ const tempPath = `${settingsPath}.tmp`;
427
+ fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2), 'utf8');
428
+ fs.renameSync(tempPath, settingsPath);
429
+
430
+ log('Added permission to settings.local.json', {
431
+ permissionString,
432
+ totalAllowed: settings.permissions.allow.length
433
+ });
434
+ } else {
435
+ log('Permission already exists in settings.local.json', { permissionString });
436
+ }
437
+ } catch (error) {
438
+ log('Failed to update settings.local.json', { error: error.message });
439
+ }
440
+ }
441
+
442
+ // Valid response received
443
+ cleanup(() => {
444
+ resolve({
445
+ decision: response.decision,
446
+ reason: response.reason || `User ${response.decision}ed the tool use`
447
+ });
448
+ });
449
+
450
+ } catch (error) {
451
+ log('Failed to parse response', { error: error.message });
452
+ cleanup(() => {
453
+ resolve({
454
+ decision: 'deny',
455
+ reason: 'Failed to parse permission response'
456
+ });
457
+ });
458
+ }
459
+ });
460
+
461
+ // Handle connection errors
462
+ client.on('error', (error) => {
463
+ log('MQTT connection error', { error: error.message });
464
+ cleanup(() => {
465
+ reject(new Error(`MQTT error: ${error.message}`));
466
+ });
467
+ });
468
+
469
+ // Handle unexpected disconnection
470
+ client.on('close', () => {
471
+ if (!resolved) {
472
+ log('MQTT connection closed unexpectedly');
473
+ cleanup(() => {
474
+ reject(new Error('MQTT connection closed before receiving response'));
475
+ });
476
+ }
477
+ });
478
+ });
479
+ }
480
+
481
+ // ============================================================================
482
+ // Main Execution
483
+ // ============================================================================
484
+
485
+ async function main() {
486
+ try {
487
+ log('Hook started');
488
+
489
+ // Read hook input from stdin
490
+ const hookInput = await readStdin();
491
+ log('Received hook input', hookInput);
492
+
493
+ // Validate required fields
494
+ if (!hookInput.tool_name || !hookInput.session_id || !hookInput.cwd) {
495
+ throw new Error('Missing required fields in hook input');
496
+ }
497
+
498
+ // Get project ID from session mapping file
499
+ let projectId = getProjectIdFromSession(hookInput.session_id);
500
+
501
+ // Fallback to extracting from cwd if not found in mapping
502
+ if (!projectId) {
503
+ log('Using fallback: extracting project ID from cwd');
504
+ projectId = extractProjectIdFromCwd(hookInput.cwd);
505
+ }
506
+
507
+ log('Resolved project ID', { projectId });
508
+
509
+ // Get permission mode for this session
510
+ const permissionMode = getPermissionModeForSession(hookInput.session_id);
511
+ const toolName = hookInput.tool_name;
512
+
513
+ log('Permission mode check', { sessionId: hookInput.session_id, permissionMode, toolName });
514
+
515
+ // Auto-approval logic based on permission mode
516
+ if (permissionMode === 'bypassPermissions') {
517
+ log('Auto-approving (bypass mode)');
518
+ outputDecision('allow', 'User has approved the tool use (bypass mode)');
519
+ process.exit(0);
520
+ }
521
+
522
+ if (permissionMode === 'acceptEdits') {
523
+ // Only auto-approve actual code-modifying tools
524
+ if (CODE_MODIFYING_TOOLS.includes(toolName)) {
525
+ log('Auto-approving (acceptEdits mode)', { toolName });
526
+ outputDecision('allow', `User has approved code-modifying tools`);
527
+ process.exit(0);
528
+ }
529
+ log('Tool not auto-approved in acceptEdits mode, asking user', { toolName });
530
+ }
531
+
532
+ // Plan mode - will handle separately with special UI
533
+ // For now, ask user for all tools in plan mode
534
+ if (permissionMode === 'plan') {
535
+ log('Plan mode detected - will ask user with special UI');
536
+ // TODO: Plan mode will need special handling in frontend
537
+ // For now, ask user normally
538
+ }
539
+
540
+ // Check if this specific tool+command is in allowed list
541
+ if (checkAllowedTools(toolName, hookInput.tool_input)) {
542
+ log('Auto-approving (found in allowed list)');
543
+ outputDecision('allow', `User has allowed this tool use previously for auto-approval`);
544
+ process.exit(0);
545
+ }
546
+
547
+ // Default mode or plan mode - ask user via MQTT
548
+ log('Requesting permission via MQTT', { mode: permissionMode });
549
+ const result = await requestPermission(hookInput, projectId);
550
+ log('Permission decision made', result);
551
+
552
+ // Output decision to Claude
553
+ outputDecision(result.decision, result.reason);
554
+
555
+ // Exit with appropriate code
556
+ process.exit(0);
557
+
558
+ } catch (error) {
559
+ log('Hook execution failed', { error: error.message, stack: error.stack });
560
+
561
+ // On error, deny the tool use
562
+ outputDecision('deny', `Hook error: ${error.message}`);
563
+ process.exit(0); // Exit 0 even on error, but with deny decision
564
+ }
565
+ }
566
+
567
+ // Handle process signals
568
+ process.on('SIGINT', () => {
569
+ log('Received SIGINT, exiting');
570
+ outputDecision('deny', 'Hook interrupted by signal');
571
+ process.exit(0);
572
+ });
573
+
574
+ process.on('SIGTERM', () => {
575
+ log('Received SIGTERM, exiting');
576
+ outputDecision('deny', 'Hook terminated by signal');
577
+ process.exit(0);
578
+ });
579
+
580
+ // Run the hook
581
+ main();