@naarang/ccc 1.2.0-beta.9 → 2.0.0-alpha.1
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 +136 -569
- package/bin/ccc.cjs +76 -0
- package/dist/index.js +2640 -1
- package/dist/ngrok.win32-x64-msvc-zjj4rz8c.node +0 -0
- package/dist/scripts/postinstall.cjs +84 -0
- package/package.json +32 -25
- package/scripts/postinstall.cjs +84 -0
- package/LICENSE +0 -44
- package/bin/ccc.js +0 -45
- package/dist/claude/manager.js +0 -1
- package/dist/claude/project-setup.js +0 -1
- package/dist/claude/session-manager.js +0 -1
- package/dist/claude/session-message-parser.js +0 -1
- package/dist/claude/session.js +0 -1
- package/dist/claude/stream-parser.js +0 -1
- package/dist/firebase/admin.js +0 -1
- package/dist/hooks/notification_hook.js +0 -306
- package/dist/hooks/package-lock.json +0 -550
- package/dist/hooks/package.json +0 -16
- package/dist/hooks/permissions_hook.js +0 -657
- package/dist/mdns/service.js +0 -1
- package/dist/mqtt/client.js +0 -1
- package/dist/mqtt-broker.js +0 -1
- package/dist/ngrok/manager.js +0 -1
- package/dist/notifications/handlers.js +0 -1
- package/dist/notifications/index.js +0 -1
- package/dist/notifications/manager.js +0 -1
- package/dist/notifications/preferences-manager.js +0 -1
- package/dist/notifications/preferences-storage.js +0 -1
- package/dist/notifications/sender.js +0 -1
- package/dist/notifications/storage.js +0 -1
- package/dist/notifications/types.js +0 -1
- package/dist/proxy/router.js +0 -1
- package/dist/public/terminal.html +0 -250
- package/dist/qr/generator.js +0 -1
- package/dist/terminal/server.js +0 -1
- package/dist/types/index.js +0 -1
- package/dist/utils/auto-update.js +0 -1
- package/dist/utils/logger.js +0 -1
- package/dist/utils/version.js +0 -1
- package/scripts/check-pty.js +0 -142
- package/scripts/obfuscate.js +0 -77
|
@@ -1,657 +0,0 @@
|
|
|
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
|
-
* Reads permission mode for given session from session-config.json
|
|
91
|
-
* Returns permission mode ('default', 'acceptEdits', 'plan', 'bypassPermissions')
|
|
92
|
-
*/
|
|
93
|
-
function getPermissionModeForSession(sessionId) {
|
|
94
|
-
try {
|
|
95
|
-
if (!fs.existsSync(SESSION_CONFIG_FILE)) {
|
|
96
|
-
log('Session config file not found, using default mode', { path: SESSION_CONFIG_FILE });
|
|
97
|
-
return 'default';
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const data = fs.readFileSync(SESSION_CONFIG_FILE, 'utf8');
|
|
101
|
-
const config = JSON.parse(data);
|
|
102
|
-
|
|
103
|
-
if (config[sessionId] && config[sessionId].permissionMode) {
|
|
104
|
-
const mode = config[sessionId].permissionMode;
|
|
105
|
-
log('Found permission mode for session', { sessionId, permissionMode: mode });
|
|
106
|
-
return mode;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
log('Session ID not found in config, using default mode', { sessionId });
|
|
110
|
-
return 'default';
|
|
111
|
-
|
|
112
|
-
} catch (error) {
|
|
113
|
-
log('Error reading session config, using default mode', { error: error.message });
|
|
114
|
-
return 'default';
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Reads the current working directory for a session from session-config.json
|
|
120
|
-
* Returns the cwd or null if not found
|
|
121
|
-
*/
|
|
122
|
-
function getCwdForSession(sessionId) {
|
|
123
|
-
try {
|
|
124
|
-
if (!fs.existsSync(SESSION_CONFIG_FILE)) {
|
|
125
|
-
log('Session config file not found, no cwd available', { path: SESSION_CONFIG_FILE });
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const data = fs.readFileSync(SESSION_CONFIG_FILE, 'utf8');
|
|
130
|
-
const config = JSON.parse(data);
|
|
131
|
-
|
|
132
|
-
if (config[sessionId] && config[sessionId].cwd) {
|
|
133
|
-
const cwd = config[sessionId].cwd;
|
|
134
|
-
log('Found cwd for session', { sessionId, cwd });
|
|
135
|
-
return cwd;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
log('No cwd found for session', { sessionId });
|
|
139
|
-
return null;
|
|
140
|
-
|
|
141
|
-
} catch (error) {
|
|
142
|
-
log('Error reading cwd from session config', { error: error.message });
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Checks if a file path is outside the project's current working directory
|
|
149
|
-
* Returns true if the file is outside the project directory
|
|
150
|
-
*/
|
|
151
|
-
function isFileOutsideProjectDirectory(filePath, projectCwd) {
|
|
152
|
-
try {
|
|
153
|
-
if (!filePath || !projectCwd) {
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Resolve both paths to absolute paths
|
|
158
|
-
const absoluteFilePath = path.resolve(filePath);
|
|
159
|
-
const absoluteProjectCwd = path.resolve(projectCwd);
|
|
160
|
-
|
|
161
|
-
// Check if the file path starts with the project cwd
|
|
162
|
-
const isInside = absoluteFilePath.startsWith(absoluteProjectCwd + path.sep) ||
|
|
163
|
-
absoluteFilePath === absoluteProjectCwd;
|
|
164
|
-
|
|
165
|
-
log('Checking if file is outside project directory', {
|
|
166
|
-
filePath: absoluteFilePath,
|
|
167
|
-
projectCwd: absoluteProjectCwd,
|
|
168
|
-
isOutside: !isInside
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
return !isInside;
|
|
172
|
-
|
|
173
|
-
} catch (error) {
|
|
174
|
-
log('Error checking file path', { error: error.message });
|
|
175
|
-
// On error, be conservative and ask for permission
|
|
176
|
-
return true;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Reads JSON input from stdin
|
|
182
|
-
*/
|
|
183
|
-
function readStdin() {
|
|
184
|
-
return new Promise((resolve, reject) => {
|
|
185
|
-
let data = '';
|
|
186
|
-
|
|
187
|
-
process.stdin.setEncoding('utf8');
|
|
188
|
-
|
|
189
|
-
process.stdin.on('data', (chunk) => {
|
|
190
|
-
data += chunk;
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
process.stdin.on('end', () => {
|
|
194
|
-
try {
|
|
195
|
-
const parsed = JSON.parse(data);
|
|
196
|
-
resolve(parsed);
|
|
197
|
-
} catch (error) {
|
|
198
|
-
reject(new Error(`Failed to parse stdin JSON: ${error.message}`));
|
|
199
|
-
}
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
process.stdin.on('error', (error) => {
|
|
203
|
-
reject(new Error(`Error reading stdin: ${error.message}`));
|
|
204
|
-
});
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Outputs the decision to stdout in the format Claude expects
|
|
210
|
-
*/
|
|
211
|
-
function outputDecision(decision, reason = '') {
|
|
212
|
-
const output = {
|
|
213
|
-
hookSpecificOutput: {
|
|
214
|
-
hookEventName: 'PreToolUse',
|
|
215
|
-
permissionDecision: decision,
|
|
216
|
-
permissionDecisionReason: reason
|
|
217
|
-
}
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
console.log(JSON.stringify(output));
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Generates a permission string for Claude Code's settings.local.json format
|
|
225
|
-
* Format: "ToolName(parameter)" - e.g., "Bash(npm install)", "Edit(/path/to/file.ts)"
|
|
226
|
-
*/
|
|
227
|
-
function generatePermissionString(toolName, toolInput) {
|
|
228
|
-
try {
|
|
229
|
-
if (!toolInput) {
|
|
230
|
-
return toolName;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
switch (toolName) {
|
|
234
|
-
case 'Bash':
|
|
235
|
-
return toolInput.command ? `Bash(${toolInput.command})` : 'Bash';
|
|
236
|
-
case 'Edit':
|
|
237
|
-
return toolInput.file_path ? `Edit(${toolInput.file_path})` : 'Edit';
|
|
238
|
-
case 'Write':
|
|
239
|
-
return toolInput.file_path ? `Write(${toolInput.file_path})` : 'Write';
|
|
240
|
-
case 'Read':
|
|
241
|
-
return toolInput.file_path ? `Read(${toolInput.file_path})` : 'Read';
|
|
242
|
-
case 'WebFetch':
|
|
243
|
-
return toolInput.url ? `WebFetch(${toolInput.url})` : 'WebFetch';
|
|
244
|
-
case 'WebSearch':
|
|
245
|
-
return toolInput.query ? `WebSearch(${toolInput.query})` : 'WebSearch';
|
|
246
|
-
case 'NotebookEdit':
|
|
247
|
-
return toolInput.notebook_path ? `NotebookEdit(${toolInput.notebook_path})` : 'NotebookEdit';
|
|
248
|
-
case 'BashOutput':
|
|
249
|
-
return 'BashOutput';
|
|
250
|
-
default:
|
|
251
|
-
return toolName;
|
|
252
|
-
}
|
|
253
|
-
} catch (error) {
|
|
254
|
-
log('Error generating permission string', { error: error.message });
|
|
255
|
-
return toolName;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Checks if a tool+command is in the allowed list (settings.local.json)
|
|
261
|
-
* Returns true if permission is granted via allowed list
|
|
262
|
-
*/
|
|
263
|
-
function checkAllowedTools(toolName, toolInput) {
|
|
264
|
-
try {
|
|
265
|
-
const settingsPath = path.join(__dirname, '..', 'settings.local.json');
|
|
266
|
-
|
|
267
|
-
if (!fs.existsSync(settingsPath)) {
|
|
268
|
-
return false;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
272
|
-
|
|
273
|
-
if (!settings.permissions || !settings.permissions.allow) {
|
|
274
|
-
return false;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Generate the same permission string format used when adding
|
|
278
|
-
const permissionString = generatePermissionString(toolName, toolInput);
|
|
279
|
-
|
|
280
|
-
// Check if this exact permission exists
|
|
281
|
-
const isAllowed = settings.permissions.allow.includes(permissionString);
|
|
282
|
-
|
|
283
|
-
if (isAllowed) {
|
|
284
|
-
log('Tool+command found in allowed list', { permissionString });
|
|
285
|
-
return true;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return false;
|
|
289
|
-
} catch (error) {
|
|
290
|
-
log('Error checking allowed tools', { error: error.message });
|
|
291
|
-
return false;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// ============================================================================
|
|
296
|
-
// MQTT Communication
|
|
297
|
-
// ============================================================================
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Connects to MQTT broker and waits for permission response
|
|
301
|
-
*/
|
|
302
|
-
async function requestPermission(hookInput, projectId) {
|
|
303
|
-
const { tool_name, tool_input, session_id, cwd } = hookInput;
|
|
304
|
-
|
|
305
|
-
log('Processing permission request', { tool_name, projectId, session_id });
|
|
306
|
-
|
|
307
|
-
return new Promise((resolve, reject) => {
|
|
308
|
-
// Use WebSocket protocol for MQTT connection (embedded broker uses ws://)
|
|
309
|
-
const connectUrl = `ws://${MQTT_CONFIG.host}:${MQTT_CONFIG.port}`;
|
|
310
|
-
|
|
311
|
-
const connectOptions = {
|
|
312
|
-
clientId: MQTT_CONFIG.clientId,
|
|
313
|
-
clean: true,
|
|
314
|
-
reconnectPeriod: 0, // Don't auto-reconnect (we need fast failure)
|
|
315
|
-
connectTimeout: 10000,
|
|
316
|
-
};
|
|
317
|
-
|
|
318
|
-
// Add credentials if provided
|
|
319
|
-
if (MQTT_CONFIG.username && MQTT_CONFIG.password) {
|
|
320
|
-
connectOptions.username = MQTT_CONFIG.username;
|
|
321
|
-
connectOptions.password = MQTT_CONFIG.password;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
log('Connecting to MQTT broker', { host: MQTT_CONFIG.host, port: MQTT_CONFIG.port });
|
|
325
|
-
|
|
326
|
-
const client = mqtt.connect(connectUrl, connectOptions);
|
|
327
|
-
let resolved = false;
|
|
328
|
-
let timeoutHandle = null;
|
|
329
|
-
|
|
330
|
-
// Topics for communication
|
|
331
|
-
const requestTopic = `permissions/${projectId}/request`;
|
|
332
|
-
const responseTopic = `permissions/${projectId}/response`;
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Cleanup and resolve/reject helper
|
|
336
|
-
*/
|
|
337
|
-
function cleanup(callback) {
|
|
338
|
-
if (resolved) return;
|
|
339
|
-
resolved = true;
|
|
340
|
-
|
|
341
|
-
if (timeoutHandle) {
|
|
342
|
-
clearTimeout(timeoutHandle);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Clean disconnect
|
|
346
|
-
client.end(true, {}, () => {
|
|
347
|
-
callback();
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Set timeout for user response
|
|
352
|
-
timeoutHandle = setTimeout(() => {
|
|
353
|
-
log('Permission request timed out', { timeout: TIMEOUT_MS });
|
|
354
|
-
cleanup(() => {
|
|
355
|
-
resolve({
|
|
356
|
-
decision: 'deny',
|
|
357
|
-
reason: `Permission request timed out after ${TIMEOUT_MS / 1000} seconds. No response from user.`
|
|
358
|
-
});
|
|
359
|
-
});
|
|
360
|
-
}, TIMEOUT_MS);
|
|
361
|
-
|
|
362
|
-
// Handle connection
|
|
363
|
-
client.on('connect', () => {
|
|
364
|
-
log('Connected to MQTT broker');
|
|
365
|
-
|
|
366
|
-
// Subscribe to response topic
|
|
367
|
-
client.subscribe(responseTopic, { qos: MQTT_CONFIG.qos }, (err) => {
|
|
368
|
-
if (err) {
|
|
369
|
-
log('Failed to subscribe to response topic', { error: err.message });
|
|
370
|
-
cleanup(() => {
|
|
371
|
-
reject(new Error(`Failed to subscribe: ${err.message}`));
|
|
372
|
-
});
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
log('Subscribed to response topic', { topic: responseTopic });
|
|
377
|
-
|
|
378
|
-
// Publish permission request
|
|
379
|
-
const permissionRequest = {
|
|
380
|
-
tool_name,
|
|
381
|
-
tool_input,
|
|
382
|
-
session_id,
|
|
383
|
-
cwd,
|
|
384
|
-
timestamp: Date.now(),
|
|
385
|
-
project_id: projectId,
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
const payload = JSON.stringify(permissionRequest);
|
|
389
|
-
|
|
390
|
-
client.publish(requestTopic, payload, { qos: MQTT_CONFIG.qos, retain: false }, (err) => {
|
|
391
|
-
if (err) {
|
|
392
|
-
log('Failed to publish permission request', { error: err.message });
|
|
393
|
-
cleanup(() => {
|
|
394
|
-
reject(new Error(`Failed to publish: ${err.message}`));
|
|
395
|
-
});
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
log('Published permission request', { topic: requestTopic });
|
|
400
|
-
});
|
|
401
|
-
});
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
// Handle incoming messages (response from frontend)
|
|
405
|
-
client.on('message', (topic, payload) => {
|
|
406
|
-
if (topic !== responseTopic) return;
|
|
407
|
-
|
|
408
|
-
try {
|
|
409
|
-
const response = JSON.parse(payload.toString());
|
|
410
|
-
log('Received permission response', response);
|
|
411
|
-
|
|
412
|
-
// Validate response format
|
|
413
|
-
if (!response.decision || !['allow', 'deny'].includes(response.decision)) {
|
|
414
|
-
log('Invalid response format', response);
|
|
415
|
-
cleanup(() => {
|
|
416
|
-
resolve({
|
|
417
|
-
decision: 'deny',
|
|
418
|
-
reason: 'Invalid response format from frontend'
|
|
419
|
-
});
|
|
420
|
-
});
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// If permission mode is updated in response, save it
|
|
425
|
-
if (response.permissionMode) {
|
|
426
|
-
log('Updating permission mode from response', {
|
|
427
|
-
sessionId: session_id,
|
|
428
|
-
newMode: response.permissionMode
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
try {
|
|
432
|
-
const configPath = path.join(__dirname, '..', 'session-config.json');
|
|
433
|
-
let config = {};
|
|
434
|
-
|
|
435
|
-
if (fs.existsSync(configPath)) {
|
|
436
|
-
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
config[session_id] = { permissionMode: response.permissionMode };
|
|
440
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
441
|
-
|
|
442
|
-
log('Permission mode updated successfully', { sessionId: session_id });
|
|
443
|
-
} catch (error) {
|
|
444
|
-
log('Failed to update permission mode', { error: error.message });
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// If user clicked "don't ask again", add to allowed tools in settings.local.json
|
|
449
|
-
if (response.addToAllowedTools) {
|
|
450
|
-
// Generate permission string from the tool_name and tool_input we received earlier
|
|
451
|
-
const permissionString = generatePermissionString(tool_name, tool_input);
|
|
452
|
-
|
|
453
|
-
log('Adding permission to settings.local.json', {
|
|
454
|
-
toolName: tool_name,
|
|
455
|
-
permissionString
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
try {
|
|
459
|
-
const settingsPath = path.join(__dirname, '..', 'settings.local.json');
|
|
460
|
-
let settings = {};
|
|
461
|
-
|
|
462
|
-
// Read existing settings
|
|
463
|
-
if (fs.existsSync(settingsPath)) {
|
|
464
|
-
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Initialize permissions structure if needed
|
|
468
|
-
if (!settings.permissions) {
|
|
469
|
-
settings.permissions = {};
|
|
470
|
-
}
|
|
471
|
-
if (!settings.permissions.allow) {
|
|
472
|
-
settings.permissions.allow = [];
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Add permission string if not already present
|
|
476
|
-
if (!settings.permissions.allow.includes(permissionString)) {
|
|
477
|
-
settings.permissions.allow.push(permissionString);
|
|
478
|
-
|
|
479
|
-
// Write back atomically
|
|
480
|
-
const tempPath = `${settingsPath}.tmp`;
|
|
481
|
-
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2), 'utf8');
|
|
482
|
-
fs.renameSync(tempPath, settingsPath);
|
|
483
|
-
|
|
484
|
-
log('Added permission to settings.local.json', {
|
|
485
|
-
permissionString,
|
|
486
|
-
totalAllowed: settings.permissions.allow.length
|
|
487
|
-
});
|
|
488
|
-
} else {
|
|
489
|
-
log('Permission already exists in settings.local.json', { permissionString });
|
|
490
|
-
}
|
|
491
|
-
} catch (error) {
|
|
492
|
-
log('Failed to update settings.local.json', { error: error.message });
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// Valid response received
|
|
497
|
-
cleanup(() => {
|
|
498
|
-
resolve({
|
|
499
|
-
decision: response.decision,
|
|
500
|
-
reason: response.reason || `User ${response.decision}ed the tool use`
|
|
501
|
-
});
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
} catch (error) {
|
|
505
|
-
log('Failed to parse response', { error: error.message });
|
|
506
|
-
cleanup(() => {
|
|
507
|
-
resolve({
|
|
508
|
-
decision: 'deny',
|
|
509
|
-
reason: 'Failed to parse permission response'
|
|
510
|
-
});
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
// Handle connection errors
|
|
516
|
-
client.on('error', (error) => {
|
|
517
|
-
log('MQTT connection error', { error: error.message });
|
|
518
|
-
cleanup(() => {
|
|
519
|
-
reject(new Error(`MQTT error: ${error.message}`));
|
|
520
|
-
});
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
// Handle unexpected disconnection
|
|
524
|
-
client.on('close', () => {
|
|
525
|
-
if (!resolved) {
|
|
526
|
-
log('MQTT connection closed unexpectedly');
|
|
527
|
-
cleanup(() => {
|
|
528
|
-
reject(new Error('MQTT connection closed before receiving response'));
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
});
|
|
532
|
-
});
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// ============================================================================
|
|
536
|
-
// Main Execution
|
|
537
|
-
// ============================================================================
|
|
538
|
-
|
|
539
|
-
async function main() {
|
|
540
|
-
try {
|
|
541
|
-
log('Hook started');
|
|
542
|
-
|
|
543
|
-
// Read hook input from stdin
|
|
544
|
-
const hookInput = await readStdin();
|
|
545
|
-
log('Received hook input', hookInput);
|
|
546
|
-
|
|
547
|
-
// Validate required fields
|
|
548
|
-
if (!hookInput.tool_name || !hookInput.session_id || !hookInput.cwd) {
|
|
549
|
-
throw new Error('Missing required fields in hook input');
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// Get project ID from session mapping file
|
|
553
|
-
let projectId = getProjectIdFromSession(hookInput.session_id);
|
|
554
|
-
|
|
555
|
-
// CRITICAL: If session is not in sessions.json, this is a native Claude Code session
|
|
556
|
-
// (not managed by CCC backend). Pass through without MQTT check to avoid hanging.
|
|
557
|
-
if (!projectId) {
|
|
558
|
-
log('Session not found in sessions.json - this is a native Claude Code session');
|
|
559
|
-
log('Passing through to use Claude Code\'s native permission system');
|
|
560
|
-
process.exit(0);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
log('Resolved project ID', { projectId });
|
|
564
|
-
|
|
565
|
-
// Get permission mode for this session
|
|
566
|
-
const permissionMode = getPermissionModeForSession(hookInput.session_id);
|
|
567
|
-
const toolName = hookInput.tool_name;
|
|
568
|
-
|
|
569
|
-
log('Permission mode check', { sessionId: hookInput.session_id, permissionMode, toolName });
|
|
570
|
-
|
|
571
|
-
// Auto-approval logic based on permission mode
|
|
572
|
-
if (permissionMode === 'bypassPermissions') {
|
|
573
|
-
log('Auto-approving (bypass mode)');
|
|
574
|
-
outputDecision('allow', 'User has approved the tool use (bypass mode)');
|
|
575
|
-
process.exit(0);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
if (permissionMode === 'acceptEdits') {
|
|
579
|
-
// Only auto-approve actual code-modifying tools
|
|
580
|
-
if (CODE_MODIFYING_TOOLS.includes(toolName)) {
|
|
581
|
-
log('Auto-approving (acceptEdits mode)', { toolName });
|
|
582
|
-
outputDecision('allow', `User has approved code-modifying tools`);
|
|
583
|
-
process.exit(0);
|
|
584
|
-
}
|
|
585
|
-
log('Tool not auto-approved in acceptEdits mode, asking user', { toolName });
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// Plan mode - will handle separately with special UI
|
|
589
|
-
// For now, ask user for all tools in plan mode
|
|
590
|
-
if (permissionMode === 'plan') {
|
|
591
|
-
log('Plan mode detected - will ask user with special UI');
|
|
592
|
-
// TODO: Plan mode will need special handling in frontend
|
|
593
|
-
// For now, ask user normally
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Special handling for Read tool - check if file is outside project directory
|
|
597
|
-
if (toolName === 'Read') {
|
|
598
|
-
const projectCwd = getCwdForSession(hookInput.session_id);
|
|
599
|
-
const filePath = hookInput.tool_input?.file_path;
|
|
600
|
-
|
|
601
|
-
if (projectCwd && filePath) {
|
|
602
|
-
const isOutside = isFileOutsideProjectDirectory(filePath, projectCwd);
|
|
603
|
-
|
|
604
|
-
if (!isOutside) {
|
|
605
|
-
// File is inside project directory, auto-approve
|
|
606
|
-
log('Auto-approving Read (file inside project directory)', { filePath, projectCwd });
|
|
607
|
-
outputDecision('allow', 'File is within project directory');
|
|
608
|
-
process.exit(0);
|
|
609
|
-
} else {
|
|
610
|
-
// File is outside project directory, will ask user below
|
|
611
|
-
log('Read operation outside project directory, will ask user', { filePath, projectCwd });
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// Check if this specific tool+command is in allowed list
|
|
617
|
-
if (checkAllowedTools(toolName, hookInput.tool_input)) {
|
|
618
|
-
log('Auto-approving (found in allowed list)');
|
|
619
|
-
outputDecision('allow', `User has allowed this tool use previously for auto-approval`);
|
|
620
|
-
process.exit(0);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// Default mode or plan mode - ask user via MQTT
|
|
624
|
-
log('Requesting permission via MQTT', { mode: permissionMode });
|
|
625
|
-
const result = await requestPermission(hookInput, projectId);
|
|
626
|
-
log('Permission decision made', result);
|
|
627
|
-
|
|
628
|
-
// Output decision to Claude
|
|
629
|
-
outputDecision(result.decision, result.reason);
|
|
630
|
-
|
|
631
|
-
// Exit with appropriate code
|
|
632
|
-
process.exit(0);
|
|
633
|
-
|
|
634
|
-
} catch (error) {
|
|
635
|
-
log('Hook execution failed', { error: error.message, stack: error.stack });
|
|
636
|
-
|
|
637
|
-
// On error, deny the tool use
|
|
638
|
-
outputDecision('deny', `Hook error: ${error.message}`);
|
|
639
|
-
process.exit(0); // Exit 0 even on error, but with deny decision
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// Handle process signals
|
|
644
|
-
process.on('SIGINT', () => {
|
|
645
|
-
log('Received SIGINT, exiting');
|
|
646
|
-
outputDecision('deny', 'Hook interrupted by signal');
|
|
647
|
-
process.exit(0);
|
|
648
|
-
});
|
|
649
|
-
|
|
650
|
-
process.on('SIGTERM', () => {
|
|
651
|
-
log('Received SIGTERM, exiting');
|
|
652
|
-
outputDecision('deny', 'Hook terminated by signal');
|
|
653
|
-
process.exit(0);
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
// Run the hook
|
|
657
|
-
main();
|