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/assets/{App-LibkCTGb.js → App-D2zrtS4P.js} +2 -2
- package/dist/assets/{MdxEditorPanel-BVsoJ9XB.js → MdxEditorPanel-BnbZpgDp.js} +1 -1
- package/dist/assets/{Mobile-B2RO24WF.js → Mobile-CU-g7cTn.js} +1 -1
- package/dist/assets/{ProxyStatsModal-C7no3iLL.js → ProxyStatsModal-CavWQfBA.js} +1 -1
- package/dist/assets/{index-BjL6Cj_m.js → index-D2maXxkC.js} +2 -2
- package/dist/assets/{seqResourceLoaders-D3m3KM8U.js → seqResourceLoaders-jOSz1x7F.js} +2 -2
- 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 +35 -2
- package/server/routes/ask-perm.js +94 -3
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-
|
|
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.
|
|
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
|
-
//
|
|
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 {
|
|
@@ -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
|
-
|
|
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
|
];
|