cc-viewer 1.9.7 → 1.9.9

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.
@@ -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 {
@@ -326,6 +327,13 @@ function _createSession(key, cwd) {
326
327
  let _sessionId = null;
327
328
  let _initAnnouncedSid = ''; // sessionId whose slash-command surface was already announced (reset on full reset)
328
329
  let _lastInitSnapshot = null; // last sdk-init payload for WS reconnect replay
330
+ // Last sdk-skills frame (supportedCommands() result) for WS reconnect replay, and the
331
+ // resident query it was loaded from (dedupes the once-per-query-generation fetch).
332
+ let _lastSkillsSnapshot = null;
333
+ let _skillsLoadedQuery = null;
334
+ // init 帧的 skills 白名单(name / plugin:skill / :suffix 形式),用于把 supportedCommands()
335
+ // 的全量命令面收敛为纯 skill 面;null = 该 init 帧未提供(不做过滤,保守放行)。
336
+ let _initSkills = null;
329
337
  // Last announced session state ('idle' | 'running' | 'requires_action' | null) — the
330
338
  // turn-level busy truth for the conversation spinner. Sources, most authoritative
331
339
  // first: CLI-emitted session_state_changed (gated behind CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS,
@@ -689,6 +697,8 @@ function _createSession(key, cwd) {
689
697
  const inputQueue = _inputQueue;
690
698
  _activeQuery = null;
691
699
  _inputQueue = null;
700
+ _skillsLoadedQuery = null; // next resident query re-pulls supportedCommands() on init
701
+ _initSkills = null; // re-captured from the next init frame
692
702
  _clearGraceTimer();
693
703
  _clearTurnWatchdog();
694
704
  _lateResultTombstones = 0;
@@ -747,20 +757,33 @@ function _createSession(key, cwd) {
747
757
  // push/result fallbacks keep the spinner correct at result granularity.
748
758
  if (msg.subtype === 'session_state_changed') {
749
759
  _announceSessionState(msg.state);
750
- } else if (msg.subtype === 'init' && _broadcastWs
751
- && Array.isArray(msg.slash_commands) && msg.slash_commands.length > 0
752
- && msg.session_id !== _initAnnouncedSid) {
753
- _initAnnouncedSid = msg.session_id;
754
- _lastInitSnapshot = {
755
- type: 'sdk-init',
756
- sessionId: msg.session_id,
757
- model: msg.model,
758
- slashCommands: msg.slash_commands,
759
- tools: msg.tools,
760
- cwd: _cwd,
761
- instanceKey,
762
- };
763
- _broadcastWs(_lastInitSnapshot);
760
+ } else if (msg.subtype === 'init') {
761
+ if (_broadcastWs && Array.isArray(msg.slash_commands) && msg.slash_commands.length > 0
762
+ && msg.session_id !== _initAnnouncedSid) {
763
+ _initAnnouncedSid = msg.session_id;
764
+ _lastInitSnapshot = {
765
+ type: 'sdk-init',
766
+ sessionId: msg.session_id,
767
+ model: msg.model,
768
+ slashCommands: msg.slash_commands,
769
+ tools: msg.tools,
770
+ cwd: _cwd,
771
+ instanceKey,
772
+ };
773
+ _broadcastWs(_lastInitSnapshot);
774
+ }
775
+ // Skill menu surface: pull the full supportedCommands() (name + description +
776
+ // argumentHint) once per resident-query generation. Done even when slash_commands
777
+ // is empty/absent — a session with no TUI commands can still expose skills.
778
+ // supportedCommands() returns the whole command surface (incl. /clear, /compact,
779
+ // /model, /usage), so keep ONLY real skills: intersect with the init frame's
780
+ // `skills` allowlist (name / plugin:skill / :suffix forms) to honour the
781
+ // "slash menu indexes skills only" decision.
782
+ if (_broadcastWs && _activeQuery && _skillsLoadedQuery !== _activeQuery) {
783
+ _skillsLoadedQuery = _activeQuery;
784
+ _initSkills = Array.isArray(msg.skills) ? msg.skills : null;
785
+ _loadSupportedCommands();
786
+ }
764
787
  } else if (msg.subtype === 'compact_boundary' && _broadcastWs) {
765
788
  const meta = msg.compact_metadata;
766
789
  _broadcastWs({
@@ -952,6 +975,58 @@ function _createSession(key, cwd) {
952
975
  }
953
976
  }
954
977
 
978
+ /**
979
+ * Pull the SDK's supportedCommands() (skill surface: name + description + argumentHint)
980
+ * once per resident-query generation and broadcast it as an `sdk-skills` frame, retained
981
+ * for WS reconnect replay. Fire-and-forget from the init branch — never awaited. Bounded
982
+ * by CTX_USAGE_TIMEOUT_MS; on timeout/rejection/stale generation it reports and broadcasts
983
+ * nothing (clients fall back to /api/skills). Raw commands are passed through; the web
984
+ * client's normalizeSdkCommands does the safe-name filtering.
985
+ */
986
+ function _loadSupportedCommands() {
987
+ const q = _activeQuery;
988
+ if (!q || typeof q.supportedCommands !== 'function') return;
989
+ let timeoutId;
990
+ const timeout = new Promise((_, reject) => {
991
+ timeoutId = setTimeout(() => reject(new Error('supportedCommands timeout')), CTX_USAGE_TIMEOUT_MS);
992
+ if (timeoutId.unref) timeoutId.unref();
993
+ });
994
+ Promise.race([Promise.resolve().then(() => q.supportedCommands()), timeout]).then((commands) => {
995
+ if (_activeQuery !== q) return; // stale generation (session switch/death) — drop
996
+ // Keep only real skills: supportedCommands() also returns TUI built-ins (/clear,
997
+ // /compact, /model, /usage). When the init frame supplied a skills allowlist, filter
998
+ // by it (name, plugin:skill, or :suffix). No allowlist → pass through (conservative).
999
+ let filtered = Array.isArray(commands) ? commands : [];
1000
+ if (Array.isArray(_initSkills) && _initSkills.length > 0) {
1001
+ const allow = new Set();
1002
+ for (const s of _initSkills) {
1003
+ if (typeof s !== 'string' || !s) continue;
1004
+ allow.add(s); // exact (name or plugin:skill)
1005
+ const idx = s.lastIndexOf(':');
1006
+ if (idx >= 0) allow.add(s.slice(idx + 1)); // :suffix form → bare name
1007
+ }
1008
+ filtered = filtered.filter(c => c && (allow.has(c.name)
1009
+ || (typeof c.name === 'string' && allow.has(c.name.replace(/^plugin.*:/, '')))));
1010
+ }
1011
+ _lastSkillsSnapshot = {
1012
+ type: 'sdk-skills',
1013
+ sessionId: _sessionId || null,
1014
+ ts: Date.now(),
1015
+ cwd: _cwd,
1016
+ instanceKey,
1017
+ commands: filtered,
1018
+ };
1019
+ if (_broadcastWs) {
1020
+ try { _broadcastWs(_lastSkillsSnapshot); }
1021
+ catch (err) { reportSwallowed('sdk-manager.skills-broadcast', err); }
1022
+ }
1023
+ }).catch((err) => {
1024
+ reportSwallowed('sdk-manager.supported-commands', err);
1025
+ }).finally(() => {
1026
+ if (timeoutId) clearTimeout(timeoutId);
1027
+ });
1028
+ }
1029
+
955
1030
  /**
956
1031
  * Collect an MCP-server-status snapshot via the Query control surface (`mcp_status`).
957
1032
  * Returns { ok:true, snapshot } | { ok:false, reason:'session-inactive'|'control-failed' }.
@@ -1103,6 +1178,38 @@ function _createSession(key, cwd) {
1103
1178
  // Tools that need explicit user approval via Web UI (mutating or external access).
1104
1179
  // The six-tool set is shared with the PTY perm-bridge via approval-policy.js.
1105
1180
  if (!APPROVAL_TOOLS.has(toolName)) {
1181
+ // Read/Glob/Grep outside the session cwd would still hit Claude Code's own ask
1182
+ // rules even if we allow here. Only add a session-level directory grant when the
1183
+ // user has enabled auto-approval; otherwise the plain allow still stands but the
1184
+ // directory is not trusted for subsequent calls.
1185
+ if (toolName === 'Read' || toolName === 'Glob' || toolName === 'Grep') {
1186
+ const prefs = readPrefsRaw();
1187
+ const autoApproveSeconds = Number(prefs.autoApproveSeconds);
1188
+ const shouldGrantDir = autoApproveSeconds === -1 || autoApproveSeconds > 0;
1189
+ const sessionCwd = _cwd || process.env.CCV_PROJECT_DIR;
1190
+ if (sessionCwd && shouldGrantDir) {
1191
+ let targetDir = null;
1192
+ if (toolName === 'Read' && typeof input?.file_path === 'string') {
1193
+ targetDir = _pathDirname(_pathResolve(input.file_path));
1194
+ } else if ((toolName === 'Glob' || toolName === 'Grep') && typeof input?.pattern === 'string') {
1195
+ targetDir = input.path ? _pathResolve(input.path) : _pathResolve(sessionCwd);
1196
+ }
1197
+ if (targetDir) {
1198
+ const root = _pathResolve(sessionCwd);
1199
+ if (!(targetDir === root || targetDir.startsWith(root + _pathSep))) {
1200
+ if (!isOverTrustDir(targetDir)) {
1201
+ return {
1202
+ behavior: 'allow',
1203
+ updatedInput: input,
1204
+ updatedPermissions: [
1205
+ { type: 'addDirectories', directories: [targetDir], destination: 'session' },
1206
+ ],
1207
+ };
1208
+ }
1209
+ }
1210
+ }
1211
+ }
1212
+ }
1106
1213
  return { behavior: 'allow', updatedInput: input };
1107
1214
  }
1108
1215
 
@@ -1431,6 +1538,7 @@ function _createSession(key, cwd) {
1431
1538
  _launchResume = null;
1432
1539
  _initAnnouncedSid = '';
1433
1540
  _lastInitSnapshot = null; // don't replay the OLD session's sdk-init to the new one
1541
+ _lastSkillsSnapshot = null; // same for the skill surface
1434
1542
  _lastSessionState = null; // same for the session-state snapshot — new session starts idle
1435
1543
  _clearContextUsage('session-switched');
1436
1544
  _clearMcpStatus('session-switched');
@@ -1452,6 +1560,7 @@ function _createSession(key, cwd) {
1452
1560
  _turnInFlight = false;
1453
1561
  _initAnnouncedSid = '';
1454
1562
  _lastInitSnapshot = null;
1563
+ _lastSkillsSnapshot = null;
1455
1564
  // Tell clients BEFORE nulling the snapshot: a re-init/launch mid-turn would
1456
1565
  // otherwise leave the spinner lit with no frame ever clearing it (the new
1457
1566
  // session starts idle and the state-change guard would swallow the first
@@ -1513,6 +1622,7 @@ function _createSession(key, cwd) {
1513
1622
  },
1514
1623
  getContextUsageSnapshot: () => _lastContextUsage,
1515
1624
  getMcpStatusSnapshot: () => _lastMcpStatus,
1625
+ getSkillsSnapshot: () => _lastSkillsSnapshot,
1516
1626
  fetchContextUsage: async () => {
1517
1627
  // On-demand fetch for the HTTP route: refresh the snapshot, broadcast to all clients,
1518
1628
  // then reply. Unlike _refreshContextUsage this bypasses the in-flight gate (an explicit
@@ -1867,6 +1977,16 @@ export function getSdkInitSnapshot(anchor) {
1867
1977
  return sess ? sess.getSdkInitSnapshot() : null;
1868
1978
  }
1869
1979
 
1980
+ /**
1981
+ * Last sdk-skills frame (supportedCommands() result) for WS reconnect replay. Null until
1982
+ * the first init-driven pull resolves; an absent frame leaves the client on its /api/skills
1983
+ * fallback. No anchor → active session.
1984
+ */
1985
+ export function getSkillsSnapshot(anchor) {
1986
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1987
+ return sess ? sess.getSkillsSnapshot() : null;
1988
+ }
1989
+
1870
1990
  /**
1871
1991
  * Last announced session-state frame for WS reconnect replay (sdk-init snapshot
1872
1992
  * parity). Null until the first state transition — a fresh session is idle, and an
@@ -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
  ];