ccdesk 0.1.0 → 0.2.0
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 +75 -0
- package/README.md +84 -67
- package/dist/server/agent-edit.js +77 -0
- package/dist/server/backups.js +129 -52
- package/dist/server/cli.js +21 -8
- package/dist/server/mcp-edit.js +263 -0
- package/dist/server/mutations.js +8 -3
- package/dist/server/scan/compare.js +34 -0
- package/dist/server/scan/context.js +6 -5
- package/dist/server/scan/env.js +8 -3
- package/dist/server/scan/files.js +118 -30
- package/dist/server/scan/index.js +29 -15
- package/dist/server/scan/instructions.js +11 -7
- package/dist/server/scan/inventory.js +134 -42
- package/dist/server/scan/mcp.js +88 -30
- package/dist/server/scan/memory.js +8 -9
- package/dist/server/scan/path-test.js +32 -10
- package/dist/server/scan/permissions.js +22 -6
- package/dist/server/scan/plugins.js +18 -11
- package/dist/server/scan/projects.js +7 -6
- package/dist/server/scan/sandbox.js +161 -0
- package/dist/server/scan/settings.js +33 -20
- package/dist/server/scan/tools.js +7 -3
- package/dist/server/scan/usage.js +119 -46
- package/dist/server/schema.js +15 -0
- package/dist/server/secrets.js +63 -0
- package/dist/server/server.js +222 -128
- package/dist/server/settings-edit.js +90 -21
- package/dist/server/watch.js +52 -10
- package/dist/server/write.js +58 -20
- package/dist/shared/compare.js +1 -0
- package/dist/shared/hook-events.json +35 -0
- package/dist/shared/mcp-server.schema.json +25 -0
- package/dist/shared/memory.js +4 -0
- package/dist/shared/scopes.js +3 -1
- package/dist/shared/types.js +1 -1
- package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
- package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
- package/dist/ui/assets/index-BRgGlpjd.css +1 -0
- package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/dist/ui/index.html +2 -2
- package/package.json +10 -4
- package/dist/ui/assets/index-BM4K7hSO.css +0 -1
- package/dist/ui/assets/index-CN3vkVjv.js +0 -45
|
@@ -1,41 +1,83 @@
|
|
|
1
1
|
import { open, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { storeDirName } from "../../shared/memory.js";
|
|
3
4
|
import {} from "../../shared/types.js";
|
|
4
5
|
import {} from "./env.js";
|
|
5
|
-
import { isDir, isRecord, isWithin,
|
|
6
|
-
import { storeDirName } from "./memory.js";
|
|
6
|
+
import { isCredentials, isDir, isRecord, isWithin, listDir, wildcard, worktrees } from "./files.js";
|
|
7
7
|
import { namesTool, parseRule } from "./tools.js";
|
|
8
8
|
/**
|
|
9
|
-
* Transcripts already counted, by path, so reopening a directory reads only what was added.
|
|
10
|
-
*
|
|
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.
|
|
11
12
|
*/
|
|
12
13
|
const transcripts = new Map();
|
|
14
|
+
/** Roughly how many calls the cache keeps, beyond those of the directory counted last. */
|
|
15
|
+
export const MAX_CACHED_CALLS = 100_000;
|
|
13
16
|
/** A transcript is read in parts this size, so one huge file never becomes one huge string. */
|
|
14
17
|
const CHUNK = 8 * 1024 * 1024;
|
|
15
18
|
let queue = Promise.resolve();
|
|
16
19
|
/** Requests waiting their turn, so a burst of the same request runs once. */
|
|
17
20
|
const waiting = new Map();
|
|
21
|
+
/** How long one count may take before it stops; what it read by then stays counted. */
|
|
22
|
+
const TIME_LIMIT_MS = 60_000;
|
|
23
|
+
/** A count that stopped before it finished, because it took too long or nobody wanted it. */
|
|
24
|
+
export class CountStopped extends Error {
|
|
25
|
+
}
|
|
18
26
|
/**
|
|
19
27
|
* Counts the tool calls in the sessions started in `dir`, or anywhere in its repository and its
|
|
20
28
|
* worktrees, since rules at a repository's root apply there too (docs: permissions#permission-system).
|
|
21
29
|
* Subagents are included. It also counts the calls you approved or refused by hand, which Claude
|
|
22
30
|
* Code records on each call's result as `permissionDecision` (not documented; older transcripts
|
|
23
|
-
* lack it), and how many calls each rule covers. Requests run one at a time, since they share the
|
|
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.
|
|
24
35
|
*/
|
|
25
|
-
export function readUsage(env, dir, rules) {
|
|
26
|
-
const key = JSON.stringify([env.claudeDir, resolve(dir), rules]);
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
export function readUsage(env, dir, rules, { signal, timeLimitMs = TIME_LIMIT_MS, maxCachedCalls = MAX_CACHED_CALLS, } = {}) {
|
|
37
|
+
const key = JSON.stringify([env.claudeDir, resolve(dir), rules, timeLimitMs, maxCachedCalls]);
|
|
38
|
+
let run = waiting.get(key);
|
|
39
|
+
if (!run) {
|
|
40
|
+
const stop = new AbortController();
|
|
41
|
+
const promise = queue.then(() => {
|
|
42
|
+
waiting.delete(key);
|
|
43
|
+
const during = AbortSignal.any([stop.signal, AbortSignal.timeout(timeLimitMs)]);
|
|
44
|
+
// A stopped count answers right away, even while one of its reads never returns.
|
|
45
|
+
const stopped = new Promise((_, fail) => {
|
|
46
|
+
const onStop = () => fail(stoppedBecause(during));
|
|
47
|
+
if (during.aborted)
|
|
48
|
+
onStop();
|
|
49
|
+
else
|
|
50
|
+
during.addEventListener("abort", onStop, { once: true });
|
|
51
|
+
});
|
|
52
|
+
return Promise.race([count(env, resolve(dir), rules, during, maxCachedCalls), stopped]);
|
|
53
|
+
});
|
|
54
|
+
run = { promise, stop, waiters: 0 };
|
|
55
|
+
waiting.set(key, run);
|
|
56
|
+
// The queue moves on when the count settles or stops: a stopped count changes nothing more.
|
|
57
|
+
queue = promise.catch(() => undefined);
|
|
58
|
+
}
|
|
59
|
+
const current = run;
|
|
60
|
+
if (signal?.aborted)
|
|
61
|
+
return current.promise;
|
|
62
|
+
current.waiters++;
|
|
63
|
+
signal?.addEventListener("abort", () => {
|
|
64
|
+
if (--current.waiters === 0)
|
|
65
|
+
current.stop.abort();
|
|
66
|
+
}, { once: true });
|
|
67
|
+
return current.promise;
|
|
37
68
|
}
|
|
38
|
-
|
|
69
|
+
function stoppedBecause(signal) {
|
|
70
|
+
return new CountStopped(signal.reason instanceof DOMException && signal.reason.name === "TimeoutError"
|
|
71
|
+
? "Counting tool calls took too long and stopped; what was read is kept, so the next count continues."
|
|
72
|
+
: "The count was no longer wanted.");
|
|
73
|
+
}
|
|
74
|
+
/** Throws once a count has to stop, before it changes anything else. */
|
|
75
|
+
function checkStopped(signal) {
|
|
76
|
+
if (signal.aborted)
|
|
77
|
+
throw stoppedBecause(signal);
|
|
78
|
+
}
|
|
79
|
+
async function count(env, dir, rules, signal, maxCachedCalls) {
|
|
80
|
+
checkStopped(signal);
|
|
39
81
|
const checkouts = await worktrees(dir);
|
|
40
82
|
// $HOME may end in a slash or be a link, while git prints the resolved path.
|
|
41
83
|
const homes = new Set([
|
|
@@ -50,6 +92,7 @@ async function count(env, dir, rules) {
|
|
|
50
92
|
const storeNames = await listDir(projects);
|
|
51
93
|
const taken = new Set();
|
|
52
94
|
const calls = [];
|
|
95
|
+
const counted = new Set();
|
|
53
96
|
let sessions = 0;
|
|
54
97
|
for (const folder of folders) {
|
|
55
98
|
const own = storeDirName(folder);
|
|
@@ -65,19 +108,23 @@ async function count(env, dir, rules) {
|
|
|
65
108
|
if (taken.has(file))
|
|
66
109
|
continue;
|
|
67
110
|
// A subagent's session decides; one whose session file is gone goes by its own start.
|
|
68
|
-
const start = (await transcript(session))?.startCwd ??
|
|
111
|
+
const start = (await transcript(env, session, signal))?.startCwd ??
|
|
112
|
+
(await transcript(env, file, signal))?.startCwd;
|
|
69
113
|
if (start ? !isWithin(folder, start) : name !== own)
|
|
70
114
|
continue;
|
|
71
115
|
taken.add(file);
|
|
72
|
-
|
|
116
|
+
counted.add(file);
|
|
117
|
+
calls.push(...((await transcript(env, file, signal))?.calls ?? []));
|
|
73
118
|
if (file === session)
|
|
74
119
|
sessions++;
|
|
75
120
|
}
|
|
121
|
+
checkStopped(signal);
|
|
76
122
|
for (const path of transcripts.keys())
|
|
77
123
|
if (path.startsWith(store + sep) && !files.has(path))
|
|
78
124
|
transcripts.delete(path);
|
|
79
125
|
}
|
|
80
126
|
}
|
|
127
|
+
trimCache(counted, maxCachedCalls);
|
|
81
128
|
return {
|
|
82
129
|
sessions,
|
|
83
130
|
repository,
|
|
@@ -87,6 +134,18 @@ async function count(env, dir, rules) {
|
|
|
87
134
|
matches: Object.fromEntries(rules.map((r) => [r.rule, matchCount(r, calls)])),
|
|
88
135
|
};
|
|
89
136
|
}
|
|
137
|
+
/** Drops the least recently counted transcripts over the limit, keeping those just counted. */
|
|
138
|
+
function trimCache(counted, maxCalls) {
|
|
139
|
+
let total = 0;
|
|
140
|
+
for (const known of transcripts.values())
|
|
141
|
+
total += known.calls.length;
|
|
142
|
+
for (const [path, known] of transcripts) {
|
|
143
|
+
if (total <= maxCalls || counted.has(path))
|
|
144
|
+
return;
|
|
145
|
+
transcripts.delete(path);
|
|
146
|
+
total -= known.calls.length;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
90
149
|
/** A store's transcripts, each with the session it belongs to: itself, or a subagent's session. */
|
|
91
150
|
async function transcriptsIn(store) {
|
|
92
151
|
const files = new Map();
|
|
@@ -100,32 +159,39 @@ async function transcriptsIn(store) {
|
|
|
100
159
|
}
|
|
101
160
|
return files;
|
|
102
161
|
}
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
162
|
+
/**
|
|
163
|
+
* One transcript, read up to its end; one that cannot be read keeps what was read before, and one
|
|
164
|
+
* whose count stops keeps the whole lines read so far.
|
|
165
|
+
*/
|
|
166
|
+
async function transcript(env, file, signal) {
|
|
167
|
+
if (await isCredentials(env, file))
|
|
106
168
|
return null;
|
|
107
169
|
const info = await stat(file).catch(() => null);
|
|
170
|
+
checkStopped(signal);
|
|
108
171
|
if (!info?.isFile())
|
|
109
172
|
return null;
|
|
110
173
|
let known = transcripts.get(file);
|
|
111
|
-
if (!known || info.size < known.offset)
|
|
174
|
+
if (!known || info.size < known.offset)
|
|
112
175
|
known = { size: 0, mtimeMs: 0, offset: 0, calls: [], byId: new Map() };
|
|
113
|
-
|
|
114
|
-
|
|
176
|
+
// Set again so the map stays in order of last use.
|
|
177
|
+
transcripts.delete(file);
|
|
178
|
+
transcripts.set(file, known);
|
|
115
179
|
if (known.size === info.size && known.mtimeMs === info.mtimeMs)
|
|
116
180
|
return known;
|
|
117
181
|
try {
|
|
118
|
-
await readFrom(file, known, info.size);
|
|
182
|
+
await readFrom(file, known, info.size, signal);
|
|
119
183
|
known.size = info.size;
|
|
120
184
|
known.mtimeMs = info.mtimeMs;
|
|
121
185
|
}
|
|
122
|
-
catch {
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error instanceof CountStopped)
|
|
188
|
+
throw error;
|
|
123
189
|
// Gone or unreadable since the stat: keep what was counted; the offset still marks it.
|
|
124
190
|
}
|
|
125
191
|
return known;
|
|
126
192
|
}
|
|
127
193
|
/** Reads whole lines from the offset up to `size`; a line still being written is read next time. */
|
|
128
|
-
async function readFrom(file, known, size) {
|
|
194
|
+
async function readFrom(file, known, size, signal) {
|
|
129
195
|
const handle = await open(file, "r");
|
|
130
196
|
try {
|
|
131
197
|
let carried = Buffer.alloc(0);
|
|
@@ -133,6 +199,8 @@ async function readFrom(file, known, size) {
|
|
|
133
199
|
while (position < size) {
|
|
134
200
|
const chunk = Buffer.alloc(Math.min(CHUNK, size - position));
|
|
135
201
|
const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
|
|
202
|
+
// Checked before the cache changes: a later count may already be reading this file.
|
|
203
|
+
checkStopped(signal);
|
|
136
204
|
if (bytesRead === 0)
|
|
137
205
|
break;
|
|
138
206
|
position += bytesRead;
|
|
@@ -198,17 +266,23 @@ function read(line, transcript) {
|
|
|
198
266
|
}
|
|
199
267
|
}
|
|
200
268
|
function callOf(tool, input, at) {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
269
|
+
const command = (tool === "Bash" || tool === "Monitor") && typeof input.command === "string"
|
|
270
|
+
? input.command
|
|
271
|
+
: undefined;
|
|
272
|
+
let host;
|
|
204
273
|
if (tool === "WebFetch" && typeof input.url === "string") {
|
|
205
274
|
try {
|
|
206
|
-
|
|
275
|
+
host = new URL(input.url).hostname.replace(/\.$/, "");
|
|
207
276
|
}
|
|
208
277
|
catch {
|
|
209
278
|
// Not a URL: no domain rule can match it.
|
|
210
279
|
}
|
|
211
280
|
}
|
|
281
|
+
const call = { tool, at, rule: ruleFor(tool, command, host) };
|
|
282
|
+
if (command !== undefined)
|
|
283
|
+
call.commands = everyCommandIn(command);
|
|
284
|
+
if (host !== undefined)
|
|
285
|
+
call.host = host;
|
|
212
286
|
return call;
|
|
213
287
|
}
|
|
214
288
|
function toolUsage(calls) {
|
|
@@ -235,9 +309,8 @@ function approvals(calls, rules) {
|
|
|
235
309
|
for (const c of calls) {
|
|
236
310
|
if (c.byYou !== true)
|
|
237
311
|
continue;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
groups.set(rule, [...(groups.get(rule) ?? []), c]);
|
|
312
|
+
if (c.rule)
|
|
313
|
+
groups.set(c.rule, [...(groups.get(c.rule) ?? []), c]);
|
|
241
314
|
}
|
|
242
315
|
const refused = calls.filter((c) => c.byYou === false);
|
|
243
316
|
const out = [];
|
|
@@ -307,13 +380,13 @@ const PATH_TOOLS = new Set(["Read", "Edit", "Write", "NotebookEdit", "Glob", "Gr
|
|
|
307
380
|
* permissions#process-wrappers) and file tools. A rule takes no other words from a command, so
|
|
308
381
|
* arguments never leave the server.
|
|
309
382
|
*/
|
|
310
|
-
function ruleFor(
|
|
311
|
-
if (
|
|
312
|
-
if (
|
|
383
|
+
function ruleFor(tool, command, host) {
|
|
384
|
+
if (tool === "Bash") {
|
|
385
|
+
if (command === undefined || /[`$<>(){}]/.test(command))
|
|
313
386
|
return null;
|
|
314
|
-
if (/^\s*\w+=/.test(
|
|
387
|
+
if (/^\s*\w+=/.test(command))
|
|
315
388
|
return null;
|
|
316
|
-
const parts = subcommands(
|
|
389
|
+
const parts = subcommands(command);
|
|
317
390
|
if (parts.length !== 1)
|
|
318
391
|
return null;
|
|
319
392
|
const [program, ...rest] = (parts[0] ?? "").split(/\s+/);
|
|
@@ -324,11 +397,11 @@ function ruleFor(call) {
|
|
|
324
397
|
const sub = SUBCOMMANDS[program]?.find((words) => words.split(" ").every((word, i) => rest[i] === word));
|
|
325
398
|
return sub ? `Bash(${program} ${sub} *)` : null;
|
|
326
399
|
}
|
|
327
|
-
if (
|
|
328
|
-
return
|
|
329
|
-
if (PATH_TOOLS.has(
|
|
400
|
+
if (tool === "WebFetch")
|
|
401
|
+
return host ? `WebFetch(domain:${host})` : null;
|
|
402
|
+
if (PATH_TOOLS.has(tool) || tool === "PowerShell" || tool === "Monitor")
|
|
330
403
|
return null;
|
|
331
|
-
return
|
|
404
|
+
return tool;
|
|
332
405
|
}
|
|
333
406
|
const SEPARATORS = /&&|\|\||\|&|[;|&\n]/;
|
|
334
407
|
/** docs: permissions#process-wrappers */
|
|
@@ -416,7 +489,7 @@ function matcherFor({ list, rule }) {
|
|
|
416
489
|
const pattern = bashPattern(specifier);
|
|
417
490
|
if (pattern === null)
|
|
418
491
|
return null;
|
|
419
|
-
return (c) => c.
|
|
492
|
+
return (c) => c.commands?.some(pattern) === true;
|
|
420
493
|
}
|
|
421
494
|
if (tool === "WebFetch" && specifier.startsWith("domain:")) {
|
|
422
495
|
const host = wildcard(specifier.slice(7).replace(/\.$/, ""), "i");
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Ajv } from "ajv";
|
|
2
|
+
export const ajv = new Ajv({ allErrors: true, strict: false });
|
|
3
|
+
/**
|
|
4
|
+
* What `validate` found wrong with `value`, each led by where: `label` and the path inside it, such
|
|
5
|
+
* as `sandbox.network`.
|
|
6
|
+
*/
|
|
7
|
+
export function schemaErrors(validate, value, label) {
|
|
8
|
+
if (validate(value))
|
|
9
|
+
return [];
|
|
10
|
+
return (validate.errors ?? []).map((e) => {
|
|
11
|
+
const where = [label, ...e.instancePath.split("/")].filter(Boolean).join(".");
|
|
12
|
+
const allowed = e.keyword === "enum" ? `: ${e.params.allowedValues.join(", ")}` : "";
|
|
13
|
+
return `${where} ${e.message ?? "is invalid"}${allowed}`;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isRecord, redactSecrets, shownUrl } from "./scan/files.js";
|
|
2
|
+
/**
|
|
3
|
+
* Helpers for the values ccdesk hides. What is hidden is decided only by redactSecrets; these
|
|
4
|
+
* compare a value with its redacted form.
|
|
5
|
+
*/
|
|
6
|
+
/** A value as ccdesk shows it: JSON with hidden values as •••. */
|
|
7
|
+
export function shown(value) {
|
|
8
|
+
return `${JSON.stringify(redactSecrets(value), null, 2)}\n`;
|
|
9
|
+
}
|
|
10
|
+
/** Every value redactSecrets changes, with where it is, what it is, and how it is shown. */
|
|
11
|
+
function hiddenIn(value) {
|
|
12
|
+
const found = [];
|
|
13
|
+
// redactSecrets keeps every key and list length, so the two can be walked side by side.
|
|
14
|
+
const walk = (raw, shownValue, path) => {
|
|
15
|
+
if (Array.isArray(raw) && Array.isArray(shownValue)) {
|
|
16
|
+
for (const [i, inner] of raw.entries())
|
|
17
|
+
walk(inner, shownValue[i], [...path, i]);
|
|
18
|
+
}
|
|
19
|
+
else if (isRecord(raw) && isRecord(shownValue))
|
|
20
|
+
for (const [key, inner] of Object.entries(raw))
|
|
21
|
+
walk(inner, shownValue[key], [...path, key]);
|
|
22
|
+
else if (raw !== shownValue)
|
|
23
|
+
found.push({ path, raw, shown: shownValue });
|
|
24
|
+
};
|
|
25
|
+
walk(value, redactSecrets(value), []);
|
|
26
|
+
return found;
|
|
27
|
+
}
|
|
28
|
+
export function hasHiddenValues(value) {
|
|
29
|
+
return hiddenIn(value).length > 0;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A fingerprint of the hidden values in a JSON file, so a restore can tell whether they changed.
|
|
33
|
+
* Null for a file that is not valid JSON.
|
|
34
|
+
*/
|
|
35
|
+
export function hiddenValues(text) {
|
|
36
|
+
try {
|
|
37
|
+
const data = text === null ? null : JSON.parse(text);
|
|
38
|
+
return JSON.stringify(hiddenIn(data).map(({ path, raw }) => [path, raw]));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The secret text in a value, to take out of a message that may quote it: an env or header value
|
|
46
|
+
* or a hidden argument whole, the part after `=` of `--token=value`, and a URL's user name,
|
|
47
|
+
* password and query. Longest first, so a secret that contains another is taken out whole.
|
|
48
|
+
*/
|
|
49
|
+
export function secretTexts(value) {
|
|
50
|
+
const found = hiddenIn(value).flatMap(({ raw, shown }) => {
|
|
51
|
+
if (typeof raw !== "string")
|
|
52
|
+
return [];
|
|
53
|
+
if (shown === "•••")
|
|
54
|
+
return [raw];
|
|
55
|
+
if (typeof shown === "string" && shown.endsWith("=•••"))
|
|
56
|
+
return [raw.slice(raw.indexOf("=") + 1)];
|
|
57
|
+
if (shownUrl(raw) === raw)
|
|
58
|
+
return [];
|
|
59
|
+
const url = URL.parse(raw);
|
|
60
|
+
return [url?.username, url?.password, url?.search.slice(1)];
|
|
61
|
+
});
|
|
62
|
+
return found.filter((s) => !!s).sort((a, b) => b.length - a.length);
|
|
63
|
+
}
|