cc-viewer 1.9.15 → 1.9.17
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/concepts/ar/UltraPlan.md +9 -9
- package/concepts/da/UltraPlan.md +9 -9
- package/concepts/de/UltraPlan.md +9 -9
- package/concepts/en/UltraPlan.md +9 -9
- package/concepts/es/UltraPlan.md +9 -9
- package/concepts/fr/UltraPlan.md +9 -9
- package/concepts/it/UltraPlan.md +9 -9
- package/concepts/ja/UltraPlan.md +9 -9
- package/concepts/ko/UltraPlan.md +9 -9
- package/concepts/no/UltraPlan.md +9 -9
- package/concepts/pl/UltraPlan.md +9 -9
- package/concepts/pt-BR/UltraPlan.md +9 -9
- package/concepts/ru/UltraPlan.md +9 -9
- package/concepts/th/UltraPlan.md +9 -9
- package/concepts/tr/UltraPlan.md +9 -9
- package/concepts/uk/UltraPlan.md +9 -9
- package/concepts/zh/UltraPlan.md +9 -9
- package/concepts/zh-TW/UltraPlan.md +9 -9
- package/dist/assets/App-BlNbziAQ.js +2 -0
- package/dist/assets/App-BvVdj099.css +1 -0
- package/dist/assets/{MdxEditorPanel-DRLFmdU5.js → MdxEditorPanel-C9cr-WBH.js} +1 -1
- package/dist/assets/Mobile-CDQPYYQo.js +1 -0
- package/dist/assets/{ProxyStatsModal-Ba1E1lu4.js → ProxyStatsModal-BEqrTR-F.js} +1 -1
- package/dist/assets/index-B4J-uwYG.js +2 -0
- package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
- package/dist/assets/seqResourceLoaders-DaZ8fZXu.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/ask/perm-bridge.js +4 -1
- package/server/lib/auto-approve.js +160 -0
- package/server/lib/file-access-policy.js +132 -5
- package/server/lib/os-open.js +66 -0
- package/server/lib/sdk-manager.js +96 -9
- package/server/lib/target-scope.js +174 -0
- package/server/routes/ask-perm.js +54 -3
- package/server/routes/files-content.js +174 -8
- package/server/routes/files-fs.js +8 -54
- package/server/routes/skills.js +19 -8
- package/server/routes/target-files-fs.js +332 -0
- package/server/server.js +89 -14
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
- package/ultraAgents/code-expert.json +1 -1
- package/dist/assets/App-BCDnm2Lh.js +0 -2
- package/dist/assets/App-CCnPkf2_.css +0 -1
- package/dist/assets/Mobile-C8VJqBwa.js +0 -1
- package/dist/assets/index-Sp3JYYKo.js +0 -2
- package/dist/assets/seqResourceLoaders-BUfjTaCV.css +0 -41
- package/dist/assets/seqResourceLoaders-CHmtPDFO.js +0 -2
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-B4J-uwYG.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="./assets/vendor-antd-I6I2F7Rv.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-Zkm6UHQg.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-CC09MDyZ.js">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.17",
|
|
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",
|
|
@@ -164,7 +164,10 @@ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
164
164
|
|
|
165
165
|
function postToViewer() {
|
|
166
166
|
return new Promise((resolve, reject) => {
|
|
167
|
-
|
|
167
|
+
// Forward the session cwd so the server can resolve the project-forked
|
|
168
|
+
// autoApproveSeconds for the right workspace (falls back to the server's
|
|
169
|
+
// current project key when absent — backward compatible).
|
|
170
|
+
const body = JSON.stringify({ toolName, input: toolInput, cwd: typeof payload?.cwd === 'string' ? payload.cwd : undefined });
|
|
168
171
|
const req = httpClient.request({
|
|
169
172
|
hostname: '127.0.0.1',
|
|
170
173
|
port: Number(port),
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Server-side auto-approve countdown for plan/permission approvals.
|
|
2
|
+
//
|
|
3
|
+
// The auto-approve countdown used to live entirely in the web client: when the
|
|
4
|
+
// browser tab was closed, backgrounded (timer throttling), or disconnected, no
|
|
5
|
+
// approval ever fired and the pending request degraded to the 5-minute deny
|
|
6
|
+
// timeout. This module moves the deadline to the server: the countdown is armed
|
|
7
|
+
// from the moment the approval request arrives (wall-clock), and the server
|
|
8
|
+
// resolves the pending approval itself at deadline + AUTO_APPROVE_BUFFER_MS.
|
|
9
|
+
// The buffer gives a connected client whose own countdown fires at the exact
|
|
10
|
+
// deadline a head start, so a watched approval is still answered by the client
|
|
11
|
+
// and the server timer is only the fallback.
|
|
12
|
+
//
|
|
13
|
+
// Cancellation is durable while the approval is pending: a client cancel marks
|
|
14
|
+
// the entry (autoCancelled) and clears the server timer, the approval stays
|
|
15
|
+
// pending for a manual decision, and the cancelled state is replayed to newly
|
|
16
|
+
// connected clients so they never re-arm a countdown.
|
|
17
|
+
//
|
|
18
|
+
// Semantics of the settings (shared with the client):
|
|
19
|
+
// plan: prefs.approvalModal.planAutoApproveSeconds (default 0 = off)
|
|
20
|
+
// perm: prefs.autoApproveSeconds (default 3 when unset)
|
|
21
|
+
// 0/off, N>0 = N seconds, -1 = instant (resolve immediately, no buffer).
|
|
22
|
+
// AskUserQuestion approvals are never auto-approved.
|
|
23
|
+
|
|
24
|
+
import { readPrefsRaw } from './prefs-store.js';
|
|
25
|
+
import { getCurrentProjectKey, hasFork, resolveScoped } from './project-prefs.js';
|
|
26
|
+
import { reportSwallowed } from './error-report.js';
|
|
27
|
+
|
|
28
|
+
export const AUTO_APPROVE_BUFFER_MS = 1000;
|
|
29
|
+
export const DEFAULT_PERM_AUTO_APPROVE_SECONDS = 3;
|
|
30
|
+
export const AUTO_APPROVE_INSTANT = -1;
|
|
31
|
+
// A hand-edited preferences.json can hold an absurdly large value. Besides being
|
|
32
|
+
// pointless, a value above the 5-minute hard approval timeout would advertise an
|
|
33
|
+
// autoApproveAt the deny timeout always beats (a lying countdown); and beyond
|
|
34
|
+
// 2^31-1 ms Node's setTimeout clamps the delay to 1ms (instant approval). Cap the
|
|
35
|
+
// countdown at the approval timeout so the advertised deadline is always reachable.
|
|
36
|
+
export const MAX_AUTO_APPROVE_SECONDS = 300;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract the configured seconds for a kind ('plan' | 'perm') from an effective
|
|
40
|
+
* prefs object. Returns null when disabled (0 / invalid / negative other than -1 /
|
|
41
|
+
* absurdly large).
|
|
42
|
+
*/
|
|
43
|
+
export function effectiveAutoApproveSeconds(kind, prefs) {
|
|
44
|
+
const raw = kind === 'plan'
|
|
45
|
+
? prefs?.approvalModal?.planAutoApproveSeconds
|
|
46
|
+
: (prefs?.autoApproveSeconds ?? DEFAULT_PERM_AUTO_APPROVE_SECONDS);
|
|
47
|
+
const seconds = Number(raw);
|
|
48
|
+
if (!Number.isFinite(seconds)) return null;
|
|
49
|
+
if (seconds === AUTO_APPROVE_INSTANT) return AUTO_APPROVE_INSTANT;
|
|
50
|
+
if (seconds <= 0) return null;
|
|
51
|
+
if (seconds > MAX_AUTO_APPROVE_SECONDS) return null;
|
|
52
|
+
return seconds;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read prefs (project-fork aware) and compute the auto-approve deadline for a
|
|
57
|
+
* pending approval. `projectKey` is the session cwd (SDK) or hook-reported cwd
|
|
58
|
+
* (PTY hook); falls back to the server's current project key.
|
|
59
|
+
*
|
|
60
|
+
* Returns:
|
|
61
|
+
* { enabled:false } — no auto-approve
|
|
62
|
+
* { enabled:true, instant, seconds, autoApproveAt } — absolute epoch ms deadline
|
|
63
|
+
*/
|
|
64
|
+
export function computeAutoApprove(kind, projectKey, { now = Date.now(), readPrefs = readPrefsRaw, file } = {}) {
|
|
65
|
+
let prefs = {};
|
|
66
|
+
try {
|
|
67
|
+
prefs = readPrefs(file) || {};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
reportSwallowed('auto-approve.read-prefs', err);
|
|
70
|
+
return { enabled: false };
|
|
71
|
+
}
|
|
72
|
+
const key = typeof projectKey === 'string' && projectKey ? projectKey : getCurrentProjectKey();
|
|
73
|
+
const effective = hasFork(prefs, key) ? resolveScoped(prefs, prefs.prefsByProject[key]) : prefs;
|
|
74
|
+
const seconds = effectiveAutoApproveSeconds(kind, effective);
|
|
75
|
+
if (seconds === null) return { enabled: false };
|
|
76
|
+
const instant = seconds === AUTO_APPROVE_INSTANT;
|
|
77
|
+
return {
|
|
78
|
+
enabled: true,
|
|
79
|
+
instant,
|
|
80
|
+
seconds,
|
|
81
|
+
autoApproveAt: instant ? now : now + seconds * 1000,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Delay for the server-side fallback timer: the user-visible deadline plus the
|
|
87
|
+
* request buffer, never negative. Instant fires on the same tick (0).
|
|
88
|
+
*/
|
|
89
|
+
export function autoApproveFireDelayMs(computed, now = Date.now()) {
|
|
90
|
+
if (!computed?.enabled) return null;
|
|
91
|
+
if (computed.instant) return 0;
|
|
92
|
+
return Math.max(0, computed.autoApproveAt - now) + AUTO_APPROVE_BUFFER_MS;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Arm the server-side fallback timer. Returns the auto state stored on the
|
|
97
|
+
* pending entry: { autoApproveAt, seconds, cancelled, timer }.
|
|
98
|
+
* `onFire` is invoked once when the deadline (+buffer) passes; callers must
|
|
99
|
+
* re-check the entry is still pending and not cancelled inside onFire.
|
|
100
|
+
* Instant (-1) fires SYNCHRONOUSLY — the caller MUST register the pending entry
|
|
101
|
+
* (and emit the pending frame) before calling this.
|
|
102
|
+
*/
|
|
103
|
+
export function createAutoApproveState(computed, onFire) {
|
|
104
|
+
const delay = autoApproveFireDelayMs(computed);
|
|
105
|
+
if (delay === null) return null; // disabled — never arm
|
|
106
|
+
const state = {
|
|
107
|
+
autoApproveAt: computed.autoApproveAt,
|
|
108
|
+
seconds: computed.seconds,
|
|
109
|
+
cancelled: false,
|
|
110
|
+
timer: null,
|
|
111
|
+
};
|
|
112
|
+
if (delay === 0) {
|
|
113
|
+
onFire();
|
|
114
|
+
return state;
|
|
115
|
+
}
|
|
116
|
+
state.timer = setTimeout(onFire, delay);
|
|
117
|
+
return state;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Cancel an armed auto-approve state: clears the timer and marks it cancelled.
|
|
122
|
+
* Accepts either the auto state itself or the whole pending entry ({ auto,
|
|
123
|
+
* autoCancelled }) — the entry form also stamps the durable entry-level flag so
|
|
124
|
+
* replays never re-arm. Returns true only on the first cancellation (so callers
|
|
125
|
+
* broadcast once).
|
|
126
|
+
*/
|
|
127
|
+
export function cancelAutoApproveState(autoOrEntry) {
|
|
128
|
+
if (!autoOrEntry) return false;
|
|
129
|
+
// Entry form: { auto, autoCancelled }
|
|
130
|
+
if (Object.prototype.hasOwnProperty.call(autoOrEntry, 'autoCancelled')) {
|
|
131
|
+
const entry = autoOrEntry;
|
|
132
|
+
if (entry.autoCancelled) return false;
|
|
133
|
+
entry.autoCancelled = true;
|
|
134
|
+
if (entry.auto) {
|
|
135
|
+
entry.auto.cancelled = true;
|
|
136
|
+
if (entry.auto.timer) {
|
|
137
|
+
clearTimeout(entry.auto.timer);
|
|
138
|
+
entry.auto.timer = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
const auto = autoOrEntry;
|
|
144
|
+
if (auto.cancelled) return false;
|
|
145
|
+
auto.cancelled = true;
|
|
146
|
+
if (auto.timer) {
|
|
147
|
+
clearTimeout(auto.timer);
|
|
148
|
+
auto.timer = null;
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Fields merged into pending/replay frames for the client countdown UI. */
|
|
154
|
+
export function replayAutoApproveFields(auto, autoCancelled) {
|
|
155
|
+
return {
|
|
156
|
+
autoApproveAt: autoCancelled ? null : (auto?.autoApproveAt ?? null),
|
|
157
|
+
autoApproveSeconds: auto?.seconds ?? null,
|
|
158
|
+
autoCancelled: autoCancelled === true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* 不替代写路径的业务校验(见 server/lib/file-api.js writeFileContent),但同样会被 endpoint 用作首道关卡。
|
|
12
12
|
*/
|
|
13
|
-
import { realpathSync } from 'node:fs';
|
|
14
|
-
import { resolve, basename, sep, join } from 'node:path';
|
|
13
|
+
import { realpathSync, lstatSync } from 'node:fs';
|
|
14
|
+
import { resolve, basename, dirname, sep, join } from 'node:path';
|
|
15
15
|
import { homedir, platform, tmpdir } from 'node:os';
|
|
16
16
|
import { getClaudeConfigDir, onLogDirChange, LOG_DIR } from '../../findcc.js';
|
|
17
17
|
import { loadWorkspaces } from '../workspace-registry.js';
|
|
@@ -122,7 +122,14 @@ function _logDirSensitiveFiles() {
|
|
|
122
122
|
files.add(norm(join(LOG_DIR, name)));
|
|
123
123
|
files.add(norm(join(rootReal, name)));
|
|
124
124
|
}
|
|
125
|
-
|
|
125
|
+
// `root`/`rootReal` keep a trailing separator for startsWith() prefix matching;
|
|
126
|
+
// `rootBare`/`rootRealBare` (no trailing sep) let isWriteAllowed also deny the
|
|
127
|
+
// LOG_DIR directory ITSELF (delete/rename), which the prefix match would miss.
|
|
128
|
+
_logDirSensitiveCache = {
|
|
129
|
+
root: norm(withSep(LOG_DIR)), rootReal: norm(withSep(rootReal)),
|
|
130
|
+
rootBare: norm(LOG_DIR), rootRealBare: norm(rootReal),
|
|
131
|
+
files,
|
|
132
|
+
};
|
|
126
133
|
return _logDirSensitiveCache;
|
|
127
134
|
}
|
|
128
135
|
onLogDirChange(() => { _logDirSensitiveCache = null; });
|
|
@@ -215,6 +222,16 @@ export function isReadAllowed(absPath) {
|
|
|
215
222
|
let real;
|
|
216
223
|
try { real = realpathSync(absPath); } catch { return { ok: false, reason: 'realpath-failed' }; }
|
|
217
224
|
|
|
225
|
+
return evaluateResolvedPath(real);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Shared verdict for a realpath-resolved path — the allowlist + sensitive-file
|
|
229
|
+
// + sensitive-prefix checks that isReadAllowed (existing files) and
|
|
230
|
+
// isWriteAllowed (existing or to-be-created files) both apply. Keeping this the
|
|
231
|
+
// single implementation prevents read/write policy drift: a path the user can
|
|
232
|
+
// read but must not overwrite (e.g. ~/.claude/settings.json) is denied here for
|
|
233
|
+
// both directions.
|
|
234
|
+
function evaluateResolvedPath(real) {
|
|
218
235
|
// 1) Allowlist 命中
|
|
219
236
|
const roots = getAllowedRoots();
|
|
220
237
|
let hitRoot = null;
|
|
@@ -233,8 +250,30 @@ export function isReadAllowed(absPath) {
|
|
|
233
250
|
if (SENSITIVE_CLAUDE_FILES.has(last)) {
|
|
234
251
|
return { ok: false, reason: 'sensitive-claude-config' };
|
|
235
252
|
}
|
|
236
|
-
//
|
|
237
|
-
|
|
253
|
+
// settings.json / settings.local.json at any depth, plus all backups/variants
|
|
254
|
+
// (settings.json.bk*, settings_*.json, etc.) — rolling backup files carry the
|
|
255
|
+
// same env/api-key content and must not slip past the exact-name match.
|
|
256
|
+
if (/^settings(\.local)?\.json($|\.)|^settings_.*\.json$/i.test(last)) {
|
|
257
|
+
return { ok: false, reason: 'sensitive-claude-config' };
|
|
258
|
+
}
|
|
259
|
+
// Prompt history, project transcripts, shell snapshots, file history, and
|
|
260
|
+
// paste cache contain user prompt content and (in the case of shell snapshots)
|
|
261
|
+
// live env vars. Block the whole subtree EXCEPT projects/<encoded>/memory/**,
|
|
262
|
+
// which is the documented per-project memory dir served by /api/project-memory.
|
|
263
|
+
const rel = norm(real.slice(norm(withSep(claudeReal)).length));
|
|
264
|
+
const firstSeg = rel.split(sep)[0];
|
|
265
|
+
if (firstSeg === 'todos' || firstSeg === 'shell-snapshots'
|
|
266
|
+
|| firstSeg === 'file-history' || firstSeg === 'paste-cache' || firstSeg === 'session-env') {
|
|
267
|
+
return { ok: false, reason: 'sensitive-claude-config' };
|
|
268
|
+
}
|
|
269
|
+
if (firstSeg === 'projects') {
|
|
270
|
+
const parts = rel.split(sep);
|
|
271
|
+
const isMemoryDir = parts.length >= 3 && parts[2] === 'memory';
|
|
272
|
+
if (!isMemoryDir) {
|
|
273
|
+
return { ok: false, reason: 'sensitive-claude-config' };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (last === 'history.jsonl') {
|
|
238
277
|
return { ok: false, reason: 'sensitive-claude-config' };
|
|
239
278
|
}
|
|
240
279
|
}
|
|
@@ -288,6 +327,94 @@ export function isReadAllowed(absPath) {
|
|
|
288
327
|
return { ok: true, real };
|
|
289
328
|
}
|
|
290
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Write-access verdict for the file browser's target mode (the CLAUDE.md / skill
|
|
332
|
+
* folder modal, where the user may browse up to an ancestor of the target). The
|
|
333
|
+
* target file itself may not exist yet (create-file / create-dir / rename
|
|
334
|
+
* destination), so unlike isReadAllowed we cannot realpath it directly. Instead:
|
|
335
|
+
* the parent directory must exist and pass the read policy, and the candidate
|
|
336
|
+
* path (parent's real path + basename) must pass the SAME shared verdict — so a
|
|
337
|
+
* location the user can read but must not overwrite (settings.json, credentials,
|
|
338
|
+
* master.key, backups) is rejected identically in both directions.
|
|
339
|
+
*
|
|
340
|
+
* SECURITY / INTENTIONAL DESIGN (do NOT weaken or remove in review): the write
|
|
341
|
+
* surface intentionally spans the whole navigation root (see
|
|
342
|
+
* lib/target-scope.js resolveTargetWriteScope). This function is the backstop
|
|
343
|
+
* that keeps secrets unwritable inside that wider surface.
|
|
344
|
+
*
|
|
345
|
+
* Two write-only denials layered on top of the read policy:
|
|
346
|
+
* - The ENTIRE LOG_DIR tree (~/.claude/cc-viewer/**): the read policy only
|
|
347
|
+
* blocks specific data files in it, but a write/delete could rmdir the whole
|
|
348
|
+
* dir (wiping the preferences + scheduled-task store), so writes deny the
|
|
349
|
+
* whole subtree.
|
|
350
|
+
* - The config-backup root is already denied for reads (2b) and stays denied.
|
|
351
|
+
*/
|
|
352
|
+
export function isWriteAllowed(absPath) {
|
|
353
|
+
if (typeof absPath !== 'string' || !absPath) return { ok: false, reason: 'invalid' };
|
|
354
|
+
if (absPath.indexOf('\x00') !== -1) return { ok: false, reason: 'null-byte' };
|
|
355
|
+
|
|
356
|
+
// Existing file: realpath it and run the shared verdict directly.
|
|
357
|
+
// New file: realpath the nearest existing parent, then re-evaluate the
|
|
358
|
+
// candidate (parentReal + basename) so symlink/denylist checks apply to the
|
|
359
|
+
// resolved location, not the spelled path.
|
|
360
|
+
let real;
|
|
361
|
+
let existed = true;
|
|
362
|
+
try { real = realpathSync(absPath); } catch { existed = false; }
|
|
363
|
+
if (!existed) {
|
|
364
|
+
// Dangling-symlink guard: realpath failed but the leaf may still be a symlink
|
|
365
|
+
// whose target is missing/outside the allowlist. writeFileSync would FOLLOW it
|
|
366
|
+
// and create/overwrite the link's target outside the navigation root — a
|
|
367
|
+
// containment bypass. lstat does not follow the link, so an isSymbolicLink()
|
|
368
|
+
// hit here refuses the write. Do NOT remove (TOCTOU-class escape).
|
|
369
|
+
try { if (lstatSync(absPath).isSymbolicLink()) return { ok: false, reason: 'symlink' }; } catch { /* truly absent leaf */ }
|
|
370
|
+
let cursor = resolve(absPath, '..');
|
|
371
|
+
const descent = [basename(absPath)];
|
|
372
|
+
let parentReal = null;
|
|
373
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
374
|
+
try { parentReal = realpathSync(cursor); break; } catch {
|
|
375
|
+
const parent = resolve(cursor, '..');
|
|
376
|
+
if (parent === cursor) break;
|
|
377
|
+
descent.unshift(basename(cursor));
|
|
378
|
+
cursor = parent;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (parentReal === null) return { ok: false, reason: 'realpath-failed' };
|
|
382
|
+
// Every ancestor on the way down must be inside the allowlist / not denied;
|
|
383
|
+
// evaluate the nearest existing ancestor, then rebuild the candidate.
|
|
384
|
+
const ancestorVerdict = evaluateResolvedPath(parentReal);
|
|
385
|
+
if (!ancestorVerdict.ok) return ancestorVerdict;
|
|
386
|
+
real = join(parentReal, ...descent);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const verdict = evaluateResolvedPath(real);
|
|
390
|
+
if (!verdict.ok) return verdict;
|
|
391
|
+
|
|
392
|
+
// Write-only: deny the whole LOG_DIR subtree (read policy only blocks named
|
|
393
|
+
// data files in it). Matches both the raw and realpath'd spellings, both the
|
|
394
|
+
// prefix (children) AND the directory itself (bare), so a delete/rename cannot
|
|
395
|
+
// rmdir the whole task/preferences store.
|
|
396
|
+
const sens = _logDirSensitiveFiles();
|
|
397
|
+
const realNorm = norm(real);
|
|
398
|
+
if (realNorm.startsWith(sens.root) || realNorm.startsWith(sens.rootReal)
|
|
399
|
+
|| realNorm === sens.rootBare || realNorm === sens.rootRealBare) {
|
|
400
|
+
return { ok: false, reason: 'sensitive-ccv-data' };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Write-only: deny a sensitive-PREFIX directory itself (~/.ssh, ~/.aws, …), not
|
|
404
|
+
// just its contents. The read policy only matches children (startsWith withSep),
|
|
405
|
+
// so renaming the directory to a benign name would expose the whole prefix's
|
|
406
|
+
// contents. Renaming/deleting the dir root is refused here. (Only meaningful
|
|
407
|
+
// outside the project root, matching the prefix rule's own scope.)
|
|
408
|
+
const projectRoot = getProjectRoot();
|
|
409
|
+
if (!isInsideRoot(real, projectRoot)) {
|
|
410
|
+
for (const prefix of SENSITIVE_PATH_PREFIXES) {
|
|
411
|
+
if (realNorm === norm(prefix)) return { ok: false, reason: 'sensitive-prefix' };
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return { ok: true, real };
|
|
416
|
+
}
|
|
417
|
+
|
|
291
418
|
/**
|
|
292
419
|
* 给 endpoint 用的 reason → HTTP status code 映射。
|
|
293
420
|
* - 路径不存在/realpath 失败 → 404(更友好,不暴露"路径合法但不存在")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Shared OS-integration helpers (open in file manager / terminal) used by both
|
|
2
|
+
// the project-scoped routes (files-fs.js) and the target-mode write routes
|
|
3
|
+
// (target-files-fs.js). Centralizing the platform branches keeps the two call
|
|
4
|
+
// surfaces from drifting apart (a Windows quoting or Linux terminal-fallback fix
|
|
5
|
+
// must apply to both). Pure argv spawning — no shell — so no metachar injection.
|
|
6
|
+
//
|
|
7
|
+
// L1 (lib): must NOT import from routes/ (verify:boundaries).
|
|
8
|
+
import { execFile, spawn } from 'node:child_process';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import { reportSwallowed } from './error-report.js';
|
|
11
|
+
|
|
12
|
+
// Reveal a file (select it) or open a directory in the OS file manager.
|
|
13
|
+
// - macOS: `open -R <path>` selects files AND opens directories in place.
|
|
14
|
+
// - Windows: `explorer /select,"<path>"` (verbatim args; a dir path opens it).
|
|
15
|
+
// - Linux: no select concept — open the path itself when it is a directory,
|
|
16
|
+
// otherwise its parent. (xdg-open on a FILE would launch the default app, so
|
|
17
|
+
// callers pass `isDir` to pick the directory to show.)
|
|
18
|
+
export function revealInOs(fullPath, { isDir = false } = {}) {
|
|
19
|
+
const plat = process.platform;
|
|
20
|
+
if (plat === 'darwin') {
|
|
21
|
+
execFile('open', ['-R', fullPath], () => {});
|
|
22
|
+
} else if (plat === 'win32') {
|
|
23
|
+
// windowsVerbatimArguments: Node would otherwise re-quote the spaced path into
|
|
24
|
+
// a form explorer cannot parse. No cmd.exe is involved, so no metachar risk;
|
|
25
|
+
// Windows filenames cannot contain `"`.
|
|
26
|
+
const child = spawn('explorer.exe', [`/select,"${fullPath}"`], { windowsVerbatimArguments: true, windowsHide: true });
|
|
27
|
+
child.on('error', (err) => { reportSwallowed('osOpen.reveal', err); });
|
|
28
|
+
} else {
|
|
29
|
+
execFile('xdg-open', [isDir ? fullPath : dirname(fullPath)], () => {});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Open an OS terminal with cwd = dir. Linux probes common emulators with piped
|
|
34
|
+
// stdio so the next candidate is tried after ENOENT, falling back to xdg-open.
|
|
35
|
+
export function openTerminalAt(dir) {
|
|
36
|
+
const plat = process.platform;
|
|
37
|
+
if (plat === 'darwin') {
|
|
38
|
+
const child = spawn('open', ['-a', 'Terminal', dir], { stdio: 'ignore', detached: true });
|
|
39
|
+
child.on('error', (err) => { reportSwallowed('osOpen.terminal', err, { terminal: 'Terminal.app' }); });
|
|
40
|
+
child.unref();
|
|
41
|
+
} else if (plat === 'win32') {
|
|
42
|
+
const child = spawn('cmd.exe', ['/c', 'start', 'cmd.exe'], { cwd: dir, stdio: 'ignore', detached: true, windowsHide: true });
|
|
43
|
+
child.on('error', (err) => { reportSwallowed('osOpen.terminal', err, { terminal: 'cmd.exe' }); });
|
|
44
|
+
child.unref();
|
|
45
|
+
} else {
|
|
46
|
+
const terminals = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
|
|
47
|
+
const tryTerminal = (index) => {
|
|
48
|
+
if (index >= terminals.length) {
|
|
49
|
+
const child = spawn('xdg-open', [dir], { stdio: 'ignore', detached: true });
|
|
50
|
+
child.on('error', (err) => { reportSwallowed('osOpen.terminal', err, { terminal: 'xdg-open' }); });
|
|
51
|
+
child.unref();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const term = terminals[index];
|
|
55
|
+
let child;
|
|
56
|
+
try {
|
|
57
|
+
if (term === 'gnome-terminal') child = spawn(term, ['--working-directory=' + dir], { stdio: 'pipe' });
|
|
58
|
+
else if (term === 'konsole') child = spawn(term, ['--workdir', dir], { stdio: 'pipe' });
|
|
59
|
+
else child = spawn(term, [], { cwd: dir, stdio: 'pipe' });
|
|
60
|
+
} catch { tryTerminal(index + 1); return; }
|
|
61
|
+
child.once('error', (err) => { reportSwallowed('osOpen.terminal', err, { terminal: term }); tryTerminal(index + 1); });
|
|
62
|
+
child.once('spawn', () => { for (const s of [child.stdin, child.stdout, child.stderr]) s?.destroy(); child.unref(); });
|
|
63
|
+
};
|
|
64
|
+
tryTerminal(0);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -36,6 +36,12 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
36
36
|
import { registerSdkSessionLister } from './sdk-session-index.js';
|
|
37
37
|
import { readPrefsRaw } from './prefs-store.js';
|
|
38
38
|
import { projectKeyForCwd } from './system-prompt-snapshots.js';
|
|
39
|
+
import {
|
|
40
|
+
computeAutoApprove,
|
|
41
|
+
createAutoApproveState,
|
|
42
|
+
cancelAutoApproveState,
|
|
43
|
+
replayAutoApproveFields,
|
|
44
|
+
} from './auto-approve.js';
|
|
39
45
|
|
|
40
46
|
let _query;
|
|
41
47
|
try {
|
|
@@ -1489,11 +1495,24 @@ function _createSession(key, cwd) {
|
|
|
1489
1495
|
}
|
|
1490
1496
|
} catch (err) { reportSwallowed('sdk-manager.waterfall.plan', err); }
|
|
1491
1497
|
}
|
|
1492
|
-
|
|
1498
|
+
// Server-side auto-approve countdown (armed from request arrival, wall-clock):
|
|
1499
|
+
// the pending frame carries the absolute deadline so clients render the remaining
|
|
1500
|
+
// time instead of arming their own full-length timer; the server resolves the
|
|
1501
|
+
// approval itself at deadline + 1s buffer when no client answered.
|
|
1502
|
+
// Unattended scheduled-task runs (ctx != null → origin 'scheduled-task') are exempt
|
|
1503
|
+
// from the server-side auto-approve fallback — with no human watching, an ephemeral
|
|
1504
|
+
// run must wait for a decision (5-min deny), symmetric to the npm-publish floor below.
|
|
1505
|
+
const planAuto = ctx ? { enabled: false } : computeAutoApprove('plan', _cwd);
|
|
1506
|
+
// Always stamp autoApproveSeconds (0 = server says OFF): the client's local pref
|
|
1507
|
+
// can disagree with the fork-resolved server value, and absence would make the
|
|
1508
|
+
// client fall back to its own countdown and defeat a fork-level off.
|
|
1509
|
+
const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin,
|
|
1510
|
+
autoApproveSeconds: planAuto.enabled ? planAuto.seconds : 0,
|
|
1511
|
+
...(planAuto.enabled ? { autoApproveAt: planAuto.autoApproveAt } : {}) };
|
|
1493
1512
|
if (_broadcastWs) {
|
|
1494
1513
|
_broadcastWs(planPayload);
|
|
1495
1514
|
}
|
|
1496
|
-
const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal, ctx);
|
|
1515
|
+
const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal, ctx, planAuto);
|
|
1497
1516
|
if (result === null) {
|
|
1498
1517
|
return { behavior: 'deny', message: 'Timeout waiting for plan approval' };
|
|
1499
1518
|
}
|
|
@@ -1523,7 +1542,7 @@ function _createSession(key, cwd) {
|
|
|
1523
1542
|
// server/lib/ask/ask-constants.js.
|
|
1524
1543
|
const askTimeoutMs = ASK_TIMEOUT_MS;
|
|
1525
1544
|
const askStartedAt = Date.now();
|
|
1526
|
-
const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId: effSessionId(), ...origin };
|
|
1545
|
+
const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin };
|
|
1527
1546
|
if (_broadcastWs) {
|
|
1528
1547
|
_broadcastWs(askPayload);
|
|
1529
1548
|
}
|
|
@@ -1599,12 +1618,18 @@ function _createSession(key, cwd) {
|
|
|
1599
1618
|
// unknown decision → fall through to normal approval flow
|
|
1600
1619
|
} catch (err) { reportSwallowed('sdk-manager.waterfall.perm', err); }
|
|
1601
1620
|
}
|
|
1602
|
-
|
|
1621
|
+
// npm publish is a hard safety floor: it must always wait for a HUMAN decision
|
|
1622
|
+
// (the 5-minute deny timeout applies), never the server-side auto-approve fallback.
|
|
1623
|
+
// Same exemption for unattended scheduled-task runs (ctx != null).
|
|
1624
|
+
const permAuto = (isPublish || ctx) ? { enabled: false } : computeAutoApprove('perm', _cwd);
|
|
1625
|
+
const permPayload = { type: 'perm-hook-pending', id, toolName, input, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin,
|
|
1626
|
+
autoApproveSeconds: permAuto.enabled ? permAuto.seconds : 0,
|
|
1627
|
+
...(permAuto.enabled ? { autoApproveAt: permAuto.autoApproveAt } : {}) };
|
|
1603
1628
|
if (_broadcastWs) {
|
|
1604
1629
|
_broadcastWs(permPayload);
|
|
1605
1630
|
}
|
|
1606
1631
|
|
|
1607
|
-
const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal, ctx);
|
|
1632
|
+
const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal, ctx, permAuto);
|
|
1608
1633
|
if (result === null) {
|
|
1609
1634
|
return { behavior: 'deny', message: 'Timeout waiting for user approval' };
|
|
1610
1635
|
}
|
|
@@ -1645,30 +1670,52 @@ function _createSession(key, cwd) {
|
|
|
1645
1670
|
} catch (err) { console.warn('[sdk-manager] approval-dismiss broadcast threw:', err?.message); }
|
|
1646
1671
|
}
|
|
1647
1672
|
|
|
1648
|
-
function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null, ctx = null) {
|
|
1673
|
+
function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null, ctx = null, autoMeta = null) {
|
|
1649
1674
|
// Record the id on the ephemeral run's ctx ONLY when it actually parks (the three
|
|
1650
1675
|
// callers below sit after every auto-allow/hard-deny early-return, so an auto-allowed
|
|
1651
1676
|
// tool never lands here) — keeps ctx.approvalIds bounded (review P3: no unbounded growth).
|
|
1652
1677
|
if (ctx && Array.isArray(ctx.approvalIds)) ctx.approvalIds.push(id);
|
|
1653
1678
|
return new Promise((resolve) => {
|
|
1654
1679
|
const timer = setTimeout(() => {
|
|
1680
|
+
const cur = _pendingApprovals.get(id);
|
|
1681
|
+
if (cur?.auto) cancelAutoApproveState(cur.auto);
|
|
1655
1682
|
_pendingApprovals.delete(id);
|
|
1656
1683
|
// Close the modal on every client — a silent deny would leave it hanging forever.
|
|
1657
1684
|
_broadcastApprovalDismiss(kind, id, 'timeout');
|
|
1658
1685
|
resolve(null);
|
|
1659
1686
|
}, timeoutMs);
|
|
1660
|
-
|
|
1687
|
+
const entry = {
|
|
1661
1688
|
kind, // 'ask' | 'plan' | 'perm' — lets cancelApproval distinguish types so an
|
|
1662
1689
|
// ask-cancel does not collide with a plan/perm id
|
|
1663
1690
|
replay, // exact broadcast payload announced to clients — replayed to a fresh WS connection
|
|
1664
1691
|
startedAt: Date.now(),
|
|
1665
1692
|
timeoutMs,
|
|
1693
|
+
// Durable auto-approve cancellation: a client cancel marks this and clears the
|
|
1694
|
+
// server fallback timer, the approval stays pending for a manual decision, and
|
|
1695
|
+
// the flag is replayed to fresh clients so they never re-arm the countdown.
|
|
1696
|
+
autoCancelled: false,
|
|
1697
|
+
auto: null,
|
|
1666
1698
|
resolve: (value) => {
|
|
1667
1699
|
clearTimeout(timer);
|
|
1700
|
+
const cur = _pendingApprovals.get(id);
|
|
1701
|
+
if (cur?.auto) cancelAutoApproveState(cur.auto);
|
|
1668
1702
|
_pendingApprovals.delete(id);
|
|
1669
1703
|
resolve(value);
|
|
1670
1704
|
},
|
|
1671
|
-
}
|
|
1705
|
+
};
|
|
1706
|
+
_pendingApprovals.set(id, entry);
|
|
1707
|
+
// Arm the server-side fallback AFTER the entry is in the map: instant (-1) fires
|
|
1708
|
+
// synchronously and resolves through the same first-wins wrapper.
|
|
1709
|
+
if (autoMeta?.enabled) {
|
|
1710
|
+
entry.auto = createAutoApproveState(autoMeta, () => {
|
|
1711
|
+
const p = _pendingApprovals.get(id);
|
|
1712
|
+
if (!p || p.autoCancelled) return;
|
|
1713
|
+
p.resolve(kind === 'plan' ? { approve: true, feedback: '' } : { decision: 'allow' });
|
|
1714
|
+
try {
|
|
1715
|
+
_broadcastWs?.({ type: kind === 'plan' ? 'sdk-plan-resolved' : 'perm-hook-resolved', id, reason: 'auto-approved', cwd: _cwd, sessionId: _sessionId || null, instanceKey });
|
|
1716
|
+
} catch (err) { reportSwallowed('sdk-manager.auto-approve-broadcast', err); }
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1672
1719
|
// CLI-side abort of the in-flight control request (e.g. interrupt while the modal is
|
|
1673
1720
|
// open): settle through the same first-wins path as a timeout so the promise resolves
|
|
1674
1721
|
// to deny and the modal closes everywhere. { once: true } prevents listener leaks.
|
|
@@ -1711,11 +1758,37 @@ function _createSession(key, cwd) {
|
|
|
1711
1758
|
for (const [id, p] of _pendingApprovals) {
|
|
1712
1759
|
const remaining = (p.timeoutMs ?? 0) - (now - (p.startedAt ?? now));
|
|
1713
1760
|
if (remaining <= 0) continue;
|
|
1714
|
-
out.push({
|
|
1761
|
+
out.push({
|
|
1762
|
+
id, kind: p.kind || null, replay: p.replay || null, remainingMs: remaining,
|
|
1763
|
+
...replayAutoApproveFields(p.auto, p.autoCancelled),
|
|
1764
|
+
});
|
|
1715
1765
|
}
|
|
1716
1766
|
return out;
|
|
1717
1767
|
}
|
|
1718
1768
|
|
|
1769
|
+
/**
|
|
1770
|
+
* Cancel the SERVER-SIDE auto-approve countdown for a pending plan/perm approval
|
|
1771
|
+
* (client clicked the countdown-cancel button). Unlike cancelApproval this does NOT
|
|
1772
|
+
* resolve the approval — it stays pending for a manual decision; only the fallback
|
|
1773
|
+
* timer is destroyed and the cancelled flag is replayed to fresh clients so they
|
|
1774
|
+
* never re-arm. Returns true when the id matched a pending approval of that kind
|
|
1775
|
+
* (even on a repeat cancel — the state change is durable while pending); false when
|
|
1776
|
+
* nothing matched (caller falls back to the legacy relay-only behavior).
|
|
1777
|
+
*/
|
|
1778
|
+
function _autoCancelApproval(id, kind) {
|
|
1779
|
+
// plan/perm only — ask approvals never arm a server auto-approve timer.
|
|
1780
|
+
if (kind !== 'plan' && kind !== 'perm') return false;
|
|
1781
|
+
const pending = _pendingApprovals.get(id);
|
|
1782
|
+
if (!pending || pending.kind !== kind) return false;
|
|
1783
|
+
const first = cancelAutoApproveState(pending);
|
|
1784
|
+
if (first && _broadcastWs) {
|
|
1785
|
+
try {
|
|
1786
|
+
_broadcastWs({ type: kind === 'plan' ? 'sdk-plan-auto-cancel' : 'perm-hook-auto-cancel', id, cwd: _cwd, sessionId: _sessionId || null, instanceKey });
|
|
1787
|
+
} catch (err) { reportSwallowed('sdk-manager.auto-cancel-broadcast', err); }
|
|
1788
|
+
}
|
|
1789
|
+
return true;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1719
1792
|
/**
|
|
1720
1793
|
* Cancel a pending canUseTool approval — used by ask-cancel WS handler
|
|
1721
1794
|
* (user clicked Cancel button or typed-interrupt in input bar).
|
|
@@ -2021,6 +2094,7 @@ function _createSession(key, cwd) {
|
|
|
2021
2094
|
getQueueSnapshot: _getQueueSnapshot,
|
|
2022
2095
|
resolveApproval: _resolveApproval,
|
|
2023
2096
|
cancelApproval: _cancelApproval,
|
|
2097
|
+
autoCancelApproval: _autoCancelApproval,
|
|
2024
2098
|
getPendingApprovals: _getPendingApprovals,
|
|
2025
2099
|
getSdkInitSnapshot: () => _lastInitSnapshot,
|
|
2026
2100
|
getSessionStateSnapshot: () => {
|
|
@@ -2256,6 +2330,19 @@ export function cancelApproval(id, reason) {
|
|
|
2256
2330
|
return false;
|
|
2257
2331
|
}
|
|
2258
2332
|
|
|
2333
|
+
/**
|
|
2334
|
+
* Cancel the server-side auto-approve countdown for a plan/perm approval,
|
|
2335
|
+
* scanning EVERY session (auto-cancel WS handlers). The approval stays pending
|
|
2336
|
+
* for a manual decision. Returns false when no pending approval matched, so the
|
|
2337
|
+
* caller can fall back to the legacy relay-only broadcast.
|
|
2338
|
+
*/
|
|
2339
|
+
export function autoCancelApproval(id, kind) {
|
|
2340
|
+
for (const sess of new Set([..._sessions.values(), ..._ephemeralHosts])) {
|
|
2341
|
+
if (sess.autoCancelApproval(id, kind)) return true;
|
|
2342
|
+
}
|
|
2343
|
+
return false;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2259
2346
|
/**
|
|
2260
2347
|
* Snapshot of pending approvals for WS-reconnect replay (server.js connection
|
|
2261
2348
|
* handler). WITHOUT an anchor: aggregated across ALL sessions; WITH an anchor:
|