ccdesk 0.0.0-stage → 0.1.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 +15 -0
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/dist/server/backups.js +226 -0
- package/dist/server/cli.js +92 -0
- package/dist/server/mutations.js +188 -0
- package/dist/server/open.js +139 -0
- package/dist/server/scan/context.js +139 -0
- package/dist/server/scan/env.js +23 -0
- package/dist/server/scan/files.js +390 -0
- package/dist/server/scan/index.js +266 -0
- package/dist/server/scan/instructions.js +285 -0
- package/dist/server/scan/inventory.js +360 -0
- package/dist/server/scan/mcp.js +255 -0
- package/dist/server/scan/memory.js +91 -0
- package/dist/server/scan/path-test.js +99 -0
- package/dist/server/scan/permissions.js +85 -0
- package/dist/server/scan/plugins.js +117 -0
- package/dist/server/scan/projects.js +100 -0
- package/dist/server/scan/settings.js +317 -0
- package/dist/server/scan/tools.js +107 -0
- package/dist/server/scan/usage.js +441 -0
- package/dist/server/server.js +258 -0
- package/dist/server/settings-edit.js +152 -0
- package/dist/server/watch.js +172 -0
- package/dist/server/write.js +195 -0
- package/dist/shared/builtin-tools.json +235 -0
- package/dist/shared/scopes.js +18 -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-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-BM4K7hSO.css +1 -0
- package/dist/ui/assets/index-CN3vkVjv.js +45 -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 +80 -4
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import {} from "../../shared/types.js";
|
|
6
|
+
import {} from "./env.js";
|
|
7
|
+
import { isDir, isRecord, leadsToSecret, listDir } from "./files.js";
|
|
8
|
+
import { readMemoryStore, storeDirName } from "./memory.js";
|
|
9
|
+
/** Directories Claude Code has been used in: its project stores, `~/.claude.json`, and the launch directory. */
|
|
10
|
+
export async function listProjects(env, claudeJson, launchDir) {
|
|
11
|
+
const byPath = new Map();
|
|
12
|
+
const storesRoot = join(env.claudeDir, "projects");
|
|
13
|
+
const knownPaths = isRecord(claudeJson?.projects) ? Object.keys(claudeJson.projects) : [];
|
|
14
|
+
for (const name of await listDir(storesRoot)) {
|
|
15
|
+
const storeDir = join(storesRoot, name);
|
|
16
|
+
if (!(await isDir(storeDir)))
|
|
17
|
+
continue;
|
|
18
|
+
const sessions = await newestFirst(storeDir, (await listDir(storeDir)).filter((f) => f.endsWith(".jsonl")));
|
|
19
|
+
const path = knownPaths.find((p) => storeDirName(p) === name) ??
|
|
20
|
+
(await sessionCwd(storeDir, sessions)) ??
|
|
21
|
+
null;
|
|
22
|
+
if (!path)
|
|
23
|
+
continue;
|
|
24
|
+
const store = await readMemoryStore(join(storeDir, "memory"), path);
|
|
25
|
+
byPath.set(path, {
|
|
26
|
+
path,
|
|
27
|
+
store,
|
|
28
|
+
exists: await isDir(path),
|
|
29
|
+
sessions: sessions.length,
|
|
30
|
+
lastActive: sessions[0] ? new Date(sessions[0].mtime).toISOString() : null,
|
|
31
|
+
memories: store?.memories.length ?? 0,
|
|
32
|
+
ephemeral: isEphemeral(env, path),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
for (const path of [...knownPaths, resolve(launchDir)]) {
|
|
36
|
+
if (byPath.has(path))
|
|
37
|
+
continue;
|
|
38
|
+
byPath.set(path, {
|
|
39
|
+
path,
|
|
40
|
+
store: null,
|
|
41
|
+
exists: await isDir(path),
|
|
42
|
+
sessions: 0,
|
|
43
|
+
lastActive: null,
|
|
44
|
+
memories: 0,
|
|
45
|
+
ephemeral: isEphemeral(env, path),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
49
|
+
}
|
|
50
|
+
function isEphemeral(env, path) {
|
|
51
|
+
return (path.startsWith("/tmp/") ||
|
|
52
|
+
path === "/tmp" ||
|
|
53
|
+
path.startsWith("/run/") ||
|
|
54
|
+
path.startsWith("/private/var/folders/") ||
|
|
55
|
+
path.startsWith("/var/folders/") ||
|
|
56
|
+
path.startsWith("/private/tmp/") ||
|
|
57
|
+
/[\\/]AppData[\\/]Local[\\/]Temp[\\/]/i.test(path) ||
|
|
58
|
+
path.startsWith(join(env.home, ".config", "Claude", "scratch-workspaces")));
|
|
59
|
+
}
|
|
60
|
+
/** Transcripts by last change, newest first; stat calls run in batches rather than one by one. */
|
|
61
|
+
async function newestFirst(dir, files) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (let i = 0; i < files.length; i += 64) {
|
|
64
|
+
const batch = await Promise.all(files.slice(i, i + 64).map(async (file) => {
|
|
65
|
+
const info = await stat(join(dir, file)).catch(() => null);
|
|
66
|
+
return info ? { file, mtime: info.mtimeMs } : null;
|
|
67
|
+
}));
|
|
68
|
+
out.push(...batch.filter((t) => t !== null));
|
|
69
|
+
}
|
|
70
|
+
return out.sort((a, b) => b.mtime - a.mtime);
|
|
71
|
+
}
|
|
72
|
+
/** The `cwd` recorded in the newest transcripts, for store names that do not map back cleanly. */
|
|
73
|
+
async function sessionCwd(storeDir, sessions) {
|
|
74
|
+
for (const { file } of sessions.slice(0, 3)) {
|
|
75
|
+
if (await leadsToSecret(join(storeDir, file)))
|
|
76
|
+
continue;
|
|
77
|
+
const input = createReadStream(join(storeDir, file), { encoding: "utf8" });
|
|
78
|
+
const lines = createInterface({ input });
|
|
79
|
+
let read = 0;
|
|
80
|
+
try {
|
|
81
|
+
for await (const line of lines) {
|
|
82
|
+
if (++read > 50)
|
|
83
|
+
break;
|
|
84
|
+
if (!line.includes('"cwd"'))
|
|
85
|
+
continue;
|
|
86
|
+
const parsed = JSON.parse(line);
|
|
87
|
+
if (isRecord(parsed) && typeof parsed.cwd === "string")
|
|
88
|
+
return parsed.cwd;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// A truncated or malformed transcript line: try the next file.
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
lines.close();
|
|
96
|
+
input.destroy();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { allowedScopes, ignoredReason } from "../../shared/scopes.js";
|
|
4
|
+
import {} from "../../shared/types.js";
|
|
5
|
+
import {} from "./env.js";
|
|
6
|
+
import { isRecord, listDir, mainWorktreeRoot, readJsonObject, redactSecrets } from "./files.js";
|
|
7
|
+
/** Lowest precedence first, so later layers override earlier ones. */
|
|
8
|
+
const PRECEDENCE = ["user", "project", "local", "managed"];
|
|
9
|
+
/** Keys whose whole value comes from the highest layer that sets it, even though they are lists. */
|
|
10
|
+
const WHOLE_VALUE_KEYS = new Set(["fallbackModel", "modelPicker"]);
|
|
11
|
+
/** Maps whose entries are taken whole from the highest layer that has them, never field by field. */
|
|
12
|
+
const WHOLE_ENTRY_KEYS = new Set(["managedMcpServers", "extraKnownMarketplaces", "modelSettings"]);
|
|
13
|
+
const ANY_FILE = ["user", "project", "local", "managed"];
|
|
14
|
+
/**
|
|
15
|
+
* Keys, some nested, where Claude Code honours the more restrictive value from some files even over
|
|
16
|
+
* a higher file (docs: settings#exceptions-to-managed-settings-precedence).
|
|
17
|
+
*/
|
|
18
|
+
const STRICTEST = {
|
|
19
|
+
disableClaudeAiConnectors: { ladder: [false, true], from: ANY_FILE },
|
|
20
|
+
isolatePeerMachines: { ladder: [false, true], from: ANY_FILE },
|
|
21
|
+
enableArtifact: { ladder: [true, false], from: ANY_FILE },
|
|
22
|
+
disableArtifact: { ladder: [false, true], from: ANY_FILE },
|
|
23
|
+
"permissions.blockReadsOutsideWorkingDirectories": { ladder: [false, true], from: ANY_FILE },
|
|
24
|
+
"autoMode.classifyAllShell": { ladder: [false, true], from: ["user"] },
|
|
25
|
+
maxEffortLevel: { ladder: ["max", "xhigh", "high", "medium", "low"], from: ANY_FILE },
|
|
26
|
+
useAutoModeDuringPlan: { ladder: [true, false], from: ["managed", "user", "local"] },
|
|
27
|
+
syncClaudeAiSkills: { ladder: [true, false], from: ["managed", "user", "local"] },
|
|
28
|
+
syncClaudeAiPlugins: { ladder: [true, false], from: ["managed", "user", "local"] },
|
|
29
|
+
remoteControlAtStartup: { ladder: [true, false], from: ["project", "local"], onlyTightens: true },
|
|
30
|
+
crossSessionInbound: {
|
|
31
|
+
ladder: ["accept", "hold", "refuse"],
|
|
32
|
+
from: ["project", "local"],
|
|
33
|
+
onlyTightens: true,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Why Claude Code ignores a `permissions.defaultMode` from this file, or undefined when it may apply:
|
|
38
|
+
* a repository's settings cannot start sessions in auto or bypass mode (docs: permission-modes).
|
|
39
|
+
*/
|
|
40
|
+
export function ignoredModeReason(scope, mode) {
|
|
41
|
+
if ((scope === "project" || scope === "local") &&
|
|
42
|
+
(mode === "auto" || mode === "bypassPermissions"))
|
|
43
|
+
return `${mode} takes effect only from user or managed settings`;
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
/** A file's value for `key` without nested fields Claude Code ignores from that file, and what was dropped. */
|
|
47
|
+
function withoutIgnoredFields(key, scope, value) {
|
|
48
|
+
if (key !== "permissions" || !isRecord(value))
|
|
49
|
+
return { value };
|
|
50
|
+
const reason = ignoredModeReason(scope, value.defaultMode);
|
|
51
|
+
if (!reason)
|
|
52
|
+
return { value };
|
|
53
|
+
const { defaultMode, ...rest } = value;
|
|
54
|
+
return { value: rest, dropped: { value: { defaultMode }, reason } };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The value that wins for a key with a strictness exception, given its values lowest precedence
|
|
58
|
+
* first; undefined when plain precedence decides.
|
|
59
|
+
*/
|
|
60
|
+
function strictWinner(strict, values) {
|
|
61
|
+
const rank = (value) => strict.ladder.indexOf(value);
|
|
62
|
+
const honoured = values.filter((v) => strict.from.includes(v.scope) && rank(v.value) >= 0);
|
|
63
|
+
const strictest = honoured.reduce((best, v) => (!best || rank(v.value) > rank(best.value) ? v : best), undefined);
|
|
64
|
+
const top = strict.onlyTightens
|
|
65
|
+
? values.filter((v) => !strict.from.includes(v.scope)).at(-1)
|
|
66
|
+
: values.at(-1);
|
|
67
|
+
const baseline = top ? rank(top.value) : 0;
|
|
68
|
+
if (strictest && rank(strictest.value) > baseline)
|
|
69
|
+
return { winner: strictest };
|
|
70
|
+
if (strict.onlyTightens && honoured.length > 0)
|
|
71
|
+
return { winner: top ?? null };
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
function strictNote(strict) {
|
|
75
|
+
const files = strict.from.length === 4 ? "any" : strict.from.join(" or ");
|
|
76
|
+
return strict.onlyTightens
|
|
77
|
+
? `Claude Code takes this key from ${files} settings only when it is stricter`
|
|
78
|
+
: `Claude Code keeps the strictest value of this key, from ${files} settings`;
|
|
79
|
+
}
|
|
80
|
+
/** Combines one key's values from two layers, `over` being the higher one. */
|
|
81
|
+
function mergeKey(key, base, over) {
|
|
82
|
+
if (WHOLE_VALUE_KEYS.has(key))
|
|
83
|
+
return over;
|
|
84
|
+
if (WHOLE_ENTRY_KEYS.has(key) && isRecord(base) && isRecord(over))
|
|
85
|
+
return { ...base, ...over };
|
|
86
|
+
return deepMerge(base, over);
|
|
87
|
+
}
|
|
88
|
+
/** Combines two whole settings files, `over` being the higher one. */
|
|
89
|
+
function mergeSettings(base, over) {
|
|
90
|
+
const out = { ...base };
|
|
91
|
+
for (const [key, value] of Object.entries(over))
|
|
92
|
+
out[key] = key in out ? mergeKey(key, out[key], value) : value;
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Where Claude Code keeps `.claude/settings.local.json` for a session started in `dir`: the
|
|
97
|
+
* repository root (the main checkout's root in a worktree), except outside git, when that root is
|
|
98
|
+
* the home directory, on Windows, or when the root, its .git or its .claude is not yours
|
|
99
|
+
* (docs: settings#where-claude-code-looks-for-each-file).
|
|
100
|
+
*/
|
|
101
|
+
async function localSettingsDir(env, dir) {
|
|
102
|
+
if (env.platform === "win32")
|
|
103
|
+
return dir;
|
|
104
|
+
const root = await mainWorktreeRoot(dir);
|
|
105
|
+
if (!root || root === env.home)
|
|
106
|
+
return dir;
|
|
107
|
+
const uid = process.getuid?.();
|
|
108
|
+
for (const path of [root, join(root, ".git"), join(root, ".claude")]) {
|
|
109
|
+
const info = await lstat(path).catch(() => null);
|
|
110
|
+
if (info && uid !== undefined && info.uid !== uid)
|
|
111
|
+
return dir;
|
|
112
|
+
}
|
|
113
|
+
return root;
|
|
114
|
+
}
|
|
115
|
+
/** The settings files Claude Code reads in `dir`, highest precedence first. */
|
|
116
|
+
export async function settingsFiles(env, dir) {
|
|
117
|
+
const localDir = await localSettingsDir(env, dir);
|
|
118
|
+
const userFile = join(env.claudeDir, "settings.json");
|
|
119
|
+
const files = [
|
|
120
|
+
{ scope: "managed", path: join(env.managedDir, "managed-settings.json") },
|
|
121
|
+
{ scope: "local", path: join(localDir, ".claude", "settings.local.json") },
|
|
122
|
+
];
|
|
123
|
+
// Before v2.1.211 the local file lived in the start directory; Claude Code still reads one left there.
|
|
124
|
+
if (localDir !== dir)
|
|
125
|
+
files.push({ scope: "local", path: join(dir, ".claude", "settings.local.json"), legacy: true });
|
|
126
|
+
const projectFile = join(dir, ".claude", "settings.json");
|
|
127
|
+
// In the home directory, .claude/settings.json is the user file, not a project layer.
|
|
128
|
+
if (projectFile !== userFile)
|
|
129
|
+
files.push({ scope: "project", path: projectFile });
|
|
130
|
+
files.push({ scope: "user", path: userFile });
|
|
131
|
+
return files;
|
|
132
|
+
}
|
|
133
|
+
/** Settings files that apply in `dir`, highest precedence first. */
|
|
134
|
+
export async function readSettingsLayers(env, dir) {
|
|
135
|
+
const layers = [];
|
|
136
|
+
for (const file of await settingsFiles(env, dir)) {
|
|
137
|
+
if (file.scope === "managed") {
|
|
138
|
+
layers.push(await readManagedLayer(env, file.path));
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const result = await readJsonObject(file.path);
|
|
142
|
+
if (file.legacy && !result.ok && result.missing)
|
|
143
|
+
continue;
|
|
144
|
+
const layer = { scope: file.scope, path: file.path, exists: true, data: null };
|
|
145
|
+
if (file.legacy)
|
|
146
|
+
layer.legacy = true;
|
|
147
|
+
if (result.ok)
|
|
148
|
+
layer.data = redactSecrets(result.data);
|
|
149
|
+
else if (result.missing)
|
|
150
|
+
layer.exists = false;
|
|
151
|
+
else
|
|
152
|
+
layer.error = result.error;
|
|
153
|
+
layers.push(layer);
|
|
154
|
+
}
|
|
155
|
+
return layers;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* managed-settings.json, then every *.json in managed-settings.d in alphabetical order, merged, with
|
|
159
|
+
* nothing hidden: for checks such as managedMcpServers that must see header values. Never sent to the
|
|
160
|
+
* page as is.
|
|
161
|
+
*/
|
|
162
|
+
export async function readManagedSettings(env) {
|
|
163
|
+
const dropInDir = join(env.managedDir, "managed-settings.d");
|
|
164
|
+
const dropIns = (await listDir(dropInDir))
|
|
165
|
+
.filter((name) => name.endsWith(".json") && !name.startsWith("."))
|
|
166
|
+
.map((name) => join(dropInDir, name));
|
|
167
|
+
const sources = [];
|
|
168
|
+
const errors = [];
|
|
169
|
+
let data = null;
|
|
170
|
+
for (const source of [join(env.managedDir, "managed-settings.json"), ...dropIns]) {
|
|
171
|
+
const result = await readJsonObject(source);
|
|
172
|
+
if (!result.ok) {
|
|
173
|
+
if (!result.missing)
|
|
174
|
+
errors.push(`${source}: ${result.error}`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
sources.push(source);
|
|
178
|
+
data = data ? mergeSettings(data, result.data) : result.data;
|
|
179
|
+
}
|
|
180
|
+
return { data, sources, dropIns, errors };
|
|
181
|
+
}
|
|
182
|
+
async function readManagedLayer(env, path) {
|
|
183
|
+
const { data, sources, dropIns, errors } = await readManagedSettings(env);
|
|
184
|
+
const layer = { scope: "managed", path, exists: false, data: null };
|
|
185
|
+
if (data)
|
|
186
|
+
layer.data = redactSecrets(data);
|
|
187
|
+
layer.exists = sources.length > 0 || errors.length > 0;
|
|
188
|
+
if (sources.length > 1 || dropIns.length > 0)
|
|
189
|
+
layer.sources = sources;
|
|
190
|
+
if (errors.length > 0)
|
|
191
|
+
layer.error = errors.join("; ");
|
|
192
|
+
return layer;
|
|
193
|
+
}
|
|
194
|
+
/** The value Claude Code ends up with for each top-level key, and where it came from. */
|
|
195
|
+
export function effectiveSettings(layers) {
|
|
196
|
+
// Layers are highest first; a second layer of one scope (a legacy local file) sits under the first.
|
|
197
|
+
const byScope = new Map();
|
|
198
|
+
for (const layer of layers) {
|
|
199
|
+
if (!layer.data)
|
|
200
|
+
continue;
|
|
201
|
+
const higher = byScope.get(layer.scope);
|
|
202
|
+
byScope.set(layer.scope, higher ? mergeSettings(layer.data, higher) : layer.data);
|
|
203
|
+
}
|
|
204
|
+
const keys = new Set([...byScope.values()].flatMap((data) => Object.keys(data)));
|
|
205
|
+
const out = [];
|
|
206
|
+
for (const key of [...keys].sort()) {
|
|
207
|
+
const allowed = allowedScopes(key);
|
|
208
|
+
const present = PRECEDENCE.filter((scope) => byScope.get(scope)?.[key] !== undefined);
|
|
209
|
+
const valueIn = (scope) => byScope.get(scope)?.[key];
|
|
210
|
+
const ignored = present
|
|
211
|
+
.filter((scope) => !allowed.includes(scope))
|
|
212
|
+
.map((scope) => ({ scope, value: valueIn(scope), reason: ignoredReason(key) }));
|
|
213
|
+
let defined = present.filter((scope) => allowed.includes(scope));
|
|
214
|
+
// A managed availableModels list is applied as-is; other files' entries are ignored.
|
|
215
|
+
if (key === "availableModels" && defined.includes("managed")) {
|
|
216
|
+
for (const scope of defined.filter((s) => s !== "managed"))
|
|
217
|
+
ignored.push({
|
|
218
|
+
scope,
|
|
219
|
+
value: valueIn(scope),
|
|
220
|
+
reason: "Managed settings set availableModels, so Claude Code uses that list as-is",
|
|
221
|
+
});
|
|
222
|
+
defined = ["managed"];
|
|
223
|
+
}
|
|
224
|
+
if (defined.length === 0) {
|
|
225
|
+
out.push({ key, value: undefined, from: [], merged: false, overridden: [], ignored });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const values = defined.map((scope) => {
|
|
229
|
+
const { value, dropped } = withoutIgnoredFields(key, scope, valueIn(scope));
|
|
230
|
+
if (dropped)
|
|
231
|
+
ignored.push({ scope, ...dropped });
|
|
232
|
+
return { scope, value };
|
|
233
|
+
});
|
|
234
|
+
const top = values[values.length - 1];
|
|
235
|
+
if (!top)
|
|
236
|
+
continue;
|
|
237
|
+
const mergeable = !WHOLE_VALUE_KEYS.has(key) &&
|
|
238
|
+
values.every((v) => Array.isArray(v.value) || isRecord(v.value));
|
|
239
|
+
const strict = STRICTEST[key];
|
|
240
|
+
const decided = strict && strictWinner(strict, values);
|
|
241
|
+
if (strict && decided) {
|
|
242
|
+
const { winner } = decided;
|
|
243
|
+
out.push({
|
|
244
|
+
key,
|
|
245
|
+
value: winner?.value,
|
|
246
|
+
from: winner ? [winner.scope] : [],
|
|
247
|
+
merged: false,
|
|
248
|
+
overridden: values.filter((v) => v !== winner).reverse(),
|
|
249
|
+
ignored,
|
|
250
|
+
note: strictNote(strict),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
else if (mergeable && values.length > 1) {
|
|
254
|
+
out.push({
|
|
255
|
+
key,
|
|
256
|
+
value: withStrictFields(key, values.slice(1).reduce((acc, v) => mergeKey(key, acc, v.value), values[0]?.value), values),
|
|
257
|
+
from: defined,
|
|
258
|
+
merged: true,
|
|
259
|
+
overridden: [],
|
|
260
|
+
ignored,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
else if (isRecord(top.value)) {
|
|
264
|
+
out.push({
|
|
265
|
+
key,
|
|
266
|
+
value: withStrictFields(key, top.value, values),
|
|
267
|
+
from: [top.scope],
|
|
268
|
+
merged: false,
|
|
269
|
+
overridden: values.slice(0, -1).reverse(),
|
|
270
|
+
ignored,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
out.push({
|
|
275
|
+
key,
|
|
276
|
+
value: top.value,
|
|
277
|
+
from: [top.scope],
|
|
278
|
+
merged: false,
|
|
279
|
+
overridden: values.slice(0, -1).reverse(),
|
|
280
|
+
ignored,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
/** An object setting with each nested strictness exception (such as permissions.x) applied. */
|
|
287
|
+
function withStrictFields(key, value, values) {
|
|
288
|
+
if (!isRecord(value))
|
|
289
|
+
return value;
|
|
290
|
+
let out = value;
|
|
291
|
+
for (const [path, strict] of Object.entries(STRICTEST)) {
|
|
292
|
+
const [head, field] = path.split(".");
|
|
293
|
+
if (head !== key || !field)
|
|
294
|
+
continue;
|
|
295
|
+
const fieldValues = values
|
|
296
|
+
.filter((v) => isRecord(v.value) && v.value[field] !== undefined)
|
|
297
|
+
.map((v) => ({ scope: v.scope, value: v.value[field] }));
|
|
298
|
+
const decided = strictWinner(strict, fieldValues);
|
|
299
|
+
if (decided)
|
|
300
|
+
out = { ...out, [field]: decided.winner?.value };
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
/** Objects merge by key, lists concatenate without duplicates, anything else is replaced. */
|
|
305
|
+
function deepMerge(base, over) {
|
|
306
|
+
if (Array.isArray(base) && Array.isArray(over)) {
|
|
307
|
+
const seen = new Set(base.map((v) => JSON.stringify(v)));
|
|
308
|
+
return [...base, ...over.filter((v) => !seen.has(JSON.stringify(v)))];
|
|
309
|
+
}
|
|
310
|
+
if (isRecord(base) && isRecord(over)) {
|
|
311
|
+
const out = { ...base };
|
|
312
|
+
for (const [k, v] of Object.entries(over))
|
|
313
|
+
out[k] = k in out ? deepMerge(out[k], v) : v;
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
return over;
|
|
317
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import table from "../../shared/builtin-tools.json" with { type: "json" };
|
|
2
|
+
import {} from "../../shared/types.js";
|
|
3
|
+
import { wildcard } from "./files.js";
|
|
4
|
+
/**
|
|
5
|
+
* The other tools a rule for some uses covers: Bash rules apply to Monitor, Claude Code checks file
|
|
6
|
+
* paths against Edit and Read rules only, and a Read deny also blocks Edit and Write on that path
|
|
7
|
+
* (docs: tools-reference#configure-tools-with-permission-rules-and-hooks, permissions#read-and-edit).
|
|
8
|
+
*/
|
|
9
|
+
function coveredTools(tool, list) {
|
|
10
|
+
if (tool === "Bash")
|
|
11
|
+
return ["Bash", "Monitor"];
|
|
12
|
+
if (tool === "Edit")
|
|
13
|
+
return ["Edit", "Write", "NotebookEdit"];
|
|
14
|
+
if (tool === "Read")
|
|
15
|
+
return list === "deny"
|
|
16
|
+
? ["Read", "Grep", "Glob", "LSP", "Edit", "Write"]
|
|
17
|
+
: ["Read", "Grep", "Glob", "LSP"];
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
/** Path rules Claude Code accepts but never consults; Edit and Read rules do the job instead. */
|
|
21
|
+
const UNCHECKED_PATH_RULE_TOOLS = new Set(["Write", "NotebookEdit", "Glob", "MultiEdit"]);
|
|
22
|
+
/**
|
|
23
|
+
* No deny or ask rule and no PreToolUse hook applies to it while any other tool remains
|
|
24
|
+
* (docs: tools-reference#endconversation-tool-behavior).
|
|
25
|
+
*/
|
|
26
|
+
const EXEMPT = "EndConversation";
|
|
27
|
+
/** `Tool` or `Tool(specifier)`; `Tool(*)` names the whole tool too (docs: permissions#permission-rule-syntax). */
|
|
28
|
+
export function parseRule(rule) {
|
|
29
|
+
const open = rule.indexOf("(");
|
|
30
|
+
if (open === -1 || !rule.endsWith(")"))
|
|
31
|
+
return { tool: rule.trim(), specifier: null };
|
|
32
|
+
const specifier = rule.slice(open + 1, -1);
|
|
33
|
+
return { tool: rule.slice(0, open).trim(), specifier: specifier === "*" ? null : specifier };
|
|
34
|
+
}
|
|
35
|
+
/** Deny and ask rules may glob the tool name; allow rules name it exactly (docs: permissions#tool-name-wildcards). */
|
|
36
|
+
export function namesTool(list, tool, name) {
|
|
37
|
+
return list === "allow" || !tool.includes("*") ? tool === name : wildcard(tool).test(name);
|
|
38
|
+
}
|
|
39
|
+
/** A hook matcher, evaluated as the docs describe (docs: hooks#matcher-patterns). */
|
|
40
|
+
function matcherCovers(matcher, name) {
|
|
41
|
+
if (matcher === undefined || matcher === "" || matcher === "*")
|
|
42
|
+
return true;
|
|
43
|
+
if (/^[\w\s,|-]+$/.test(matcher))
|
|
44
|
+
return matcher.split(/[|,]/).some((part) => part.trim() === name);
|
|
45
|
+
try {
|
|
46
|
+
return new RegExp(matcher).test(name);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const ORDER = ["deny", "ask", "allow"];
|
|
53
|
+
/** Every built-in tool, with the rules and hooks that apply to it here. */
|
|
54
|
+
export function builtinTools(permissions, hooks) {
|
|
55
|
+
const rules = permissions.flatMap((p) => p.kind === "rule" && p.inEffect ? [{ list: p.list, rule: p.rule, scope: p.scope }] : []);
|
|
56
|
+
const preToolUse = hooks.filter((h) => h.active && h.event === "PreToolUse");
|
|
57
|
+
const tools = table.tools.map(({ name, description, asks }) => {
|
|
58
|
+
const whole = [];
|
|
59
|
+
const scoped = [];
|
|
60
|
+
for (const r of rules) {
|
|
61
|
+
if (name === EXEMPT)
|
|
62
|
+
break;
|
|
63
|
+
const { tool, specifier } = parseRule(r.rule);
|
|
64
|
+
if (specifier === null) {
|
|
65
|
+
if (namesTool(r.list, tool, name))
|
|
66
|
+
whole.push(r);
|
|
67
|
+
}
|
|
68
|
+
else if (tool === name && UNCHECKED_PATH_RULE_TOOLS.has(tool)) {
|
|
69
|
+
scoped.push({ ...r, unchecked: true });
|
|
70
|
+
}
|
|
71
|
+
else if (tool === name || coveredTools(tool, r.list).includes(name))
|
|
72
|
+
scoped.push(r);
|
|
73
|
+
}
|
|
74
|
+
const decidedBy = ORDER.map((list) => whole.find((r) => r.list === list)).find(Boolean);
|
|
75
|
+
const tool = {
|
|
76
|
+
name,
|
|
77
|
+
description,
|
|
78
|
+
asks,
|
|
79
|
+
verdict: decidedBy
|
|
80
|
+
? { deny: "denied", ask: "asks", allow: "allowed" }[decidedBy.list]
|
|
81
|
+
: "default",
|
|
82
|
+
scoped: scoped.sort((a, b) => ORDER.indexOf(a.list) - ORDER.indexOf(b.list)),
|
|
83
|
+
hooks: name === EXEMPT ? 0 : preToolUse.filter((h) => matcherCovers(h.matcher, name)).length,
|
|
84
|
+
};
|
|
85
|
+
if (decidedBy)
|
|
86
|
+
tool.decidedBy = decidedBy;
|
|
87
|
+
const removedBy = whole.filter((r) => r.list === "deny");
|
|
88
|
+
if (removedBy.length > 0)
|
|
89
|
+
tool.removedBy = removedBy;
|
|
90
|
+
return tool;
|
|
91
|
+
});
|
|
92
|
+
// EndConversation ignores rules while any other tool remains. When deny rules remove every other
|
|
93
|
+
// tool and match it too, as "*" does, it goes as well, unless an allow rule names it.
|
|
94
|
+
const exempt = tools.find((t) => t.name === EXEMPT);
|
|
95
|
+
const othersGone = tools.every((t) => t.name === EXEMPT || t.verdict === "denied");
|
|
96
|
+
const denies = rules.filter((r) => {
|
|
97
|
+
const { tool, specifier } = parseRule(r.rule);
|
|
98
|
+
return r.list === "deny" && specifier === null && namesTool("deny", tool, EXEMPT);
|
|
99
|
+
});
|
|
100
|
+
const kept = rules.some((r) => r.list === "allow" && r.rule === EXEMPT);
|
|
101
|
+
if (exempt && othersGone && denies[0] && !kept) {
|
|
102
|
+
exempt.verdict = "denied";
|
|
103
|
+
exempt.decidedBy = denies[0];
|
|
104
|
+
exempt.removedBy = denies;
|
|
105
|
+
}
|
|
106
|
+
return tools;
|
|
107
|
+
}
|