ccdesk 0.0.0-stage → 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 +90 -0
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/dist/server/agent-edit.js +77 -0
- package/dist/server/backups.js +303 -0
- package/dist/server/cli.js +105 -0
- package/dist/server/mcp-edit.js +263 -0
- package/dist/server/mutations.js +193 -0
- package/dist/server/open.js +139 -0
- package/dist/server/scan/compare.js +34 -0
- package/dist/server/scan/context.js +140 -0
- package/dist/server/scan/env.js +28 -0
- package/dist/server/scan/files.js +478 -0
- package/dist/server/scan/index.js +280 -0
- package/dist/server/scan/instructions.js +289 -0
- package/dist/server/scan/inventory.js +452 -0
- package/dist/server/scan/mcp.js +313 -0
- package/dist/server/scan/memory.js +90 -0
- package/dist/server/scan/path-test.js +121 -0
- package/dist/server/scan/permissions.js +101 -0
- package/dist/server/scan/plugins.js +124 -0
- package/dist/server/scan/projects.js +101 -0
- package/dist/server/scan/sandbox.js +161 -0
- package/dist/server/scan/settings.js +330 -0
- package/dist/server/scan/tools.js +111 -0
- package/dist/server/scan/usage.js +514 -0
- package/dist/server/schema.js +15 -0
- package/dist/server/secrets.js +63 -0
- package/dist/server/server.js +352 -0
- package/dist/server/settings-edit.js +221 -0
- package/dist/server/watch.js +214 -0
- package/dist/server/write.js +233 -0
- package/dist/shared/builtin-tools.json +235 -0
- 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 +20 -0
- package/dist/shared/settings-scopes.json +208 -0
- package/dist/shared/settings.schema.json +4623 -0
- package/dist/shared/types.js +2 -0
- package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
- package/dist/ui/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/dist/ui/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/dist/ui/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.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/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/ui/index.html +15 -0
- package/dist/ui/logo.png +0 -0
- package/package.json +86 -4
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { open, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { storeDirName } from "../../shared/memory.js";
|
|
4
|
+
import {} from "../../shared/types.js";
|
|
5
|
+
import {} from "./env.js";
|
|
6
|
+
import { isCredentials, isDir, isRecord, isWithin, listDir, wildcard, worktrees } from "./files.js";
|
|
7
|
+
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
|
+
/** Roughly how many calls the cache keeps, beyond those of the directory counted last. */
|
|
15
|
+
export const MAX_CACHED_CALLS = 100_000;
|
|
16
|
+
/** A transcript is read in parts this size, so one huge file never becomes one huge string. */
|
|
17
|
+
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
|
+
/** 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
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Counts the tool calls in the sessions started in `dir`, or anywhere in its repository and its
|
|
28
|
+
* worktrees, since rules at a repository's root apply there too (docs: permissions#permission-system).
|
|
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.
|
|
35
|
+
*/
|
|
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;
|
|
68
|
+
}
|
|
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);
|
|
81
|
+
const checkouts = await worktrees(dir);
|
|
82
|
+
// $HOME may end in a slash or be a link, while git prints the resolved path.
|
|
83
|
+
const homes = new Set([
|
|
84
|
+
resolve(env.home),
|
|
85
|
+
await realpath(env.home).catch(() => resolve(env.home)),
|
|
86
|
+
]);
|
|
87
|
+
const isHome = (folder) => homes.has(resolve(folder));
|
|
88
|
+
// A home directory kept in git (dotfiles) is not a project: its folders are separate projects.
|
|
89
|
+
const repository = checkouts.length > 0 && !isHome(checkouts[0] ?? "");
|
|
90
|
+
const folders = repository ? checkouts : [dir];
|
|
91
|
+
const projects = join(env.claudeDir, "projects");
|
|
92
|
+
const storeNames = await listDir(projects);
|
|
93
|
+
const taken = new Set();
|
|
94
|
+
const calls = [];
|
|
95
|
+
const counted = new Set();
|
|
96
|
+
let sessions = 0;
|
|
97
|
+
for (const folder of folders) {
|
|
98
|
+
const own = storeDirName(folder);
|
|
99
|
+
// Sessions started in a subfolder or a nested worktree have their own store, named with the
|
|
100
|
+
// folder's as a prefix; the prefix also matches siblings such as app-old, so each session is
|
|
101
|
+
// kept by the folder it started in. A home directory would pull in every store, so only its
|
|
102
|
+
// own is read.
|
|
103
|
+
const stores = storeNames.filter((name) => name === own || (!isHome(folder) && name.startsWith(`${own}-`)));
|
|
104
|
+
for (const name of stores) {
|
|
105
|
+
const store = join(projects, name);
|
|
106
|
+
const files = await transcriptsIn(store);
|
|
107
|
+
for (const [file, session] of files) {
|
|
108
|
+
if (taken.has(file))
|
|
109
|
+
continue;
|
|
110
|
+
// A subagent's session decides; one whose session file is gone goes by its own start.
|
|
111
|
+
const start = (await transcript(env, session, signal))?.startCwd ??
|
|
112
|
+
(await transcript(env, file, signal))?.startCwd;
|
|
113
|
+
if (start ? !isWithin(folder, start) : name !== own)
|
|
114
|
+
continue;
|
|
115
|
+
taken.add(file);
|
|
116
|
+
counted.add(file);
|
|
117
|
+
calls.push(...((await transcript(env, file, signal))?.calls ?? []));
|
|
118
|
+
if (file === session)
|
|
119
|
+
sessions++;
|
|
120
|
+
}
|
|
121
|
+
checkStopped(signal);
|
|
122
|
+
for (const path of transcripts.keys())
|
|
123
|
+
if (path.startsWith(store + sep) && !files.has(path))
|
|
124
|
+
transcripts.delete(path);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
trimCache(counted, maxCachedCalls);
|
|
128
|
+
return {
|
|
129
|
+
sessions,
|
|
130
|
+
repository,
|
|
131
|
+
since: calls.reduce((min, c) => (min === null || c.at < min ? c.at : min), null),
|
|
132
|
+
tools: toolUsage(calls),
|
|
133
|
+
approvals: approvals(calls, rules),
|
|
134
|
+
matches: Object.fromEntries(rules.map((r) => [r.rule, matchCount(r, calls)])),
|
|
135
|
+
};
|
|
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
|
+
}
|
|
149
|
+
/** A store's transcripts, each with the session it belongs to: itself, or a subagent's session. */
|
|
150
|
+
async function transcriptsIn(store) {
|
|
151
|
+
const files = new Map();
|
|
152
|
+
for (const name of await listDir(store)) {
|
|
153
|
+
if (name.endsWith(".jsonl"))
|
|
154
|
+
files.set(join(store, name), join(store, name));
|
|
155
|
+
else if (await isDir(join(store, name, "subagents")))
|
|
156
|
+
for (const sub of await listDir(join(store, name, "subagents")))
|
|
157
|
+
if (sub.endsWith(".jsonl"))
|
|
158
|
+
files.set(join(store, name, "subagents", sub), join(store, `${name}.jsonl`));
|
|
159
|
+
}
|
|
160
|
+
return files;
|
|
161
|
+
}
|
|
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))
|
|
168
|
+
return null;
|
|
169
|
+
const info = await stat(file).catch(() => null);
|
|
170
|
+
checkStopped(signal);
|
|
171
|
+
if (!info?.isFile())
|
|
172
|
+
return null;
|
|
173
|
+
let known = transcripts.get(file);
|
|
174
|
+
if (!known || info.size < known.offset)
|
|
175
|
+
known = { size: 0, mtimeMs: 0, offset: 0, calls: [], byId: new Map() };
|
|
176
|
+
// Set again so the map stays in order of last use.
|
|
177
|
+
transcripts.delete(file);
|
|
178
|
+
transcripts.set(file, known);
|
|
179
|
+
if (known.size === info.size && known.mtimeMs === info.mtimeMs)
|
|
180
|
+
return known;
|
|
181
|
+
try {
|
|
182
|
+
await readFrom(file, known, info.size, signal);
|
|
183
|
+
known.size = info.size;
|
|
184
|
+
known.mtimeMs = info.mtimeMs;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error instanceof CountStopped)
|
|
188
|
+
throw error;
|
|
189
|
+
// Gone or unreadable since the stat: keep what was counted; the offset still marks it.
|
|
190
|
+
}
|
|
191
|
+
return known;
|
|
192
|
+
}
|
|
193
|
+
/** Reads whole lines from the offset up to `size`; a line still being written is read next time. */
|
|
194
|
+
async function readFrom(file, known, size, signal) {
|
|
195
|
+
const handle = await open(file, "r");
|
|
196
|
+
try {
|
|
197
|
+
let carried = Buffer.alloc(0);
|
|
198
|
+
let position = known.offset;
|
|
199
|
+
while (position < size) {
|
|
200
|
+
const chunk = Buffer.alloc(Math.min(CHUNK, size - position));
|
|
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);
|
|
204
|
+
if (bytesRead === 0)
|
|
205
|
+
break;
|
|
206
|
+
position += bytesRead;
|
|
207
|
+
const data = Buffer.concat([carried, chunk.subarray(0, bytesRead)]);
|
|
208
|
+
const end = data.lastIndexOf(0x0a) + 1;
|
|
209
|
+
for (const line of data.subarray(0, end).toString("utf8").split("\n"))
|
|
210
|
+
read(line, known);
|
|
211
|
+
known.offset += end;
|
|
212
|
+
carried = data.subarray(end);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await handle.close();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** Takes the tool calls from an assistant line and your answers from a tool result line. */
|
|
220
|
+
function read(line, transcript) {
|
|
221
|
+
const hasCall = line.includes('"tool_use"');
|
|
222
|
+
const hasDecision = line.includes('"permissionDecision"');
|
|
223
|
+
const hasStart = !transcript.startCwd && line.includes('"cwd"');
|
|
224
|
+
if (!hasCall && !hasDecision && !hasStart)
|
|
225
|
+
return;
|
|
226
|
+
let record;
|
|
227
|
+
try {
|
|
228
|
+
record = JSON.parse(line);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (!isRecord(record))
|
|
234
|
+
return;
|
|
235
|
+
if (!transcript.startCwd && typeof record.cwd === "string")
|
|
236
|
+
transcript.startCwd = record.cwd;
|
|
237
|
+
if (!isRecord(record.message))
|
|
238
|
+
return;
|
|
239
|
+
const content = record.message.content;
|
|
240
|
+
if (!Array.isArray(content))
|
|
241
|
+
return;
|
|
242
|
+
const at = typeof record.timestamp === "string" ? record.timestamp : "";
|
|
243
|
+
if (record.type === "assistant") {
|
|
244
|
+
for (const block of content) {
|
|
245
|
+
if (!isRecord(block) || block.type !== "tool_use")
|
|
246
|
+
continue;
|
|
247
|
+
if (typeof block.id !== "string" || typeof block.name !== "string")
|
|
248
|
+
continue;
|
|
249
|
+
const call = callOf(block.name, isRecord(block.input) ? block.input : {}, at);
|
|
250
|
+
transcript.calls.push(call);
|
|
251
|
+
transcript.byId.set(block.id, call);
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const decision = record.permissionDecision;
|
|
256
|
+
if (record.type !== "user" || !isRecord(decision))
|
|
257
|
+
return;
|
|
258
|
+
if (typeof decision.source !== "string" || !decision.source.startsWith("user"))
|
|
259
|
+
return;
|
|
260
|
+
for (const block of content) {
|
|
261
|
+
if (!isRecord(block) || block.type !== "tool_result")
|
|
262
|
+
continue;
|
|
263
|
+
const call = typeof block.tool_use_id === "string" && transcript.byId.get(block.tool_use_id);
|
|
264
|
+
if (call)
|
|
265
|
+
call.byYou = decision.decision === "accept";
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function callOf(tool, input, at) {
|
|
269
|
+
const command = (tool === "Bash" || tool === "Monitor") && typeof input.command === "string"
|
|
270
|
+
? input.command
|
|
271
|
+
: undefined;
|
|
272
|
+
let host;
|
|
273
|
+
if (tool === "WebFetch" && typeof input.url === "string") {
|
|
274
|
+
try {
|
|
275
|
+
host = new URL(input.url).hostname.replace(/\.$/, "");
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// Not a URL: no domain rule can match it.
|
|
279
|
+
}
|
|
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;
|
|
286
|
+
return call;
|
|
287
|
+
}
|
|
288
|
+
function toolUsage(calls) {
|
|
289
|
+
const byTool = new Map();
|
|
290
|
+
for (const c of calls) {
|
|
291
|
+
const usage = byTool.get(c.tool) ?? { tool: c.tool, calls: 0, approved: 0, refused: 0 };
|
|
292
|
+
usage.calls++;
|
|
293
|
+
if (c.byYou === true)
|
|
294
|
+
usage.approved++;
|
|
295
|
+
if (c.byYou === false)
|
|
296
|
+
usage.refused++;
|
|
297
|
+
byTool.set(c.tool, usage);
|
|
298
|
+
}
|
|
299
|
+
return [...byTool.values()].sort((a, b) => b.calls - a.calls);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Calls you allowed by hand, grouped by the allow rule that would cover them. A rule is left out
|
|
303
|
+
* when it would also cover a call you refused, or when rules in effect already decide every call
|
|
304
|
+
* behind it: allowing it then changes nothing.
|
|
305
|
+
*/
|
|
306
|
+
function approvals(calls, rules) {
|
|
307
|
+
const matchers = rules.flatMap((r) => matcherFor(r) ?? []);
|
|
308
|
+
const groups = new Map();
|
|
309
|
+
for (const c of calls) {
|
|
310
|
+
if (c.byYou !== true)
|
|
311
|
+
continue;
|
|
312
|
+
if (c.rule)
|
|
313
|
+
groups.set(c.rule, [...(groups.get(c.rule) ?? []), c]);
|
|
314
|
+
}
|
|
315
|
+
const refused = calls.filter((c) => c.byYou === false);
|
|
316
|
+
const out = [];
|
|
317
|
+
for (const [rule, approved] of groups) {
|
|
318
|
+
if (rules.some((r) => r.rule === rule))
|
|
319
|
+
continue;
|
|
320
|
+
const covers = matcherFor({ list: "allow", rule });
|
|
321
|
+
if (covers && refused.some(covers))
|
|
322
|
+
continue;
|
|
323
|
+
if (approved.every((c) => matchers.some((m) => m(c))))
|
|
324
|
+
continue;
|
|
325
|
+
const last = approved.reduce((max, c) => (c.at > max ? c.at : max), "");
|
|
326
|
+
out.push({ rule, approved: approved.length, last });
|
|
327
|
+
}
|
|
328
|
+
return out.sort((a, b) => b.approved - a.approved);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Programs a prefix rule may name on their own: read-only commands with no flag that runs a
|
|
332
|
+
* command or writes a file (docs: permissions#read-only-commands). A rule for any other program
|
|
333
|
+
* could allow far more than you approved, as `sed`'s `e` command or `make --eval` do, so ccdesk
|
|
334
|
+
* suggests none.
|
|
335
|
+
*/
|
|
336
|
+
const SAFE_PROGRAMS = new Set("ls cat head tail wc grep diff cmp comm cut tr jq stat du df pwd echo printf date whoami uname which basename dirname realpath readlink nproc".split(" "));
|
|
337
|
+
/**
|
|
338
|
+
* Programs with subcommands, and the subcommands a rule may name, one or two words each: those
|
|
339
|
+
* whose flags neither run another command nor write a file you did not name
|
|
340
|
+
* (docs: permissions#wildcard-patterns). The exceptions are the script runners `test`, `run`,
|
|
341
|
+
* `build` and `lint`, and `git commit`, whose hooks may run scripts from package.json: they run
|
|
342
|
+
* the project's own code, and npm and pnpm config flags such as `--node-options` can make them
|
|
343
|
+
* run any code. A rule for them is the one Claude Code's own prompt offers, `npm test *`
|
|
344
|
+
* (docs: permissions#permission-system), and no wider.
|
|
345
|
+
*
|
|
346
|
+
* Left out on purpose: `git stash show`, `git blame` and `git log` write anywhere with
|
|
347
|
+
* `--output`; `git grep -O` and `git fetch --upload-pack` run a command; the rest of `gh repo`
|
|
348
|
+
* reaches delete, visibility and deploy keys; `bun build --compile --outfile` writes an
|
|
349
|
+
* executable; `npm audit fix` installs; every pnpm command loads a pnpmfile, which
|
|
350
|
+
* `--config.pnpmfile` can point at any script; podman takes `--runtime` after the subcommand;
|
|
351
|
+
* `go test -exec`, `cargo --config`, kubeconfig exec plugins, `pip --python` and installs run
|
|
352
|
+
* whatever they are given.
|
|
353
|
+
*/
|
|
354
|
+
const SUBCOMMANDS = {
|
|
355
|
+
git: "status add commit switch restore tag branch rev-parse describe ls-files".split(" "),
|
|
356
|
+
npm: "test run ls outdated view".split(" "),
|
|
357
|
+
pnpm: "test run build lint".split(" "),
|
|
358
|
+
yarn: "test run build lint".split(" "),
|
|
359
|
+
bun: ["test"],
|
|
360
|
+
gh: [
|
|
361
|
+
"status",
|
|
362
|
+
"pr view",
|
|
363
|
+
"pr list",
|
|
364
|
+
"pr diff",
|
|
365
|
+
"pr checks",
|
|
366
|
+
"issue view",
|
|
367
|
+
"issue list",
|
|
368
|
+
"repo view",
|
|
369
|
+
],
|
|
370
|
+
docker: "ps images logs inspect".split(" "),
|
|
371
|
+
systemctl: ["status"],
|
|
372
|
+
};
|
|
373
|
+
/** Path rules for these need a path ccdesk should not guess. */
|
|
374
|
+
const PATH_TOOLS = new Set(["Read", "Edit", "Write", "NotebookEdit", "Glob", "Grep", "LSP"]);
|
|
375
|
+
/**
|
|
376
|
+
* The allow rule that would have covered a call, in the form Claude Code's "don't ask again"
|
|
377
|
+
* writes: a Bash program on the allowlists above, with its subcommand where it has them, a fetch's
|
|
378
|
+
* domain, or the tool. None for compound commands, other programs and subcommands, a leading
|
|
379
|
+
* variable assignment (an allow rule does not match past most of them, docs:
|
|
380
|
+
* permissions#process-wrappers) and file tools. A rule takes no other words from a command, so
|
|
381
|
+
* arguments never leave the server.
|
|
382
|
+
*/
|
|
383
|
+
function ruleFor(tool, command, host) {
|
|
384
|
+
if (tool === "Bash") {
|
|
385
|
+
if (command === undefined || /[`$<>(){}]/.test(command))
|
|
386
|
+
return null;
|
|
387
|
+
if (/^\s*\w+=/.test(command))
|
|
388
|
+
return null;
|
|
389
|
+
const parts = subcommands(command);
|
|
390
|
+
if (parts.length !== 1)
|
|
391
|
+
return null;
|
|
392
|
+
const [program, ...rest] = (parts[0] ?? "").split(/\s+/);
|
|
393
|
+
if (!program)
|
|
394
|
+
return null;
|
|
395
|
+
if (SAFE_PROGRAMS.has(program))
|
|
396
|
+
return `Bash(${program} *)`;
|
|
397
|
+
const sub = SUBCOMMANDS[program]?.find((words) => words.split(" ").every((word, i) => rest[i] === word));
|
|
398
|
+
return sub ? `Bash(${program} ${sub} *)` : null;
|
|
399
|
+
}
|
|
400
|
+
if (tool === "WebFetch")
|
|
401
|
+
return host ? `WebFetch(domain:${host})` : null;
|
|
402
|
+
if (PATH_TOOLS.has(tool) || tool === "PowerShell" || tool === "Monitor")
|
|
403
|
+
return null;
|
|
404
|
+
return tool;
|
|
405
|
+
}
|
|
406
|
+
const SEPARATORS = /&&|\|\||\|&|[;|&\n]/;
|
|
407
|
+
/** docs: permissions#process-wrappers */
|
|
408
|
+
const WRAPPERS = new Set([
|
|
409
|
+
"timeout",
|
|
410
|
+
"time",
|
|
411
|
+
"nice",
|
|
412
|
+
"nohup",
|
|
413
|
+
"stdbuf",
|
|
414
|
+
"command",
|
|
415
|
+
"builtin",
|
|
416
|
+
"noglob",
|
|
417
|
+
]);
|
|
418
|
+
const KEYWORDS = new Set(["if", "then", "else", "elif", "do", "while", "until", "!"]);
|
|
419
|
+
/** A command's parts as Claude Code splits them for allow rules: at shell operators, wrappers stripped. */
|
|
420
|
+
function subcommands(command) {
|
|
421
|
+
return command
|
|
422
|
+
.split(SEPARATORS)
|
|
423
|
+
.map((part) => withoutWrappers(part.trim()))
|
|
424
|
+
.filter(Boolean);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Every command a rule might see in a call: the whole text, its parts, and commands nested in a
|
|
428
|
+
* subshell, a substitution or a loop body, which deny and ask rules also reach (docs:
|
|
429
|
+
* permissions#compound-commands), each also without its redirections, which Claude Code checks
|
|
430
|
+
* apart from the rule (docs: permissions#redirections). Generous on purpose, so a rule counted as
|
|
431
|
+
* covering no call really covered none.
|
|
432
|
+
*/
|
|
433
|
+
function everyCommandIn(command) {
|
|
434
|
+
const variants = [command, withoutRedirections(command)];
|
|
435
|
+
return [
|
|
436
|
+
...new Set(variants.flatMap((text) => {
|
|
437
|
+
const parts = text
|
|
438
|
+
.replace(/\$\(|[`(){}]/g, "\n")
|
|
439
|
+
.split(SEPARATORS)
|
|
440
|
+
.map((part) => {
|
|
441
|
+
const words = part.trim().split(/\s+/);
|
|
442
|
+
while (KEYWORDS.has(words[0] ?? ""))
|
|
443
|
+
words.shift();
|
|
444
|
+
return withoutWrappers(words.join(" "));
|
|
445
|
+
});
|
|
446
|
+
return [withoutWrappers(text.trim()), ...parts];
|
|
447
|
+
})),
|
|
448
|
+
].filter(Boolean);
|
|
449
|
+
}
|
|
450
|
+
/** `2>&1`, `> out.txt`, `&>> log`, `< in`: removed with their target, before `&` is taken for a separator. */
|
|
451
|
+
function withoutRedirections(command) {
|
|
452
|
+
return command.replace(/\s*(?:\d*>>?\|?|&>>?|\d*<)&?\s*(?:'[^']*'|"[^"]*"|[^\s;&|]*)/g, "");
|
|
453
|
+
}
|
|
454
|
+
function withoutWrappers(command) {
|
|
455
|
+
const words = command.split(/\s+/);
|
|
456
|
+
for (;;) {
|
|
457
|
+
const first = words[0] ?? "";
|
|
458
|
+
if (/^\w+=/.test(first))
|
|
459
|
+
words.shift();
|
|
460
|
+
else if (first === "xargs" && words.length > 1 && !(words[1] ?? "").startsWith("-"))
|
|
461
|
+
words.shift();
|
|
462
|
+
else if (WRAPPERS.has(first) &&
|
|
463
|
+
words.length > 1 &&
|
|
464
|
+
!(first === "command" && words[1] === "-v")) {
|
|
465
|
+
words.shift();
|
|
466
|
+
while (/^(-\S+|\d+(\.\d+)?[smhd]?)$/.test(words[0] ?? ""))
|
|
467
|
+
words.shift();
|
|
468
|
+
}
|
|
469
|
+
else
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
return words.join(" ");
|
|
473
|
+
}
|
|
474
|
+
/** How many calls a rule covers, or null for kinds ccdesk does not match (paths, parameters, PowerShell). */
|
|
475
|
+
function matchCount(rule, calls) {
|
|
476
|
+
const matches = matcherFor(rule);
|
|
477
|
+
return matches ? calls.filter(matches).length : null;
|
|
478
|
+
}
|
|
479
|
+
/** Whether a rule covers a call, erring towards yes; null for kinds ccdesk does not match. */
|
|
480
|
+
function matcherFor({ list, rule }) {
|
|
481
|
+
const { tool, specifier } = parseRule(rule);
|
|
482
|
+
if (specifier === null) {
|
|
483
|
+
if (tool.startsWith("mcp__") && tool.split("__").length === 2)
|
|
484
|
+
return (c) => c.tool.startsWith(`${tool}__`);
|
|
485
|
+
// Monitor runs commands under the Bash rules (docs: tools-reference#configure-tools-with-permission-rules-and-hooks).
|
|
486
|
+
return (c) => namesTool(list, tool, c.tool) || (c.tool === "Monitor" && namesTool(list, tool, "Bash"));
|
|
487
|
+
}
|
|
488
|
+
if (tool === "Bash") {
|
|
489
|
+
const pattern = bashPattern(specifier);
|
|
490
|
+
if (pattern === null)
|
|
491
|
+
return null;
|
|
492
|
+
return (c) => c.commands?.some(pattern) === true;
|
|
493
|
+
}
|
|
494
|
+
if (tool === "WebFetch" && specifier.startsWith("domain:")) {
|
|
495
|
+
const host = wildcard(specifier.slice(7).replace(/\.$/, ""), "i");
|
|
496
|
+
return (c) => c.host !== undefined && host.test(c.host);
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
/** A Bash rule's command pattern (docs: permissions#wildcard-patterns); null for a parameter rule. */
|
|
501
|
+
function bashPattern(specifier) {
|
|
502
|
+
let pattern = specifier;
|
|
503
|
+
if (pattern.endsWith(":*") && !pattern.slice(0, -2).includes("*"))
|
|
504
|
+
pattern = `${pattern.slice(0, -2)} *`;
|
|
505
|
+
else if (/^[A-Za-z_]+:/.test(pattern))
|
|
506
|
+
return null;
|
|
507
|
+
const star = pattern.indexOf("*");
|
|
508
|
+
if (pattern.endsWith(" *") && star === pattern.length - 1) {
|
|
509
|
+
const prefix = pattern.slice(0, -2);
|
|
510
|
+
return (command) => command === prefix || command.startsWith(`${prefix} `);
|
|
511
|
+
}
|
|
512
|
+
const regex = wildcard(pattern, "s");
|
|
513
|
+
return (command) => regex.test(command);
|
|
514
|
+
}
|
|
@@ -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
|
+
}
|