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.
- package/cli.js +1 -0
- package/dist/assets/{App-LibkCTGb.js → App-CLteNB83.js} +2 -2
- package/dist/assets/App-D06hBGII.css +1 -0
- package/dist/assets/{MdxEditorPanel-BVsoJ9XB.js → MdxEditorPanel-BJVD_bcU.js} +1 -1
- package/dist/assets/{Mobile-B2RO24WF.js → Mobile-CJCQf3MQ.js} +1 -1
- package/dist/assets/{ProxyStatsModal-C7no3iLL.js → ProxyStatsModal-B5ccoOPy.js} +1 -1
- package/dist/assets/index-CqX5TkEL.js +2 -0
- package/dist/assets/seqResourceLoaders-7lsgbvGa.js +2 -0
- package/dist/assets/seqResourceLoaders-C0cU2e1o.css +41 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/approval-policy.js +29 -0
- package/server/lib/ask/perm-bridge.js +60 -3
- package/server/lib/ask/perm-request-bridge.js +167 -0
- package/server/lib/ensure-hooks.js +33 -1
- package/server/lib/sdk-manager.js +136 -16
- package/server/routes/ask-perm.js +94 -3
- package/server/server.js +43 -3
- package/dist/assets/App-DuDeDOuX.css +0 -1
- package/dist/assets/index-BjL6Cj_m.js +0 -2
- package/dist/assets/seqResourceLoaders-D3m3KM8U.js +0 -2
- package/dist/assets/seqResourceLoaders-DZF6ofB7.css +0 -41
|
@@ -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
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
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'
|
|
751
|
-
&& Array.isArray(msg.slash_commands) && msg.slash_commands.length > 0
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|
];
|