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,124 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { HIGHEST_FIRST } from "../../shared/scopes.js";
|
|
3
|
+
import {} from "../../shared/types.js";
|
|
4
|
+
import {} from "./env.js";
|
|
5
|
+
import { isRecord, isWithin, readJsonObject, resolvedPath, stringField } from "./files.js";
|
|
6
|
+
function manifestPath(plugin) {
|
|
7
|
+
return join(plugin.installPath, ".claude-plugin", "plugin.json");
|
|
8
|
+
}
|
|
9
|
+
async function manifest(env, plugin) {
|
|
10
|
+
const result = await readJsonObject(env, manifestPath(plugin));
|
|
11
|
+
return result.ok ? result.data : {};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The prefix of a plugin's skills, commands and agents: the manifest's name, which wins over the
|
|
15
|
+
* name the plugin is enabled under (docs: plugins-reference#name, #metadata-precedence).
|
|
16
|
+
*/
|
|
17
|
+
export async function pluginNamespace(env, plugin) {
|
|
18
|
+
return stringField(await manifest(env, plugin), "name") || (plugin.id.split("@")[0] ?? plugin.id);
|
|
19
|
+
}
|
|
20
|
+
/** Installed plugins that apply in `projectDir`, with whether each is enabled and by which file. */
|
|
21
|
+
export async function readPlugins(env, layers, projectDir, gitRoot) {
|
|
22
|
+
const installed = await readJsonObject(env, join(env.claudeDir, "plugins", "installed_plugins.json"));
|
|
23
|
+
if (!installed.ok || !isRecord(installed.data.plugins))
|
|
24
|
+
return [];
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const [id, installs] of Object.entries(installed.data.plugins)) {
|
|
27
|
+
if (!Array.isArray(installs))
|
|
28
|
+
continue;
|
|
29
|
+
// A project or local install applies only in its own project; it beats a user install there.
|
|
30
|
+
const records = installs.filter((i) => isRecord(i));
|
|
31
|
+
const install = records.find((i) => i.projectPath === projectDir || (gitRoot && i.projectPath === gitRoot)) ??
|
|
32
|
+
records.find((i) => typeof i.projectPath !== "string");
|
|
33
|
+
if (!install)
|
|
34
|
+
continue;
|
|
35
|
+
const plugin = {
|
|
36
|
+
id,
|
|
37
|
+
version: stringField(install, "version"),
|
|
38
|
+
scope: stringField(install, "scope"),
|
|
39
|
+
installPath: stringField(install, "installPath"),
|
|
40
|
+
updated: stringField(install, "lastUpdated") || null,
|
|
41
|
+
enabled: true,
|
|
42
|
+
note: "",
|
|
43
|
+
};
|
|
44
|
+
const setting = HIGHEST_FIRST.flatMap((scope) => layers
|
|
45
|
+
.filter((l) => l.scope === scope)
|
|
46
|
+
.map((l) => l.data?.enabledPlugins)
|
|
47
|
+
.filter(isRecord)
|
|
48
|
+
.filter((flags) => flags[id] !== undefined)
|
|
49
|
+
.map((flags) => ({ scope, value: flags[id] })))[0];
|
|
50
|
+
if (setting) {
|
|
51
|
+
plugin.enabled = setting.value !== false;
|
|
52
|
+
plugin.enabledIn = setting.scope;
|
|
53
|
+
plugin.note = `${plugin.enabled ? "Enabled" : "Turned off"} in ${setting.scope} settings`;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
plugin.enabled = (await manifest(env, plugin)).defaultEnabled !== false;
|
|
57
|
+
plugin.note = plugin.enabled
|
|
58
|
+
? "On by default (no enabledPlugins entry)"
|
|
59
|
+
: "Off by default (no enabledPlugins entry)";
|
|
60
|
+
}
|
|
61
|
+
out.push(plugin);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** A path from a manifest, if it stays inside the plugin once links are followed. */
|
|
66
|
+
async function inside(plugin, path) {
|
|
67
|
+
const target = resolve(plugin.installPath, path);
|
|
68
|
+
const root = await resolvedPath(plugin.installPath);
|
|
69
|
+
return isWithin(root, await resolvedPath(target)) ? target : null;
|
|
70
|
+
}
|
|
71
|
+
/** The paths a manifest key names (one path or a list), inside the plugin; null when unset. */
|
|
72
|
+
export async function manifestPaths(env, plugin, key) {
|
|
73
|
+
const value = (await manifest(env, plugin))[key];
|
|
74
|
+
const list = typeof value === "string" ? [value] : Array.isArray(value) ? value : null;
|
|
75
|
+
if (!list)
|
|
76
|
+
return null;
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const item of list) {
|
|
79
|
+
if (typeof item !== "string")
|
|
80
|
+
continue;
|
|
81
|
+
const path = await inside(plugin, item);
|
|
82
|
+
if (path)
|
|
83
|
+
out.push(path);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** The value of a manifest key that is a path, an inline object, or a list of either. */
|
|
88
|
+
export async function manifestConfigs(env, plugin, key) {
|
|
89
|
+
const value = (await manifest(env, plugin))[key];
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const item of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
|
|
92
|
+
if (isRecord(item))
|
|
93
|
+
out.push({ value: item, path: manifestPath(plugin) });
|
|
94
|
+
if (typeof item !== "string")
|
|
95
|
+
continue;
|
|
96
|
+
const path = await inside(plugin, item);
|
|
97
|
+
const file = path ? await readJsonObject(env, path) : null;
|
|
98
|
+
if (path && file?.ok)
|
|
99
|
+
out.push({ value: file.data[key] ?? file.data, path });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/** The manifest's `commands` object form: command name to a file or inline content. */
|
|
104
|
+
export async function manifestCommandMap(env, plugin) {
|
|
105
|
+
const value = (await manifest(env, plugin)).commands;
|
|
106
|
+
if (!isRecord(value))
|
|
107
|
+
return [];
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
110
|
+
if (!isRecord(entry))
|
|
111
|
+
continue;
|
|
112
|
+
const source = typeof entry.source === "string" ? await inside(plugin, entry.source) : null;
|
|
113
|
+
const content = typeof entry.content === "string" ? entry.content : null;
|
|
114
|
+
if (!source && content === null)
|
|
115
|
+
continue;
|
|
116
|
+
out.push({
|
|
117
|
+
name,
|
|
118
|
+
path: source ?? manifestPath(plugin),
|
|
119
|
+
description: stringField(entry, "description"),
|
|
120
|
+
text: content,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
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 { storeDirName } from "../../shared/memory.js";
|
|
6
|
+
import {} from "../../shared/types.js";
|
|
7
|
+
import {} from "./env.js";
|
|
8
|
+
import { isCredentials, isDir, isRecord, listDir } from "./files.js";
|
|
9
|
+
import { readMemoryStore } from "./memory.js";
|
|
10
|
+
/** Directories Claude Code has been used in: its project stores, `~/.claude.json`, and the launch directory. */
|
|
11
|
+
export async function listProjects(env, claudeJson, launchDir) {
|
|
12
|
+
const byPath = new Map();
|
|
13
|
+
const storesRoot = join(env.claudeDir, "projects");
|
|
14
|
+
const knownPaths = isRecord(claudeJson?.projects) ? Object.keys(claudeJson.projects) : [];
|
|
15
|
+
for (const name of await listDir(storesRoot)) {
|
|
16
|
+
const storeDir = join(storesRoot, name);
|
|
17
|
+
if (!(await isDir(storeDir)))
|
|
18
|
+
continue;
|
|
19
|
+
const sessions = await newestFirst(storeDir, (await listDir(storeDir)).filter((f) => f.endsWith(".jsonl")));
|
|
20
|
+
const path = knownPaths.find((p) => storeDirName(p) === name) ??
|
|
21
|
+
(await sessionCwd(env, storeDir, sessions)) ??
|
|
22
|
+
null;
|
|
23
|
+
if (!path)
|
|
24
|
+
continue;
|
|
25
|
+
const store = await readMemoryStore(env, join(storeDir, "memory"), path);
|
|
26
|
+
byPath.set(path, {
|
|
27
|
+
path,
|
|
28
|
+
store,
|
|
29
|
+
exists: await isDir(path),
|
|
30
|
+
sessions: sessions.length,
|
|
31
|
+
lastActive: sessions[0] ? new Date(sessions[0].mtime).toISOString() : null,
|
|
32
|
+
memories: store?.memories.length ?? 0,
|
|
33
|
+
ephemeral: isEphemeral(env, path),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
for (const path of [...knownPaths, resolve(launchDir)]) {
|
|
37
|
+
if (byPath.has(path))
|
|
38
|
+
continue;
|
|
39
|
+
byPath.set(path, {
|
|
40
|
+
path,
|
|
41
|
+
store: null,
|
|
42
|
+
exists: await isDir(path),
|
|
43
|
+
sessions: 0,
|
|
44
|
+
lastActive: null,
|
|
45
|
+
memories: 0,
|
|
46
|
+
ephemeral: isEphemeral(env, path),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
50
|
+
}
|
|
51
|
+
function isEphemeral(env, path) {
|
|
52
|
+
return (path.startsWith("/tmp/") ||
|
|
53
|
+
path === "/tmp" ||
|
|
54
|
+
path.startsWith("/run/") ||
|
|
55
|
+
path.startsWith("/private/var/folders/") ||
|
|
56
|
+
path.startsWith("/var/folders/") ||
|
|
57
|
+
path.startsWith("/private/tmp/") ||
|
|
58
|
+
/[\\/]AppData[\\/]Local[\\/]Temp[\\/]/i.test(path) ||
|
|
59
|
+
path.startsWith(join(env.home, ".config", "Claude", "scratch-workspaces")));
|
|
60
|
+
}
|
|
61
|
+
/** Transcripts by last change, newest first; stat calls run in batches rather than one by one. */
|
|
62
|
+
async function newestFirst(dir, files) {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (let i = 0; i < files.length; i += 64) {
|
|
65
|
+
const batch = await Promise.all(files.slice(i, i + 64).map(async (file) => {
|
|
66
|
+
const info = await stat(join(dir, file)).catch(() => null);
|
|
67
|
+
return info ? { file, mtime: info.mtimeMs } : null;
|
|
68
|
+
}));
|
|
69
|
+
out.push(...batch.filter((t) => t !== null));
|
|
70
|
+
}
|
|
71
|
+
return out.sort((a, b) => b.mtime - a.mtime);
|
|
72
|
+
}
|
|
73
|
+
/** The `cwd` recorded in the newest transcripts, for store names that do not map back cleanly. */
|
|
74
|
+
async function sessionCwd(env, storeDir, sessions) {
|
|
75
|
+
for (const { file } of sessions.slice(0, 3)) {
|
|
76
|
+
if (await isCredentials(env, join(storeDir, file)))
|
|
77
|
+
continue;
|
|
78
|
+
const input = createReadStream(join(storeDir, file), { encoding: "utf8" });
|
|
79
|
+
const lines = createInterface({ input });
|
|
80
|
+
let read = 0;
|
|
81
|
+
try {
|
|
82
|
+
for await (const line of lines) {
|
|
83
|
+
if (++read > 50)
|
|
84
|
+
break;
|
|
85
|
+
if (!line.includes('"cwd"'))
|
|
86
|
+
continue;
|
|
87
|
+
const parsed = JSON.parse(line);
|
|
88
|
+
if (isRecord(parsed) && typeof parsed.cwd === "string")
|
|
89
|
+
return parsed.cwd;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// A truncated or malformed transcript line: try the next file.
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
lines.close();
|
|
97
|
+
input.destroy();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {} from "../../shared/types.js";
|
|
2
|
+
import { isRecord, stringsIn } from "./files.js";
|
|
3
|
+
const REPOSITORY = ["project", "local"];
|
|
4
|
+
const NOT_MANAGED = ["user", "project", "local"];
|
|
5
|
+
function at(value, path) {
|
|
6
|
+
return path.reduce((o, k) => (isRecord(o) ? o[k] : undefined), value);
|
|
7
|
+
}
|
|
8
|
+
function nested(path, leaf) {
|
|
9
|
+
return path.reduceRight((acc, k) => ({ [k]: acc }), leaf);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `value` with the field at `path` set to `leaf`, or removed when `leaf` is undefined, along with
|
|
13
|
+
* any object that removal leaves empty.
|
|
14
|
+
*/
|
|
15
|
+
function replaced(value, path, leaf) {
|
|
16
|
+
const [head, ...rest] = path;
|
|
17
|
+
if (head === undefined)
|
|
18
|
+
return value;
|
|
19
|
+
const out = { ...value };
|
|
20
|
+
const child = out[head];
|
|
21
|
+
if (rest.length > 0 && !isRecord(child))
|
|
22
|
+
return value;
|
|
23
|
+
const next = isRecord(child) && rest.length > 0 ? replaced(child, rest, leaf) : leaf;
|
|
24
|
+
if (next === undefined || (isRecord(next) && Object.keys(next).length === 0))
|
|
25
|
+
delete out[head];
|
|
26
|
+
else
|
|
27
|
+
out[head] = next;
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Which `sandbox` fields Claude Code ignores from which files, given every file's `sandbox` and
|
|
32
|
+
* `permissions` values (docs: settings-reference#sandbox-settings, sandboxing#keep-developers-from-widening-the-policy).
|
|
33
|
+
* Locks that depend on paths, such as allowRead entries under a path that managed settings deny
|
|
34
|
+
* reading, and anything the --settings flag sets, are not modelled.
|
|
35
|
+
*/
|
|
36
|
+
function rulesFor(valueIn) {
|
|
37
|
+
const managed = valueIn("sandbox", "managed");
|
|
38
|
+
const user = valueIn("sandbox", "user");
|
|
39
|
+
const managedSays = (path, value) => at(managed, path.split(".")) === value;
|
|
40
|
+
const rules = [];
|
|
41
|
+
const add = (fields, from, reason, drops) => {
|
|
42
|
+
for (const field of fields)
|
|
43
|
+
rules.push({ field, from, reason, ...(drops ? { drops } : {}) });
|
|
44
|
+
};
|
|
45
|
+
// Keys read from fewer files (docs: settings-reference, each key's Scope).
|
|
46
|
+
for (const field of [
|
|
47
|
+
"filesystem.allowManagedReadPathsOnly",
|
|
48
|
+
"network.allowManagedDomainsOnly",
|
|
49
|
+
"bwrapPath",
|
|
50
|
+
"socatPath",
|
|
51
|
+
])
|
|
52
|
+
add([field], NOT_MANAGED, `Claude Code reads sandbox.${field} only from managed settings`);
|
|
53
|
+
for (const field of [
|
|
54
|
+
"allowAppleEvents",
|
|
55
|
+
"ripgrep",
|
|
56
|
+
"credentials.allowPlaintextInject",
|
|
57
|
+
"credentials.awsPairs",
|
|
58
|
+
"credentials.sigv4",
|
|
59
|
+
"network.strictAllowlist",
|
|
60
|
+
"network.tlsTerminate",
|
|
61
|
+
])
|
|
62
|
+
add([field], REPOSITORY, `Claude Code reads sandbox.${field} only from user or managed settings`);
|
|
63
|
+
// docs: sandboxing#which-settings-can-disable-it
|
|
64
|
+
const managedFiles = at(managed, ["credentials", "files"]);
|
|
65
|
+
const filesystemLocked = isRecord(at(managed, ["filesystem"])) ||
|
|
66
|
+
(Array.isArray(managedFiles) && managedFiles.some((f) => isRecord(f) && f.mode === "deny"));
|
|
67
|
+
add(["filesystem.disabled"], filesystemLocked ? NOT_MANAGED : REPOSITORY, filesystemLocked
|
|
68
|
+
? "Managed settings configure the sandbox filesystem, so only they can turn it off"
|
|
69
|
+
: "Claude Code reads sandbox.filesystem.disabled only from user or managed settings");
|
|
70
|
+
add(["credentials.files", "credentials.envVars"], REPOSITORY, "Claude Code drops mask entries from repository settings", { entries: (e) => isRecord(e) && e.mode === "mask" });
|
|
71
|
+
// docs: sandboxing#turn-off-the-retry-with-strict-sandbox-mode
|
|
72
|
+
if (managedSays("allowUnsandboxedCommands", false) ||
|
|
73
|
+
at(user, ["allowUnsandboxedCommands"]) === false)
|
|
74
|
+
add(["allowUnsandboxedCommands"], REPOSITORY, "A false in user or managed settings holds against a repository's true", { value: true });
|
|
75
|
+
// Managed-only locks (docs: sandboxing#keep-developers-from-widening-the-policy).
|
|
76
|
+
if (managedSays("filesystem.allowManagedReadPathsOnly", true))
|
|
77
|
+
add(["filesystem.allowRead"], NOT_MANAGED, "Managed settings honor only their own allowRead entries (allowManagedReadPathsOnly)");
|
|
78
|
+
const domainsLocked = managedSays("network.allowManagedDomainsOnly", true);
|
|
79
|
+
if (domainsLocked)
|
|
80
|
+
add(["network.allowedDomains", "network.httpProxyPort", "network.socksProxyPort"], NOT_MANAGED, "Managed settings honor only their own domains and proxy ports (allowManagedDomainsOnly)");
|
|
81
|
+
// docs: sandboxing#repository-settings-under-an-admin-required-sandbox
|
|
82
|
+
if (managedSays("allowUnsandboxedCommands", false) || domainsLocked) {
|
|
83
|
+
const reason = "Managed settings require the sandbox, so Claude Code ignores repository settings that loosen it";
|
|
84
|
+
add([
|
|
85
|
+
"excludedCommands",
|
|
86
|
+
"ignoreViolations",
|
|
87
|
+
"network.allowedDomains",
|
|
88
|
+
"network.allowUnixSockets",
|
|
89
|
+
"network.allowMachLookup",
|
|
90
|
+
"network.httpProxyPort",
|
|
91
|
+
"network.socksProxyPort",
|
|
92
|
+
"filesystem.allowWrite",
|
|
93
|
+
], REPOSITORY, reason);
|
|
94
|
+
add([
|
|
95
|
+
"enableWeakerNestedSandbox",
|
|
96
|
+
"enableWeakerNetworkIsolation",
|
|
97
|
+
"network.allowAllUnixSockets",
|
|
98
|
+
"network.allowLocalBinding",
|
|
99
|
+
], REPOSITORY, reason, { value: true });
|
|
100
|
+
for (const field of ["enabled", "failIfUnavailable"])
|
|
101
|
+
if (at(user, [field]) === true)
|
|
102
|
+
add([field], REPOSITORY, reason, { value: false });
|
|
103
|
+
}
|
|
104
|
+
// docs: sandboxing#locks-that-apply-without-an-admin-required-sandbox
|
|
105
|
+
const managedDeny = stringsIn(at(valueIn("permissions", "managed"), ["deny"]));
|
|
106
|
+
if (stringsIn(at(managed, ["network", "deniedDomains"])).length > 0 ||
|
|
107
|
+
managedDeny.some((rule) => rule.startsWith("WebFetch(domain:")))
|
|
108
|
+
add(["network.httpProxyPort", "network.socksProxyPort"], REPOSITORY, "Managed settings deny domains, so a repository cannot set a proxy port");
|
|
109
|
+
const strictAllowlist = at(managed, ["network", "strictAllowlist"]) ?? at(user, ["network", "strictAllowlist"]);
|
|
110
|
+
if (strictAllowlist === true)
|
|
111
|
+
add(["network.allowedDomains", "network.httpProxyPort", "network.socksProxyPort"], REPOSITORY, "strictAllowlist is on, so Claude Code ignores a repository's domains and proxy ports");
|
|
112
|
+
// While the block covers sandboxed commands, repository allowRead and allowWrite entries do not
|
|
113
|
+
// count; it does not when filesystem isolation is off or allowManagedReadPathsOnly is set
|
|
114
|
+
// (docs: settings-reference#sandboxed-commands-under-the-block).
|
|
115
|
+
const blocked = ["user", "project", "local", "managed"].some((s) => at(valueIn("permissions", s), ["blockReadsOutsideWorkingDirectories"]) === true);
|
|
116
|
+
const filesystemOff = (filesystemLocked ? [managed] : [managed, user])
|
|
117
|
+
.map((v) => at(v, ["filesystem", "disabled"]))
|
|
118
|
+
.find((v) => v !== undefined) === true;
|
|
119
|
+
if (blocked && !filesystemOff && !managedSays("filesystem.allowManagedReadPathsOnly", true))
|
|
120
|
+
add(["filesystem.allowRead", "filesystem.allowWrite"], REPOSITORY, "blockReadsOutsideWorkingDirectories is on, so repository allowRead and allowWrite entries do not count");
|
|
121
|
+
return rules;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A filter for each file's `sandbox` value: what is left once the fields Claude Code ignores from
|
|
125
|
+
* that file are taken out, and what was taken out.
|
|
126
|
+
*/
|
|
127
|
+
export function sandboxFilter(valueIn) {
|
|
128
|
+
const rules = rulesFor(valueIn);
|
|
129
|
+
return (scope, value) => {
|
|
130
|
+
if (!isRecord(value))
|
|
131
|
+
return { value, dropped: [] };
|
|
132
|
+
let out = value;
|
|
133
|
+
const dropped = [];
|
|
134
|
+
for (const rule of rules) {
|
|
135
|
+
if (!rule.from.includes(scope))
|
|
136
|
+
continue;
|
|
137
|
+
const path = rule.field.split(".");
|
|
138
|
+
const field = at(out, path);
|
|
139
|
+
if (field === undefined)
|
|
140
|
+
continue;
|
|
141
|
+
const drops = rule.drops;
|
|
142
|
+
let removed = field;
|
|
143
|
+
let kept;
|
|
144
|
+
if (drops && "value" in drops) {
|
|
145
|
+
if (field !== drops.value)
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
else if (drops) {
|
|
149
|
+
if (!Array.isArray(field))
|
|
150
|
+
continue;
|
|
151
|
+
removed = field.filter(drops.entries);
|
|
152
|
+
if (removed.length === 0)
|
|
153
|
+
continue;
|
|
154
|
+
kept = field.filter((e) => !drops.entries(e));
|
|
155
|
+
}
|
|
156
|
+
out = replaced(out, path, kept);
|
|
157
|
+
dropped.push({ value: nested(path, removed), reason: rule.reason });
|
|
158
|
+
}
|
|
159
|
+
return { value: out, dropped };
|
|
160
|
+
};
|
|
161
|
+
}
|