ccdesk 0.2.2 → 0.2.4
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/CHANGELOG.md +46 -0
- package/README.md +7 -4
- package/dist/core/agent-edit.d.ts +6 -0
- package/dist/{server → core}/agent-edit.js +15 -21
- package/dist/core/backups.d.ts +19 -0
- package/dist/{server → core}/backups.js +64 -48
- package/dist/core/core.d.ts +96 -0
- package/dist/core/core.js +57 -0
- package/dist/core/index.d.ts +22 -0
- package/dist/core/index.js +38 -0
- package/dist/core/mcp-edit.d.ts +23 -0
- package/dist/{server → core}/mcp-edit.js +121 -100
- package/dist/core/mutations.d.ts +20 -0
- package/dist/{server → core}/mutations.js +8 -15
- package/dist/core/open.d.ts +28 -0
- package/dist/{server → core}/open.js +38 -13
- package/dist/core/parse-edit.d.ts +12 -0
- package/dist/core/parse-edit.js +64 -0
- package/dist/core/reference.d.ts +3 -0
- package/dist/core/reference.js +11 -0
- package/dist/core/scan/compare.d.ts +7 -0
- package/dist/{server → core}/scan/compare.js +2 -2
- package/dist/core/scan/context.d.ts +13 -0
- package/dist/{server → core}/scan/context.js +55 -32
- package/dist/core/scan/data.d.ts +14 -0
- package/dist/core/scan/data.js +26 -0
- package/dist/core/scan/env.d.ts +28 -0
- package/dist/{server → core}/scan/env.js +15 -4
- package/dist/core/scan/files.d.ts +68 -0
- package/dist/core/scan/files.js +270 -0
- package/dist/core/scan/git.d.ts +20 -0
- package/dist/core/scan/git.js +106 -0
- package/dist/core/scan/index.d.ts +5 -0
- package/dist/{server → core}/scan/index.js +119 -82
- package/dist/core/scan/instructions.d.ts +54 -0
- package/dist/{server → core}/scan/instructions.js +141 -73
- package/dist/core/scan/inventory.d.ts +38 -0
- package/dist/{server → core}/scan/inventory.js +134 -115
- package/dist/core/scan/mcp.d.ts +28 -0
- package/dist/{server → core}/scan/mcp.js +153 -95
- package/dist/core/scan/memory.d.ts +8 -0
- package/dist/{server → core}/scan/memory.js +8 -8
- package/dist/core/scan/path-test.d.ts +9 -0
- package/dist/core/scan/path-test.js +156 -0
- package/dist/core/scan/permissions.d.ts +31 -0
- package/dist/{server → core}/scan/permissions.js +5 -8
- package/dist/core/scan/plugins.d.ts +39 -0
- package/dist/{server → core}/scan/plugins.js +36 -27
- package/dist/core/scan/projects.d.ts +9 -0
- package/dist/{server → core}/scan/projects.js +7 -5
- package/dist/core/scan/sandbox.d.ts +14 -0
- package/dist/{server → core}/scan/sandbox.js +87 -59
- package/dist/core/scan/settings.d.ts +37 -0
- package/dist/{server → core}/scan/settings.js +98 -93
- package/dist/core/scan/tools.d.ts +10 -0
- package/dist/{server → core}/scan/tools.js +28 -20
- package/dist/core/scan/usage.d.ts +19 -0
- package/dist/{server → core}/scan/usage.js +122 -97
- package/dist/core/schema.d.ts +8 -0
- package/dist/core/schema.js +67 -0
- package/dist/core/secrets.d.ts +45 -0
- package/dist/core/secrets.js +207 -0
- package/dist/core/settings-edit.d.ts +9 -0
- package/dist/{server → core}/settings-edit.js +91 -112
- package/dist/core/watch.d.ts +45 -0
- package/dist/{server → core}/watch.js +54 -17
- package/dist/core/write.d.ts +117 -0
- package/dist/{server → core}/write.js +61 -8
- package/dist/server/args.d.ts +32 -0
- package/dist/server/args.js +106 -0
- package/dist/server/cli.d.ts +2 -0
- package/dist/server/cli.js +58 -83
- package/dist/server/commands.d.ts +8 -0
- package/dist/server/commands.js +39 -0
- package/dist/server/server.d.ts +12 -0
- package/dist/server/server.js +80 -195
- package/dist/server/start.d.ts +13 -0
- package/dist/server/start.js +21 -0
- package/dist/shared/compare.d.ts +25 -0
- package/dist/shared/memory.d.ts +6 -0
- package/dist/shared/memory.js +18 -2
- package/dist/shared/requests.d.ts +104 -0
- package/dist/shared/requests.js +73 -0
- package/dist/shared/scopes.d.ts +10 -0
- package/dist/shared/settings-catalog.json +2199 -0
- package/dist/shared/types.d.ts +535 -0
- package/dist/shared/types.js +11 -0
- package/dist/ui/assets/index-C9qqEI5Z.js +60 -0
- package/dist/ui/index.html +1 -1
- package/dist/view/diff.d.ts +16 -0
- package/dist/view/diff.js +85 -0
- package/dist/view/format.d.ts +17 -0
- package/dist/view/format.js +47 -0
- package/dist/view/index.d.ts +11 -0
- package/dist/view/index.js +13 -0
- package/dist/view/lines.d.ts +9 -0
- package/dist/view/lines.js +12 -0
- package/dist/view/path-test.d.ts +3 -0
- package/dist/view/path-test.js +7 -0
- package/dist/view/sections/catalog.d.ts +9 -0
- package/dist/view/sections/catalog.js +47 -0
- package/dist/view/sections/extensions.d.ts +1 -0
- package/dist/view/sections/extensions.js +48 -0
- package/dist/view/sections/hooks.d.ts +1 -0
- package/dist/view/sections/hooks.js +32 -0
- package/dist/view/sections/index.d.ts +6 -0
- package/dist/view/sections/index.js +27 -0
- package/dist/view/sections/instructions.d.ts +6 -0
- package/dist/view/sections/instructions.js +38 -0
- package/dist/view/sections/mcp.d.ts +3 -0
- package/dist/view/sections/mcp.js +38 -0
- package/dist/view/sections/memory.d.ts +1 -0
- package/dist/view/sections/memory.js +31 -0
- package/dist/view/sections/permissions.d.ts +17 -0
- package/dist/view/sections/permissions.js +87 -0
- package/dist/view/sections/settings.d.ts +1 -0
- package/dist/view/sections/settings.js +33 -0
- package/dist/view/sections/tools.d.ts +6 -0
- package/dist/view/sections/tools.js +57 -0
- package/dist/view/sections/types.d.ts +123 -0
- package/dist/view/sections/types.js +10 -0
- package/dist/view/settings.d.ts +12 -0
- package/dist/view/settings.js +31 -0
- package/dist/view/usage.d.ts +8 -0
- package/dist/view/usage.js +18 -0
- package/package.json +13 -2
- package/dist/server/scan/files.js +0 -480
- package/dist/server/scan/path-test.js +0 -136
- package/dist/server/schema.js +0 -15
- package/dist/server/secrets.js +0 -63
- package/dist/ui/assets/index-Dg9dxngR.js +0 -60
|
@@ -1,73 +1,79 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { join, resolve, sep } from "node:path";
|
|
3
3
|
import { storeDirName } from "../../shared/memory.js";
|
|
4
4
|
import {} from "../../shared/types.js";
|
|
5
5
|
import {} from "./env.js";
|
|
6
|
-
import { isCredentials, isDir,
|
|
6
|
+
import { isCredentials, isDir, isWithin, listDir, openUnlessCredentials, wildcard, } from "./files.js";
|
|
7
|
+
import { isRecord } from "./data.js";
|
|
8
|
+
import { worktrees } from "./git.js";
|
|
7
9
|
import { namesTool, parseRule } from "./tools.js";
|
|
8
|
-
/**
|
|
9
|
-
* Transcripts already counted, by path, so reopening a directory reads only what was added. Kept
|
|
10
|
-
* in memory only, least recently counted first; once they hold more than MAX_CACHED_CALLS calls,
|
|
11
|
-
* the oldest are dropped and read again when needed.
|
|
12
|
-
*/
|
|
13
|
-
const transcripts = new Map();
|
|
14
10
|
/** Roughly how many calls the cache keeps, beyond those of the directory counted last. */
|
|
15
|
-
|
|
11
|
+
const MAX_CACHED_CALLS = 100_000;
|
|
16
12
|
/** A transcript is read in parts this size, so one huge file never becomes one huge string. */
|
|
17
13
|
const CHUNK = 8 * 1024 * 1024;
|
|
18
|
-
let queue = Promise.resolve();
|
|
19
|
-
/** Requests waiting their turn, so a burst of the same request runs once. */
|
|
20
|
-
const waiting = new Map();
|
|
21
14
|
/** How long one count may take before it stops; what it read by then stays counted. */
|
|
22
15
|
const TIME_LIMIT_MS = 60_000;
|
|
23
16
|
/** A count that stopped before it finished, because it took too long or nobody wanted it. */
|
|
24
17
|
export class CountStopped extends Error {
|
|
25
18
|
}
|
|
26
19
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* Subagents are included. It also counts the calls you approved or refused by hand, which Claude
|
|
30
|
-
* Code records on each call's result as `permissionDecision` (not documented; older transcripts
|
|
31
|
-
* lack it), and how many calls each rule covers. Requests run one at a time, since they share the
|
|
32
|
-
* cache. A count stops when every `signal` it was asked with aborts, or after `timeLimitMs`: it
|
|
33
|
-
* rejects with CountStopped and the next one starts, even if a read of the stopped count never
|
|
34
|
-
* returns.
|
|
20
|
+
* A counter with its own cache of transcripts and its own queue: counts made through it run one at
|
|
21
|
+
* a time and reuse what earlier ones read. Each core has one.
|
|
35
22
|
*/
|
|
36
|
-
export function
|
|
37
|
-
const
|
|
38
|
-
let
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
23
|
+
export function usageCounter() {
|
|
24
|
+
const transcripts = new Map();
|
|
25
|
+
let queue = Promise.resolve();
|
|
26
|
+
/** Requests waiting their turn, so a burst of the same request runs once. */
|
|
27
|
+
const waiting = new Map();
|
|
28
|
+
/**
|
|
29
|
+
* Counts the tool calls in the sessions started in `dir`, or anywhere in its repository and its
|
|
30
|
+
* worktrees, since rules at a repository's root apply there too (docs: permissions#permission-system).
|
|
31
|
+
* Subagents are included. It also counts the calls you approved or refused by hand, which Claude
|
|
32
|
+
* Code records on each call's result as `permissionDecision` (not documented; older transcripts
|
|
33
|
+
* lack it), and how many calls each rule covers. Requests run one at a time, since they share the
|
|
34
|
+
* cache. A count stops when every `signal` it was asked with aborts, or after `timeLimitMs`: it
|
|
35
|
+
* rejects with CountStopped and the next one starts, even if a read of the stopped count never
|
|
36
|
+
* returns.
|
|
37
|
+
*/
|
|
38
|
+
return function countUsage(env, dir, rules, { signal, timeLimitMs = TIME_LIMIT_MS, maxCachedCalls = MAX_CACHED_CALLS, } = {}) {
|
|
39
|
+
const key = JSON.stringify([env.claudeDir, resolve(dir), rules, timeLimitMs, maxCachedCalls]);
|
|
40
|
+
let run = waiting.get(key);
|
|
41
|
+
// Nobody would wait for a new count, so none starts to hold up the queue.
|
|
42
|
+
if (!run && signal?.aborted)
|
|
43
|
+
return Promise.reject(stoppedBecause(signal));
|
|
44
|
+
if (!run) {
|
|
45
|
+
const stop = new AbortController();
|
|
46
|
+
const promise = queue.then(() => {
|
|
47
|
+
waiting.delete(key);
|
|
48
|
+
const during = AbortSignal.any([stop.signal, AbortSignal.timeout(timeLimitMs)]);
|
|
49
|
+
// A stopped count answers right away, even while one of its reads never returns.
|
|
50
|
+
const stopped = new Promise((_, fail) => {
|
|
51
|
+
const onStop = () => fail(stoppedBecause(during));
|
|
52
|
+
if (during.aborted)
|
|
53
|
+
onStop();
|
|
54
|
+
else
|
|
55
|
+
during.addEventListener("abort", onStop, { once: true });
|
|
56
|
+
});
|
|
57
|
+
return Promise.race([
|
|
58
|
+
count(transcripts, env, resolve(dir), rules, during, maxCachedCalls),
|
|
59
|
+
stopped,
|
|
60
|
+
]);
|
|
54
61
|
});
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
run = { promise, stop, waiters: 0 };
|
|
63
|
+
waiting.set(key, run);
|
|
64
|
+
// The queue moves on when the count settles or stops: a stopped count changes nothing more.
|
|
65
|
+
queue = promise.catch(() => undefined);
|
|
66
|
+
}
|
|
67
|
+
const current = run;
|
|
68
|
+
if (signal?.aborted)
|
|
69
|
+
return current.promise;
|
|
70
|
+
current.waiters++;
|
|
71
|
+
signal?.addEventListener("abort", () => {
|
|
72
|
+
if (--current.waiters === 0)
|
|
73
|
+
current.stop.abort();
|
|
74
|
+
}, { once: true });
|
|
64
75
|
return current.promise;
|
|
65
|
-
|
|
66
|
-
signal?.addEventListener("abort", () => {
|
|
67
|
-
if (--current.waiters === 0)
|
|
68
|
-
current.stop.abort();
|
|
69
|
-
}, { once: true });
|
|
70
|
-
return current.promise;
|
|
76
|
+
};
|
|
71
77
|
}
|
|
72
78
|
function stoppedBecause(signal) {
|
|
73
79
|
return new CountStopped(signal.reason instanceof DOMException && signal.reason.name === "TimeoutError"
|
|
@@ -79,7 +85,7 @@ function checkStopped(signal) {
|
|
|
79
85
|
if (signal.aborted)
|
|
80
86
|
throw stoppedBecause(signal);
|
|
81
87
|
}
|
|
82
|
-
async function count(env, dir, rules, signal, maxCachedCalls) {
|
|
88
|
+
async function count(transcripts, env, dir, rules, signal, maxCachedCalls) {
|
|
83
89
|
checkStopped(signal);
|
|
84
90
|
const checkouts = await worktrees(dir);
|
|
85
91
|
// $HOME may end in a slash or be a link, while git prints the resolved path.
|
|
@@ -93,10 +99,7 @@ async function count(env, dir, rules, signal, maxCachedCalls) {
|
|
|
93
99
|
const folders = repository ? checkouts : [dir];
|
|
94
100
|
const projects = join(env.claudeDir, "projects");
|
|
95
101
|
const storeNames = await listDir(projects);
|
|
96
|
-
const
|
|
97
|
-
const calls = [];
|
|
98
|
-
const counted = new Set();
|
|
99
|
-
let sessions = 0;
|
|
102
|
+
const tally = { taken: new Set(), calls: [], sessions: 0 };
|
|
100
103
|
for (const folder of folders) {
|
|
101
104
|
const own = storeDirName(folder);
|
|
102
105
|
// Sessions started in a subfolder or a nested worktree have their own store, named with the
|
|
@@ -107,27 +110,15 @@ async function count(env, dir, rules, signal, maxCachedCalls) {
|
|
|
107
110
|
for (const name of stores) {
|
|
108
111
|
const store = join(projects, name);
|
|
109
112
|
const files = await transcriptsIn(store);
|
|
110
|
-
|
|
111
|
-
if (taken.has(file))
|
|
112
|
-
continue;
|
|
113
|
-
// A subagent's session decides; one whose session file is gone goes by its own start.
|
|
114
|
-
const start = (await transcript(env, session, signal))?.startCwd ??
|
|
115
|
-
(await transcript(env, file, signal))?.startCwd;
|
|
116
|
-
if (start ? !isWithin(folder, start) : name !== own)
|
|
117
|
-
continue;
|
|
118
|
-
taken.add(file);
|
|
119
|
-
counted.add(file);
|
|
120
|
-
calls.push(...((await transcript(env, file, signal))?.calls ?? []));
|
|
121
|
-
if (file === session)
|
|
122
|
-
sessions++;
|
|
123
|
-
}
|
|
113
|
+
await countStore(transcripts, env, signal, tally, folder, files, name === own);
|
|
124
114
|
checkStopped(signal);
|
|
125
115
|
for (const path of transcripts.keys())
|
|
126
116
|
if (path.startsWith(store + sep) && !files.has(path))
|
|
127
117
|
transcripts.delete(path);
|
|
128
118
|
}
|
|
129
119
|
}
|
|
130
|
-
|
|
120
|
+
const { calls, sessions } = tally;
|
|
121
|
+
trimCache(transcripts, tally.taken, maxCachedCalls);
|
|
131
122
|
return {
|
|
132
123
|
sessions,
|
|
133
124
|
repository,
|
|
@@ -137,8 +128,27 @@ async function count(env, dir, rules, signal, maxCachedCalls) {
|
|
|
137
128
|
matches: Object.fromEntries(rules.map((r) => [r.rule, matchCount(r, calls)])),
|
|
138
129
|
};
|
|
139
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Adds the transcripts in one store whose session started in `folder`. A subagent's session
|
|
133
|
+
* decides; one whose session file is gone goes by its own start, or, without one, by whether the
|
|
134
|
+
* store is the folder's own.
|
|
135
|
+
*/
|
|
136
|
+
async function countStore(transcripts, env, signal, tally, folder, files, ownStore) {
|
|
137
|
+
for (const [file, session] of files) {
|
|
138
|
+
if (tally.taken.has(file))
|
|
139
|
+
continue;
|
|
140
|
+
const start = (await transcript(transcripts, env, session, signal))?.startCwd ??
|
|
141
|
+
(await transcript(transcripts, env, file, signal))?.startCwd;
|
|
142
|
+
if (start ? !isWithin(folder, start) : !ownStore)
|
|
143
|
+
continue;
|
|
144
|
+
tally.taken.add(file);
|
|
145
|
+
tally.calls.push(...((await transcript(transcripts, env, file, signal))?.calls ?? []));
|
|
146
|
+
if (file === session)
|
|
147
|
+
tally.sessions++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
140
150
|
/** Drops the least recently counted transcripts over the limit, keeping those just counted. */
|
|
141
|
-
function trimCache(counted, maxCalls) {
|
|
151
|
+
function trimCache(transcripts, counted, maxCalls) {
|
|
142
152
|
let total = 0;
|
|
143
153
|
for (const known of transcripts.values())
|
|
144
154
|
total += known.calls.length;
|
|
@@ -166,7 +176,7 @@ async function transcriptsIn(store) {
|
|
|
166
176
|
* One transcript, read up to its end; one that cannot be read keeps what was read before, and one
|
|
167
177
|
* whose count stops keeps the whole lines read so far.
|
|
168
178
|
*/
|
|
169
|
-
async function transcript(env, file, signal) {
|
|
179
|
+
async function transcript(transcripts, env, file, signal) {
|
|
170
180
|
if (await isCredentials(env, file))
|
|
171
181
|
return null;
|
|
172
182
|
const info = await stat(file).catch(() => null);
|
|
@@ -182,7 +192,7 @@ async function transcript(env, file, signal) {
|
|
|
182
192
|
if (known.size === info.size && known.mtimeMs === info.mtimeMs)
|
|
183
193
|
return known;
|
|
184
194
|
try {
|
|
185
|
-
await readFrom(file, known, info.size, signal);
|
|
195
|
+
await readFrom(env, file, known, info.size, signal);
|
|
186
196
|
known.size = info.size;
|
|
187
197
|
known.mtimeMs = info.mtimeMs;
|
|
188
198
|
}
|
|
@@ -194,8 +204,11 @@ async function transcript(env, file, signal) {
|
|
|
194
204
|
return known;
|
|
195
205
|
}
|
|
196
206
|
/** Reads whole lines from the offset up to `size`; a line still being written is read next time. */
|
|
197
|
-
async function readFrom(file, known, size, signal) {
|
|
198
|
-
|
|
207
|
+
async function readFrom(env, file, known, size, signal) {
|
|
208
|
+
// Checked again on the open file, so one swapped for the credentials file after transcript()'s check is not read.
|
|
209
|
+
const handle = await openUnlessCredentials(env, file);
|
|
210
|
+
if (!handle)
|
|
211
|
+
return;
|
|
199
212
|
try {
|
|
200
213
|
let carried = Buffer.alloc(0);
|
|
201
214
|
let position = known.offset;
|
|
@@ -220,10 +233,10 @@ async function readFrom(file, known, size, signal) {
|
|
|
220
233
|
}
|
|
221
234
|
}
|
|
222
235
|
/** Takes the tool calls from an assistant line and your answers from a tool result line. */
|
|
223
|
-
function read(line,
|
|
236
|
+
function read(line, into) {
|
|
224
237
|
const hasCall = line.includes('"tool_use"');
|
|
225
238
|
const hasDecision = line.includes('"permissionDecision"');
|
|
226
|
-
const hasStart = !
|
|
239
|
+
const hasStart = !into.startCwd && line.includes('"cwd"');
|
|
227
240
|
if (!hasCall && !hasDecision && !hasStart)
|
|
228
241
|
return;
|
|
229
242
|
let record;
|
|
@@ -235,35 +248,42 @@ function read(line, transcript) {
|
|
|
235
248
|
}
|
|
236
249
|
if (!isRecord(record))
|
|
237
250
|
return;
|
|
238
|
-
if (!
|
|
239
|
-
|
|
251
|
+
if (!into.startCwd && typeof record.cwd === "string")
|
|
252
|
+
into.startCwd = record.cwd;
|
|
240
253
|
if (!isRecord(record.message))
|
|
241
254
|
return;
|
|
242
255
|
const content = record.message.content;
|
|
243
256
|
if (!Array.isArray(content))
|
|
244
257
|
return;
|
|
258
|
+
if (record.type === "assistant")
|
|
259
|
+
readCalls(record, content, into);
|
|
260
|
+
else if (record.type === "user")
|
|
261
|
+
readDecision(record, content, into);
|
|
262
|
+
}
|
|
263
|
+
/** The tool calls in an assistant line. */
|
|
264
|
+
function readCalls(record, content, into) {
|
|
245
265
|
const at = typeof record.timestamp === "string" ? record.timestamp : "";
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
transcript.byId.set(block.id, call);
|
|
255
|
-
}
|
|
256
|
-
return;
|
|
266
|
+
for (const block of content) {
|
|
267
|
+
if (!isRecord(block) || block.type !== "tool_use")
|
|
268
|
+
continue;
|
|
269
|
+
if (typeof block.id !== "string" || typeof block.name !== "string")
|
|
270
|
+
continue;
|
|
271
|
+
const call = callOf(block.name, isRecord(block.input) ? block.input : {}, at);
|
|
272
|
+
into.calls.push(call);
|
|
273
|
+
into.byId.set(block.id, call);
|
|
257
274
|
}
|
|
275
|
+
}
|
|
276
|
+
/** Your answer to the calls a tool result line answers, when you gave it by hand. */
|
|
277
|
+
function readDecision(record, content, into) {
|
|
258
278
|
const decision = record.permissionDecision;
|
|
259
|
-
if (
|
|
279
|
+
if (!isRecord(decision))
|
|
260
280
|
return;
|
|
261
281
|
if (typeof decision.source !== "string" || !decision.source.startsWith("user"))
|
|
262
282
|
return;
|
|
263
283
|
for (const block of content) {
|
|
264
284
|
if (!isRecord(block) || block.type !== "tool_result")
|
|
265
285
|
continue;
|
|
266
|
-
const call = typeof block.tool_use_id === "string" &&
|
|
286
|
+
const call = typeof block.tool_use_id === "string" && into.byId.get(block.tool_use_id);
|
|
267
287
|
if (call)
|
|
268
288
|
call.byYou = decision.decision === "accept";
|
|
269
289
|
}
|
|
@@ -312,8 +332,13 @@ function approvals(calls, rules) {
|
|
|
312
332
|
for (const c of calls) {
|
|
313
333
|
if (c.byYou !== true)
|
|
314
334
|
continue;
|
|
315
|
-
if (c.rule)
|
|
316
|
-
|
|
335
|
+
if (!c.rule)
|
|
336
|
+
continue;
|
|
337
|
+
const group = groups.get(c.rule);
|
|
338
|
+
if (group)
|
|
339
|
+
group.push(c);
|
|
340
|
+
else
|
|
341
|
+
groups.set(c.rule, [c]);
|
|
317
342
|
}
|
|
318
343
|
const refused = calls.filter((c) => c.byYou === false);
|
|
319
344
|
const out = [];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Validator } from "typebox/compile";
|
|
2
|
+
/**
|
|
3
|
+
* What `validator` found wrong with `value`, each led by where: `label` and the path inside it,
|
|
4
|
+
* such as `sandbox.network`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function schemaErrors(validator: Validator, value: unknown, label: string): string[];
|
|
7
|
+
/** The first thing wrong with a request body, such as "add.list must be string". */
|
|
8
|
+
export declare function firstError(validator: Validator, value: unknown): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {} from "typebox/error";
|
|
2
|
+
import { Settings } from "typebox/system";
|
|
3
|
+
import {} from "typebox/compile";
|
|
4
|
+
/**
|
|
5
|
+
* What `validator` found wrong with `value`, each led by where: `label` and the path inside it,
|
|
6
|
+
* such as `sandbox.network`.
|
|
7
|
+
*/
|
|
8
|
+
export function schemaErrors(validator, value, label) {
|
|
9
|
+
if (validator.Check(value))
|
|
10
|
+
return [];
|
|
11
|
+
return allErrors(validator, value).flatMap((e) => messages(e).map(([path, message]) => {
|
|
12
|
+
const where = [label, ...path.split("/")].filter(Boolean).join(".");
|
|
13
|
+
return where ? `${where} ${message}` : message;
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
/** The first thing wrong with a request body, such as "add.list must be string". */
|
|
17
|
+
export function firstError(validator, value) {
|
|
18
|
+
return schemaErrors(validator, value, "")[0] ?? "the request is not well formed";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Every error, not TypeBox's first eight: a wrong hook lists what each kind of hook needs. Each
|
|
22
|
+
* value checked is one setting, server or request body, so the list stays short. The limit is
|
|
23
|
+
* TypeBox's process-wide setting, so it is put back for anything else in the process using it.
|
|
24
|
+
*/
|
|
25
|
+
function allErrors(validator, value) {
|
|
26
|
+
const { maxErrors } = Settings.Get();
|
|
27
|
+
Settings.Set({ maxErrors: Number.POSITIVE_INFINITY });
|
|
28
|
+
try {
|
|
29
|
+
return validator.Errors(value);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
Settings.Set({ maxErrors });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An error as where it is and what is wrong, worded as ccdesk has always shown them: one missing
|
|
37
|
+
* property at a time, and one "must NOT have additional properties" for each property a closed
|
|
38
|
+
* object does not allow.
|
|
39
|
+
*/
|
|
40
|
+
function messages(e) {
|
|
41
|
+
switch (e.keyword) {
|
|
42
|
+
case "required":
|
|
43
|
+
return e.params.requiredProperties.map((p) => [
|
|
44
|
+
e.instancePath,
|
|
45
|
+
`must have required property '${p}'`,
|
|
46
|
+
]);
|
|
47
|
+
// Said once for each extra property, through its "schema is false" error below; a property
|
|
48
|
+
// that fails an additionalProperties schema has its own error already.
|
|
49
|
+
case "additionalProperties":
|
|
50
|
+
return [];
|
|
51
|
+
case "boolean":
|
|
52
|
+
if (!e.schemaPath.endsWith("/additionalProperties"))
|
|
53
|
+
return [[e.instancePath, e.message]];
|
|
54
|
+
return [
|
|
55
|
+
[
|
|
56
|
+
e.instancePath.slice(0, e.instancePath.lastIndexOf("/")),
|
|
57
|
+
"must NOT have additional properties",
|
|
58
|
+
],
|
|
59
|
+
];
|
|
60
|
+
case "enum":
|
|
61
|
+
return [[e.instancePath, `${e.message}: ${e.params.allowedValues.join(", ")}`]];
|
|
62
|
+
case "minItems":
|
|
63
|
+
return [[e.instancePath, `must NOT have fewer than ${e.params.limit} items`]];
|
|
64
|
+
default:
|
|
65
|
+
return [[e.instancePath, e.message]];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A server URL as ccdesk shows it: a user name, password or query string can carry a key, so they
|
|
3
|
+
* are hidden, as Claude Code hides the URL in its own status output (docs: mcp).
|
|
4
|
+
*/
|
|
5
|
+
export declare function shownUrl(url: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Replace values that commonly hold secrets with a placeholder, keeping the keys: env and headers
|
|
8
|
+
* values, the user, password and query of a `url`, and secret values in the `args` of a command.
|
|
9
|
+
*/
|
|
10
|
+
export declare function redactSecrets(value: Record<string, unknown>): Record<string, unknown>;
|
|
11
|
+
export declare function redactSecrets(value: unknown): unknown;
|
|
12
|
+
/**
|
|
13
|
+
* A JSON parse failure without the parser's message, which quotes the text around the fault and
|
|
14
|
+
* so can quote a secret.
|
|
15
|
+
*/
|
|
16
|
+
export declare function jsonSyntaxError(error: unknown): string;
|
|
17
|
+
/**
|
|
18
|
+
* The text ccdesk shows for a file reached as an instruction, import, memory, agent or skill. A
|
|
19
|
+
* settings or MCP file read this way, whatever it is named, would otherwise show its env and
|
|
20
|
+
* headers values, so anything that parses as JSON is shown parsed with them hidden. Frontmatter can
|
|
21
|
+
* hold the same values, in a subagent's inline MCP servers (docs: sub-agents#scope-mcp-servers-to-a-subagent),
|
|
22
|
+
* so frontmatter that holds any is shown re-written with them hidden.
|
|
23
|
+
*/
|
|
24
|
+
export declare function shownText(text: string | null): string | null;
|
|
25
|
+
/** A file named .json that does not parse is not shown: its text could still hold a secret. */
|
|
26
|
+
export declare function shownJsonFile(text: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Helpers for the values ccdesk hides. What is hidden is decided only by redactSecrets; these
|
|
29
|
+
* compare a value with its redacted form.
|
|
30
|
+
*/
|
|
31
|
+
/** A value as ccdesk shows it: JSON with hidden values as •••. */
|
|
32
|
+
export declare function shown(value: unknown): string;
|
|
33
|
+
export declare function hasHiddenValues(value: unknown): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* A fingerprint of the hidden values in a JSON file, so a restore can tell whether they changed.
|
|
36
|
+
* Null for a file that is not valid JSON.
|
|
37
|
+
*/
|
|
38
|
+
export declare function hiddenValues(text: string | null): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* The secret text in a value, to take out of a message that may quote it: an env or header value
|
|
41
|
+
* or a hidden argument whole, the hidden end of one whose start is shown (`--token=value`,
|
|
42
|
+
* `Authorization: Bearer value`), and a URL's user name, password and query. Longest first, so a
|
|
43
|
+
* secret that contains another is taken out whole.
|
|
44
|
+
*/
|
|
45
|
+
export declare function secretTexts(value: unknown): string[];
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
2
|
+
import { FRONTMATTER, isRecord, parseJson } from "./scan/data.js";
|
|
3
|
+
/**
|
|
4
|
+
* A server URL as ccdesk shows it: a user name, password or query string can carry a key, so they
|
|
5
|
+
* are hidden, as Claude Code hides the URL in its own status output (docs: mcp).
|
|
6
|
+
*/
|
|
7
|
+
export function shownUrl(url) {
|
|
8
|
+
try {
|
|
9
|
+
const u = new URL(url);
|
|
10
|
+
if (!u.username && !u.password && !u.search)
|
|
11
|
+
return url;
|
|
12
|
+
if (u.username || u.password) {
|
|
13
|
+
u.username = "•••";
|
|
14
|
+
u.password = "";
|
|
15
|
+
}
|
|
16
|
+
if (u.search)
|
|
17
|
+
u.search = "?•••";
|
|
18
|
+
return decodeURI(u.toString());
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return url.replace(/\/\/[^/@]*@/, "//•••@").replace(/\?.*$/, "?•••");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** A flag whose value is a secret, such as `--token`, `--api-key`, `--client-secret` or `--auth-token`. */
|
|
25
|
+
const SECRET_FLAG = /^--([\w-]*[-_])?(token|api[-_]?key|apikey|password|secret|auth\w*|key)$/i;
|
|
26
|
+
/** A flag whose value is an HTTP header, as mcp-remote takes `--header "Authorization: Bearer …"`. */
|
|
27
|
+
const HEADER_FLAG = /^(--header|-H)$/;
|
|
28
|
+
/** An argument that is itself a credential header or an Authorization value. */
|
|
29
|
+
const SECRET_HEADER = /^([\w-]*(authorization|api-key|token|cookie)\s*:|(bearer|basic)\s+\S)/i;
|
|
30
|
+
/** A value that starts like a common API key or token. */
|
|
31
|
+
const KEY_PREFIX = /^(sk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[bp]-|glpat-|AKIA[0-9A-Z]{16})/;
|
|
32
|
+
/** A header as ccdesk shows it: the name and the `Bearer` or `Basic` scheme stay, the rest is hidden. */
|
|
33
|
+
function shownHeader(header) {
|
|
34
|
+
const kept = /^([^:]*:\s*)?((bearer|basic)\s+)?/i.exec(header)?.[0] ?? "";
|
|
35
|
+
return `${kept}•••`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A server's command-line arguments as ccdesk shows them: the value after a secret flag, given as
|
|
39
|
+
* `--token value` or `--token=value`, a header's value after `--header` or `-H`, a credential
|
|
40
|
+
* header or Authorization value on its own, a value that starts like a known key, and the
|
|
41
|
+
* credentials in a URL are hidden.
|
|
42
|
+
*/
|
|
43
|
+
function shownArgs(args) {
|
|
44
|
+
return args.map((arg, i) => (typeof arg === "string" ? shownArg(arg, args[i - 1]) : arg));
|
|
45
|
+
}
|
|
46
|
+
/** One argument as ccdesk shows it, given the one before it. */
|
|
47
|
+
function shownArg(arg, previous) {
|
|
48
|
+
if (typeof previous === "string" && !arg.startsWith("-")) {
|
|
49
|
+
if (SECRET_FLAG.test(previous))
|
|
50
|
+
return "•••";
|
|
51
|
+
if (HEADER_FLAG.test(previous))
|
|
52
|
+
return shownHeader(arg);
|
|
53
|
+
}
|
|
54
|
+
const equals = arg.indexOf("=");
|
|
55
|
+
if (equals > 0) {
|
|
56
|
+
const name = arg.slice(0, equals);
|
|
57
|
+
const value = arg.slice(equals + 1);
|
|
58
|
+
if (HEADER_FLAG.test(name))
|
|
59
|
+
return `${name}=${shownHeader(value)}`;
|
|
60
|
+
if (SECRET_FLAG.test(name) || KEY_PREFIX.test(value))
|
|
61
|
+
return `${name}=•••`;
|
|
62
|
+
}
|
|
63
|
+
if (SECRET_HEADER.test(arg))
|
|
64
|
+
return shownHeader(arg);
|
|
65
|
+
if (KEY_PREFIX.test(arg))
|
|
66
|
+
return "•••";
|
|
67
|
+
if (/^https?:\/\//i.test(arg))
|
|
68
|
+
return shownUrl(arg);
|
|
69
|
+
return arg;
|
|
70
|
+
}
|
|
71
|
+
export function redactSecrets(value) {
|
|
72
|
+
if (Array.isArray(value))
|
|
73
|
+
return value.map(redactSecrets);
|
|
74
|
+
if (!isRecord(value))
|
|
75
|
+
return value;
|
|
76
|
+
// Built with fromEntries: assigning a key named __proto__ would set the prototype instead.
|
|
77
|
+
return Object.fromEntries(Object.entries(value).map(([key, inner]) => {
|
|
78
|
+
if ((key === "env" || key === "headers") && isRecord(inner))
|
|
79
|
+
return [key, Object.fromEntries(Object.keys(inner).map((k) => [k, "•••"]))];
|
|
80
|
+
if (key === "url" && typeof inner === "string")
|
|
81
|
+
return [key, shownUrl(inner)];
|
|
82
|
+
if (key === "args" && "command" in value && Array.isArray(inner))
|
|
83
|
+
return [key, shownArgs(inner)];
|
|
84
|
+
return [key, redactSecrets(inner)];
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A JSON parse failure without the parser's message, which quotes the text around the fault and
|
|
89
|
+
* so can quote a secret.
|
|
90
|
+
*/
|
|
91
|
+
export function jsonSyntaxError(error) {
|
|
92
|
+
const at = /line (\d+) column (\d+)/.exec(String(error));
|
|
93
|
+
return at ? `not valid JSON (line ${at[1]}, column ${at[2]})` : "not valid JSON";
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The text ccdesk shows for a file reached as an instruction, import, memory, agent or skill. A
|
|
97
|
+
* settings or MCP file read this way, whatever it is named, would otherwise show its env and
|
|
98
|
+
* headers values, so anything that parses as JSON is shown parsed with them hidden. Frontmatter can
|
|
99
|
+
* hold the same values, in a subagent's inline MCP servers (docs: sub-agents#scope-mcp-servers-to-a-subagent),
|
|
100
|
+
* so frontmatter that holds any is shown re-written with them hidden.
|
|
101
|
+
*/
|
|
102
|
+
export function shownText(text) {
|
|
103
|
+
if (text === null)
|
|
104
|
+
return null;
|
|
105
|
+
let data;
|
|
106
|
+
try {
|
|
107
|
+
data = parseJson(text);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return shownFrontmatter(text);
|
|
111
|
+
}
|
|
112
|
+
return typeof data === "object" && data !== null
|
|
113
|
+
? `${JSON.stringify(redactSecrets(data), null, 2)}\n`
|
|
114
|
+
: text;
|
|
115
|
+
}
|
|
116
|
+
function shownFrontmatter(text) {
|
|
117
|
+
const match = FRONTMATTER.exec(text);
|
|
118
|
+
if (!match)
|
|
119
|
+
return text;
|
|
120
|
+
let data;
|
|
121
|
+
try {
|
|
122
|
+
data = parseYaml(match[1] ?? "");
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return text;
|
|
126
|
+
}
|
|
127
|
+
const redacted = redactSecrets(data);
|
|
128
|
+
if (JSON.stringify(redacted) === JSON.stringify(data))
|
|
129
|
+
return text;
|
|
130
|
+
return `---\n${stringifyYaml(redacted)}---\n${text.slice(match[0].length)}`;
|
|
131
|
+
}
|
|
132
|
+
/** A file named .json that does not parse is not shown: its text could still hold a secret. */
|
|
133
|
+
export function shownJsonFile(text) {
|
|
134
|
+
try {
|
|
135
|
+
parseJson(text);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return "(a JSON file ccdesk could not parse, so its text is not shown; open it to see it)\n";
|
|
139
|
+
}
|
|
140
|
+
return shownText(text) ?? "";
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Helpers for the values ccdesk hides. What is hidden is decided only by redactSecrets; these
|
|
144
|
+
* compare a value with its redacted form.
|
|
145
|
+
*/
|
|
146
|
+
/** A value as ccdesk shows it: JSON with hidden values as •••. */
|
|
147
|
+
export function shown(value) {
|
|
148
|
+
return `${JSON.stringify(redactSecrets(value), null, 2)}\n`;
|
|
149
|
+
}
|
|
150
|
+
/** Every value redactSecrets changes, with where it is, what it is, and how it is shown. */
|
|
151
|
+
function hiddenIn(value) {
|
|
152
|
+
const found = [];
|
|
153
|
+
// redactSecrets keeps every key and list length, so the two can be walked side by side.
|
|
154
|
+
const walk = (raw, shownValue, path) => {
|
|
155
|
+
if (Array.isArray(raw) && Array.isArray(shownValue)) {
|
|
156
|
+
for (const [i, inner] of raw.entries())
|
|
157
|
+
walk(inner, shownValue[i], [...path, i]);
|
|
158
|
+
}
|
|
159
|
+
else if (isRecord(raw) && isRecord(shownValue))
|
|
160
|
+
for (const [key, inner] of Object.entries(raw))
|
|
161
|
+
walk(inner, shownValue[key], [...path, key]);
|
|
162
|
+
else if (raw !== shownValue)
|
|
163
|
+
found.push({ path, raw, shown: shownValue });
|
|
164
|
+
};
|
|
165
|
+
walk(value, redactSecrets(value), []);
|
|
166
|
+
return found;
|
|
167
|
+
}
|
|
168
|
+
export function hasHiddenValues(value) {
|
|
169
|
+
return hiddenIn(value).length > 0;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* A fingerprint of the hidden values in a JSON file, so a restore can tell whether they changed.
|
|
173
|
+
* Null for a file that is not valid JSON.
|
|
174
|
+
*/
|
|
175
|
+
export function hiddenValues(text) {
|
|
176
|
+
try {
|
|
177
|
+
const data = text === null ? null : parseJson(text);
|
|
178
|
+
return JSON.stringify(hiddenIn(data).map(({ path, raw }) => [path, raw]));
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The secret text in a value, to take out of a message that may quote it: an env or header value
|
|
186
|
+
* or a hidden argument whole, the hidden end of one whose start is shown (`--token=value`,
|
|
187
|
+
* `Authorization: Bearer value`), and a URL's user name, password and query. Longest first, so a
|
|
188
|
+
* secret that contains another is taken out whole.
|
|
189
|
+
*/
|
|
190
|
+
export function secretTexts(value) {
|
|
191
|
+
const found = hiddenIn(value).flatMap(({ raw, shown: hidden }) => {
|
|
192
|
+
if (typeof raw !== "string")
|
|
193
|
+
return [];
|
|
194
|
+
if (hidden === "•••")
|
|
195
|
+
return [raw];
|
|
196
|
+
// The start was kept, as in `--token=•••` or `Authorization: Bearer •••`: the rest is the secret.
|
|
197
|
+
if (typeof hidden === "string" && hidden.endsWith("•••") && raw.startsWith(hidden.slice(0, -3)))
|
|
198
|
+
return [raw.slice(hidden.length - 3)];
|
|
199
|
+
if (shownUrl(raw) === raw)
|
|
200
|
+
return [];
|
|
201
|
+
const url = URL.parse(raw);
|
|
202
|
+
return [url?.username, url?.password, url?.search.slice(1)];
|
|
203
|
+
});
|
|
204
|
+
return found
|
|
205
|
+
.filter((s) => s !== undefined && s !== "")
|
|
206
|
+
.sort((a, b) => b.length - a.length);
|
|
207
|
+
}
|