cc-viewer 1.9.7 → 1.9.8

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/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="./assets/index-BjL6Cj_m.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-D2maXxkC.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-CS_S2mBN.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-B5l-VbBO.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-Cz09a1_I.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.9.7",
3
+ "version": "1.9.8",
4
4
  "description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -12,11 +12,16 @@
12
12
  * approval modal: perm-bridge.js exempts it from the CCV_BYPASS_PERMISSIONS
13
13
  * auto-allow; sdk-manager.js routes it into the perm approval branch even in
14
14
  * bypassPermissions mode.
15
+ * - isOverTrustDir: shared guard against over-broad session directory grants
16
+ * (used by both the PTY PermissionRequest bridge and the SDK canUseTool path).
15
17
  *
16
18
  * Pure leaf module (L1-lib): no imports, no I/O — importable from both L1-lib
17
19
  * (sdk-manager.js) and L1-sub (ask/perm-bridge.js) without boundary-gate edges.
18
20
  */
19
21
 
22
+ import { sep } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+
20
25
  export const APPROVAL_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit', 'WebFetch', 'WebSearch']);
21
26
 
22
27
  /**
@@ -27,3 +32,27 @@ export const APPROVAL_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit',
27
32
  export function isPublishCommand(toolName, input) {
28
33
  return toolName === 'Bash' && typeof input?.command === 'string' && /npm\s+publish/i.test(input.command);
29
34
  }
35
+
36
+ /**
37
+ * Whether granting session-level trust to `dir` would be over-broad.
38
+ * Skips: filesystem root, the user's home directory, top-level directories
39
+ * (e.g. /etc, /Users, C:\), and any path containing a dot-directory segment
40
+ * (e.g. ~/.ssh, ~/Library/Keychains).
41
+ *
42
+ * @param {string} dir — absolute directory path
43
+ * @returns {boolean} true when the directory should NOT be granted session trust
44
+ */
45
+ export function isOverTrustDir(dir) {
46
+ if (!dir || typeof dir !== 'string') return true;
47
+ const parts = dir.split(sep).filter(Boolean);
48
+ // Filesystem root or Windows drive root (e.g. 'C:\')
49
+ if (parts.length === 0) return true;
50
+ if (parts.length === 1 && /^[A-Za-z]$/.test(parts[0])) return true;
51
+ // Top-level system directories (e.g. /etc, /Users, /tmp, C:\Windows)
52
+ if (parts.length <= 1) return true;
53
+ // Any dot-directory segment (e.g. ~/.ssh, ~/.aws, ~/Library/Keychains)
54
+ if (parts.some(p => p.startsWith('.'))) return true;
55
+ // User home directory
56
+ if (dir === homedir()) return true;
57
+ return false;
58
+ }
@@ -14,6 +14,7 @@
14
14
  import { readFileSync } from 'node:fs';
15
15
  import http from 'node:http';
16
16
  import https from 'node:https';
17
+ import { resolve, sep } from 'node:path';
17
18
  import { evaluateImDeny } from '../im-deny.js';
18
19
  import { APPROVAL_TOOLS, isPublishCommand } from '../approval-policy.js';
19
20
 
@@ -94,9 +95,65 @@ if (toolName === 'AskUserQuestion') {
94
95
  process.exit(0);
95
96
  }
96
97
 
97
- // These tools need explicit user approval via Web UI (mutating or external access).
98
- // The six-tool set is shared with the SDK canUseTool path via approval-policy.js.
99
- if (!APPROVAL_TOOLS.has(toolName)) {
98
+ // ExitPlanMode is intentionally left as an explicit allow in PTY mode. Routing it to
99
+ // the generic permission panel would conflict with the existing inline plan-approval
100
+ // card (which uses PTY arrow-key detection) and would let the generic auto-approve
101
+ // countdown/instant bypass the independent planAutoApproveSeconds setting. SDK mode
102
+ // handles ExitPlanMode through its own dedicated branch in sdk-manager.js.
103
+ if (toolName === 'ExitPlanMode') {
104
+ process.stdout.write(JSON.stringify({
105
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' },
106
+ }) + '\n');
107
+ process.exit(0);
108
+ } else if (toolName === 'Read' && typeof toolInput?.file_path === 'string') {
109
+ // Read inside the session cwd is safe to auto-allow; outside the session cwd
110
+ // Claude Code's own ask rules would override a PreToolUse allow and still prompt.
111
+ // Let those fall through to the PermissionRequest hook (perm-request-bridge.js)
112
+ // which can approve them silently with an updatedDirectories session grant.
113
+ // Use the hook payload's cwd (the actual session cwd) rather than the server env
114
+ // so multi-workspace sessions classify inside/outside correctly.
115
+ const sessionCwd = payload?.cwd || process.env.CCV_PROJECT_DIR;
116
+ if (sessionCwd) {
117
+ const filePath = resolve(toolInput.file_path);
118
+ const root = resolve(sessionCwd);
119
+ if (filePath === root || filePath.startsWith(root + sep)) {
120
+ process.stdout.write(JSON.stringify({
121
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' },
122
+ }) + '\n');
123
+ process.exit(0);
124
+ }
125
+ // Outside session cwd → fall through to PermissionRequest hook
126
+ process.stdout.write(JSON.stringify({ continue: true, suppressOutput: true }) + '\n');
127
+ process.exit(0);
128
+ }
129
+ // No session cwd → fall back to default behavior (explicit allow)
130
+ process.stdout.write(JSON.stringify({
131
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' },
132
+ }) + '\n');
133
+ process.exit(0);
134
+ } else if ((toolName === 'Glob' || toolName === 'Grep') && typeof toolInput?.pattern === 'string') {
135
+ // Glob/Grep carry { pattern, path? }; the path field (not file_path) determines the
136
+ // base directory. When omitted, Claude Code resolves against the session cwd.
137
+ const sessionCwd = payload?.cwd || process.env.CCV_PROJECT_DIR;
138
+ if (sessionCwd) {
139
+ const baseDir = toolInput.path ? resolve(toolInput.path) : resolve(sessionCwd);
140
+ const root = resolve(sessionCwd);
141
+ if (baseDir === root || baseDir.startsWith(root + sep)) {
142
+ process.stdout.write(JSON.stringify({
143
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' },
144
+ }) + '\n');
145
+ process.exit(0);
146
+ }
147
+ // Outside session cwd → fall through to PermissionRequest hook
148
+ process.stdout.write(JSON.stringify({ continue: true, suppressOutput: true }) + '\n');
149
+ process.exit(0);
150
+ }
151
+ // No session cwd → fall back to default behavior (explicit allow)
152
+ process.stdout.write(JSON.stringify({
153
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' },
154
+ }) + '\n');
155
+ process.exit(0);
156
+ } else if (!APPROVAL_TOOLS.has(toolName)) {
100
157
  process.stdout.write(JSON.stringify({
101
158
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' },
102
159
  }) + '\n');
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * perm-request-bridge.js — PermissionRequest hook bridge for permission prompts.
4
+ *
5
+ * Registered with matcher: 'Read|Glob|Grep' in ~/.claude/settings.json.
6
+ * Called by Claude Code when a permission prompt is about to be shown, after
7
+ * PreToolUse hooks have not resolved the decision. This catches cases where
8
+ * PreToolUse allow is overridden by built-in ask rules / safety checks — most
9
+ * notably Read/Glob/Grep outside the launch directory.
10
+ *
11
+ * Behavior:
12
+ * - If cc-viewer is not running (CCVIEWER_PORT unset): exit 0 with { continue: true }.
13
+ * - Query the cc-viewer server for the current project's autoApproveSeconds.
14
+ * - Only Read/Glob/Grep are eligible for silent allow; all other tools return
15
+ * { continue: true } so the normal prompt appears.
16
+ * - If auto-approve is enabled (instant -1 or countdown >0), return an allow
17
+ * decision plus updatedPermissions.addDirectories for the target directory.
18
+ * - Otherwise return { continue: true } so the normal terminal prompt appears.
19
+ *
20
+ * Exit 0 = success (stdout contains hookSpecificOutput or continue fallback)
21
+ * Exit 1 = error (malformed/missing stdin or invalid env)
22
+ */
23
+
24
+ import { readFileSync } from 'node:fs';
25
+ import http from 'node:http';
26
+ import https from 'node:https';
27
+ import { dirname, resolve } from 'node:path';
28
+ import { isOverTrustDir } from '../approval-policy.js';
29
+
30
+ const port = process.env.CCVIEWER_PORT;
31
+ const rawProtocol = process.env.CCVIEWER_PROTOCOL;
32
+ if (rawProtocol && rawProtocol !== 'http' && rawProtocol !== 'https') {
33
+ process.stderr.write(`perm-request-bridge: invalid CCVIEWER_PROTOCOL "${rawProtocol}" (expected "http" or "https")\n`);
34
+ process.exit(1);
35
+ }
36
+ const isHttps = rawProtocol === 'https';
37
+ const httpClient = isHttps ? https : http;
38
+ if (!port) {
39
+ process.stdout.write(JSON.stringify({ continue: true, suppressOutput: true }) + '\n');
40
+ process.exit(0);
41
+ }
42
+
43
+ let stdinData;
44
+ try {
45
+ stdinData = readFileSync(0, 'utf-8');
46
+ } catch {
47
+ process.exit(1);
48
+ }
49
+
50
+ if (!stdinData || !stdinData.trim()) {
51
+ process.exit(1);
52
+ }
53
+
54
+ let payload;
55
+ try {
56
+ payload = JSON.parse(stdinData);
57
+ } catch {
58
+ process.exit(1);
59
+ }
60
+
61
+ const toolName = payload?.tool_name;
62
+ const toolInput = payload?.tool_input;
63
+ const cwd = payload?.cwd;
64
+
65
+ if (!toolName) {
66
+ process.exit(1);
67
+ }
68
+
69
+ // Only Read/Glob/Grep are eligible for silent allow via PermissionRequest. All other
70
+ // tools fall through to the normal prompt so user-configured ask rules and
71
+ // sensitive operations are never silently approved.
72
+ if (toolName !== 'Read' && toolName !== 'Glob' && toolName !== 'Grep') {
73
+ process.stdout.write(JSON.stringify({ continue: true, suppressOutput: true }) + '\n');
74
+ process.exit(0);
75
+ }
76
+
77
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
78
+
79
+ function postToViewer() {
80
+ return new Promise((resolve, reject) => {
81
+ const body = JSON.stringify({ toolName, input: toolInput, cwd });
82
+ const req = httpClient.request({
83
+ hostname: '127.0.0.1',
84
+ port: Number(port),
85
+ path: '/api/perm-request-hook',
86
+ method: 'POST',
87
+ rejectUnauthorized: false, // allow self-signed certs
88
+ headers: {
89
+ 'Content-Type': 'application/json',
90
+ 'Content-Length': Buffer.byteLength(body),
91
+ },
92
+ }, (res) => {
93
+ let data = '';
94
+ res.on('data', (chunk) => { data += chunk; });
95
+ res.on('end', () => {
96
+ if (res.statusCode === 200) {
97
+ try {
98
+ resolve(JSON.parse(data));
99
+ } catch {
100
+ reject(new Error('Invalid response JSON'));
101
+ }
102
+ } else {
103
+ reject(new Error(`HTTP ${res.statusCode}`));
104
+ }
105
+ });
106
+ });
107
+ req.on('error', reject);
108
+ req.setTimeout(TIMEOUT_MS, () => {
109
+ req.destroy();
110
+ reject(new Error('Timeout'));
111
+ });
112
+ req.write(body);
113
+ req.end();
114
+ });
115
+ }
116
+
117
+ function buildAllowOutput() {
118
+ const decision = { behavior: 'allow' };
119
+ // For path-based tools, add the target directory to session-level trusted
120
+ // directories so subsequent accesses to the same directory do not prompt again.
121
+ // Guard against over-trust: skip when the resolved parent is '/', the user's
122
+ // home directory, or a top-level/dot directory (e.g. /etc, /Users, ~/.ssh).
123
+ if (toolName === 'Read' && typeof toolInput?.file_path === 'string') {
124
+ const dir = dirname(resolve(toolInput.file_path));
125
+ if (!isOverTrustDir(dir)) {
126
+ decision.updatedPermissions = [
127
+ { type: 'addDirectories', directories: [dir], destination: 'session' },
128
+ ];
129
+ }
130
+ } else if ((toolName === 'Glob' || toolName === 'Grep') && typeof toolInput?.pattern === 'string') {
131
+ const base = toolInput.path ? resolve(toolInput.path) : (cwd ? resolve(cwd) : null);
132
+ if (base && !isOverTrustDir(base)) {
133
+ decision.updatedPermissions = [
134
+ { type: 'addDirectories', directories: [base], destination: 'session' },
135
+ ];
136
+ }
137
+ }
138
+ return {
139
+ hookSpecificOutput: {
140
+ hookEventName: 'PermissionRequest',
141
+ decision,
142
+ },
143
+ };
144
+ }
145
+
146
+ try {
147
+ const data = await postToViewer();
148
+ if (data.decision === 'allow') {
149
+ process.stdout.write(JSON.stringify(buildAllowOutput()) + '\n');
150
+ } else if (data.decision === 'deny') {
151
+ process.stdout.write(JSON.stringify({
152
+ hookSpecificOutput: {
153
+ hookEventName: 'PermissionRequest',
154
+ decision: { behavior: 'deny', message: 'Denied by cc-viewer plugin' },
155
+ },
156
+ }) + '\n');
157
+ } else {
158
+ // 'ask' or anything else → let Claude Code show the normal prompt
159
+ process.stdout.write(JSON.stringify({ continue: true, suppressOutput: true }) + '\n');
160
+ }
161
+ process.exit(0);
162
+ } catch (err) {
163
+ // Server unreachable / timeout → fall back to normal prompt
164
+ process.stderr.write(`perm-request-bridge: ${err.message} (falling back to terminal UI)\n`);
165
+ process.stdout.write(JSON.stringify({ continue: true, suppressOutput: true }) + '\n');
166
+ process.exit(0);
167
+ }
@@ -82,7 +82,7 @@ function _looksStaleManagedCommand(cmd) {
82
82
  // All hook sections cc-viewer manages. _purgeStaleManagedHooks and
83
83
  // removeAllManagedHooks must iterate this exact set or uninstall/cleanup
84
84
  // leaves zombie entries behind (cli.js cleanup-hooks path).
85
- const MANAGED_SECTIONS = ['PreToolUse', 'Stop', 'SessionStart', 'TaskCreated', 'TaskCompleted', 'UserPromptSubmit', 'PostToolUse'];
85
+ const MANAGED_SECTIONS = ['PreToolUse', 'PermissionRequest', 'Stop', 'SessionStart', 'TaskCreated', 'TaskCompleted', 'UserPromptSubmit', 'PostToolUse'];
86
86
 
87
87
  function _purgeStaleManagedHooks(settings) {
88
88
  let removed = 0;
@@ -200,6 +200,38 @@ export function ensureHooks() {
200
200
  changed = true;
201
201
  }
202
202
 
203
+ // PermissionRequest hook → perm-request-bridge.js (matcher: 'Read|Glob|Grep')
204
+ // Catches permission prompts that PreToolUse could not resolve (e.g. Read/Glob/Grep
205
+ // outside the launch directory where Claude Code ask rules override a hook allow).
206
+ // Find-by-command (NOT find-by-matcher): a user may legitimately have their own
207
+ // PermissionRequest entry, and in-place overwriting would clobber it — multiple
208
+ // entries per matcher are valid config, so we always append our own.
209
+ // Matcher is scoped to Read|Glob|Grep because the bridge only silently allows those
210
+ // tools; other tools still get a prompt and do not need the bridge process.
211
+ const permRequestBridgePath = resolve(SERVER_LIB, 'ask', 'perm-request-bridge.js');
212
+ const permRequestCmd = `[ -n "$CCVIEWER_PORT" ] && node "${permRequestBridgePath}" || true ${CCV_HOOK_MARKER}`;
213
+ const permRequestDesired = _buildHookObj(permRequestCmd);
214
+ const permRequestExisting = settings.hooks.PermissionRequest.find(h => {
215
+ const cmd = h.hooks?.[0]?.command || '';
216
+ return cmd.includes('perm-request-bridge.js');
217
+ });
218
+ if (permRequestExisting) {
219
+ if (permRequestExisting.matcher !== 'Read|Glob|Grep') {
220
+ permRequestExisting.matcher = 'Read|Glob|Grep';
221
+ changed = true;
222
+ }
223
+ if (!_hookObjEqual(permRequestExisting.hooks?.[0], permRequestDesired)) {
224
+ permRequestExisting.hooks = [_mergeHookObj(permRequestExisting.hooks?.[0], permRequestDesired)];
225
+ changed = true;
226
+ }
227
+ } else {
228
+ settings.hooks.PermissionRequest.push({
229
+ matcher: 'Read|Glob|Grep',
230
+ hooks: [permRequestDesired]
231
+ });
232
+ changed = true;
233
+ }
234
+
203
235
  // Stop hook → turn-end-bridge.js. Fires when Claude finishes responding (real
204
236
  // end of a user-prompt turn), so the voice-pack `turnEnd` event can play at the
205
237
  // right moment — not after every individual API call like the SSE streaming
@@ -29,11 +29,12 @@
29
29
  import { ASK_TIMEOUT_MS } from './ask/ask-constants.js';
30
30
  import { withDefaultThinkingDisplay, resolveLaunchSystemPrompt, launchArgsToExtraArgs } from './launch-config.js';
31
31
  import { evaluateImDeny } from './im-deny.js';
32
- import { APPROVAL_TOOLS, isPublishCommand } from './approval-policy.js';
32
+ import { APPROVAL_TOOLS, isOverTrustDir, isPublishCommand } from './approval-policy.js';
33
33
  import { reportSwallowed } from '@ccv/core/error-report';
34
- import { resolve as _pathResolve } from 'node:path';
34
+ import { resolve as _pathResolve, dirname as _pathDirname, sep as _pathSep } from 'node:path';
35
35
  import { createHash } from 'node:crypto';
36
36
  import { registerSdkSessionLister } from './sdk-session-index.js';
37
+ import { readPrefsRaw } from './prefs-store.js';
37
38
 
38
39
  let _query;
39
40
  try {
@@ -1103,6 +1104,38 @@ function _createSession(key, cwd) {
1103
1104
  // Tools that need explicit user approval via Web UI (mutating or external access).
1104
1105
  // The six-tool set is shared with the PTY perm-bridge via approval-policy.js.
1105
1106
  if (!APPROVAL_TOOLS.has(toolName)) {
1107
+ // Read/Glob/Grep outside the session cwd would still hit Claude Code's own ask
1108
+ // rules even if we allow here. Only add a session-level directory grant when the
1109
+ // user has enabled auto-approval; otherwise the plain allow still stands but the
1110
+ // directory is not trusted for subsequent calls.
1111
+ if (toolName === 'Read' || toolName === 'Glob' || toolName === 'Grep') {
1112
+ const prefs = readPrefsRaw();
1113
+ const autoApproveSeconds = Number(prefs.autoApproveSeconds);
1114
+ const shouldGrantDir = autoApproveSeconds === -1 || autoApproveSeconds > 0;
1115
+ const sessionCwd = _cwd || process.env.CCV_PROJECT_DIR;
1116
+ if (sessionCwd && shouldGrantDir) {
1117
+ let targetDir = null;
1118
+ if (toolName === 'Read' && typeof input?.file_path === 'string') {
1119
+ targetDir = _pathDirname(_pathResolve(input.file_path));
1120
+ } else if ((toolName === 'Glob' || toolName === 'Grep') && typeof input?.pattern === 'string') {
1121
+ targetDir = input.path ? _pathResolve(input.path) : _pathResolve(sessionCwd);
1122
+ }
1123
+ if (targetDir) {
1124
+ const root = _pathResolve(sessionCwd);
1125
+ if (!(targetDir === root || targetDir.startsWith(root + _pathSep))) {
1126
+ if (!isOverTrustDir(targetDir)) {
1127
+ return {
1128
+ behavior: 'allow',
1129
+ updatedInput: input,
1130
+ updatedPermissions: [
1131
+ { type: 'addDirectories', directories: [targetDir], destination: 'session' },
1132
+ ],
1133
+ };
1134
+ }
1135
+ }
1136
+ }
1137
+ }
1138
+ }
1106
1139
  return { behavior: 'allow', updatedInput: input };
1107
1140
  }
1108
1141
 
@@ -4,6 +4,9 @@ import { runWaterfallHook, runParallelHook } from '../lib/plugin-loader.js';
4
4
  import { sendEventToClients } from '../lib/log-watcher.js';
5
5
  import { filterClientsByScope } from '../lib/v2/view-router.js';
6
6
  import { _projectName } from '../interceptor.js';
7
+ import { readPrefsRaw } from '../lib/prefs-store.js';
8
+ import { getCurrentProjectKey, hasFork, resolveScoped } from '../lib/project-prefs.js';
9
+ import { reportSwallowed } from '@ccv/core/error-report';
7
10
 
8
11
  function pendingAsks(req, res, parsedUrl, isLocal, deps) {
9
12
  try {
@@ -376,15 +379,20 @@ function askHookCancel(req, res, parsedUrl, isLocal, deps) {
376
379
  // Permission hook bridge: receive tool permission request from perm-bridge.js, long-poll for user decision
377
380
  function permHook(req, res, parsedUrl, isLocal, deps) {
378
381
  let body = '';
382
+ let bodyTooLarge = false;
379
383
  req.on('data', (chunk) => {
384
+ if (bodyTooLarge) return;
380
385
  body += chunk;
381
386
  if (body.length > 1000000) {
382
- res.writeHead(413, { 'Content-Type': 'application/json' });
383
- res.end(JSON.stringify({ error: 'Request body too large' }));
384
- return;
387
+ bodyTooLarge = true;
388
+ req.destroy();
385
389
  }
386
390
  });
387
391
  req.on('end', async () => {
392
+ if (bodyTooLarge) {
393
+ try { if (!res.headersSent) { res.writeHead(413, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Request body too large' })); } } catch {}
394
+ return;
395
+ }
388
396
  try {
389
397
  const { toolName, input } = JSON.parse(body);
390
398
  if (!toolName) {
@@ -472,6 +480,88 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
472
480
  });
473
481
  }
474
482
 
483
+ // PermissionRequest hook bridge: receive permission-prompt notification from
484
+ // perm-request-bridge.js and decide whether to auto-approve based on the current
485
+ // project's autoApproveSeconds preference. This runs when a permission prompt is
486
+ // about to be shown (e.g. Read/Glob outside the launch directory) and lets us
487
+ // suppress it silently when the user has enabled auto-approval.
488
+ //
489
+ // Security model: this endpoint is a machine bridge (called by the loopback
490
+ // perm-request-bridge.js hook), not a user prefs read. It must therefore resolve
491
+ // the project fork from the payload cwd regardless of isLocal, and restrict the
492
+ // silent allow to Read/Glob only — all other tools return 'ask' so user ask-rules
493
+ // and sensitive operations still prompt.
494
+ function permRequestHook(req, res, parsedUrl, isLocal, deps) {
495
+ let body = '';
496
+ let bodyTooLarge = false;
497
+ req.on('data', (chunk) => {
498
+ if (bodyTooLarge) return;
499
+ body += chunk;
500
+ if (body.length > 1000000) {
501
+ bodyTooLarge = true;
502
+ req.destroy();
503
+ }
504
+ });
505
+ req.on('end', async () => {
506
+ if (bodyTooLarge) {
507
+ try { if (!res.headersSent) { res.writeHead(413, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Request body too large' })); } } catch {}
508
+ return;
509
+ }
510
+ try {
511
+ const { toolName, input, cwd } = JSON.parse(body);
512
+ if (!toolName) {
513
+ res.writeHead(400, { 'Content-Type': 'application/json' });
514
+ res.end(JSON.stringify({ error: 'Missing toolName' }));
515
+ return;
516
+ }
517
+
518
+ // Only Read/Glob/Grep may be silently allowed; everything else falls through to
519
+ // the normal prompt so user-configured ask rules are never bypassed.
520
+ if (toolName !== 'Read' && toolName !== 'Glob' && toolName !== 'Grep') {
521
+ res.writeHead(200, { 'Content-Type': 'application/json' });
522
+ res.end(JSON.stringify({ decision: 'ask' }));
523
+ return;
524
+ }
525
+
526
+ // Plugin hook: let plugins handle permission requests directly.
527
+ try {
528
+ const hookResult = await runWaterfallHook('onPermRequest', { toolName, input, mode: 'permission-request' });
529
+ if (hookResult.decision === 'allow' || hookResult.decision === 'deny') {
530
+ res.writeHead(200, { 'Content-Type': 'application/json' });
531
+ res.end(JSON.stringify({ decision: hookResult.decision }));
532
+ return;
533
+ }
534
+ } catch (err) { reportSwallowed('ask-perm.permRequestHook.waterfall', err); }
535
+
536
+ const prefs = readPrefsRaw(deps.getPrefsFile?.() || undefined);
537
+ // Resolve the project fork from the payload cwd (the actual session cwd),
538
+ // not the server's current CCV_PROJECT_DIR, so multi-workspace setups read
539
+ // the correct project's autoApproveSeconds.
540
+ const projectKey = typeof cwd === 'string' && cwd ? cwd : getCurrentProjectKey();
541
+ const scoped = hasFork(prefs, projectKey);
542
+ const effective = scoped ? resolveScoped(prefs, prefs.prefsByProject[projectKey]) : prefs;
543
+ const autoApproveSeconds = Number(effective.autoApproveSeconds);
544
+
545
+ // autoApproveSeconds: 0 = off/manual, >0 = countdown, -1 = instant.
546
+ // For PermissionRequest the prompt is already imminent; we only suppress it
547
+ // when the user has explicitly enabled auto-approval. Both instant (-1) and
548
+ // countdown (>0) are treated as allow here because the user has already
549
+ // opted into automatic approval; the countdown variant simply adds a visible
550
+ // delay in the UI, which is irrelevant for a headless hook.
551
+ const shouldAutoAllow = autoApproveSeconds === -1 || autoApproveSeconds > 0;
552
+
553
+ res.writeHead(200, { 'Content-Type': 'application/json' });
554
+ res.end(JSON.stringify({ decision: shouldAutoAllow ? 'allow' : 'ask' }));
555
+ } catch (err) {
556
+ reportSwallowed('ask-perm.permRequestHook.parse', err);
557
+ if (!res.headersSent) {
558
+ res.writeHead(400, { 'Content-Type': 'application/json' });
559
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
560
+ }
561
+ }
562
+ });
563
+ }
564
+
475
565
  // 流式 chunk 接收端点:interceptor 在 SSE 流过程中推送的 partial entry
476
566
  // 仅广播,不落盘。前端按 timestamp|url 自动与最终 entry 去重覆盖。
477
567
  // 鉴权:server 绑 0.0.0.0 允许同机任意进程访问,必须校验 remote 必须是 loopback
@@ -556,5 +646,6 @@ export const askPermRoutes = [
556
646
  { predicate: (url, method) => url.startsWith('/api/ask-hook/') && url.includes('/result') && method === 'GET', handler: askHookResult },
557
647
  { predicate: (url, method) => url.startsWith('/api/ask-hook/') && url.includes('/cancel') && method === 'POST', handler: askHookCancel },
558
648
  { method: 'POST', match: 'exact', path: '/api/perm-hook', handler: permHook },
649
+ { method: 'POST', match: 'exact', path: '/api/perm-request-hook', handler: permRequestHook },
559
650
  { method: 'POST', match: 'exact', path: '/api/stream-chunk', handler: streamChunk },
560
651
  ];