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
|
@@ -23,12 +23,14 @@ function firstMatch(globs, path) {
|
|
|
23
23
|
* What Claude Code adds to context once Claude reads or edits `file` in a session started in `dir`:
|
|
24
24
|
* path-scoped rules, skills with matching `paths`, and CLAUDE.md files in the folders between.
|
|
25
25
|
*/
|
|
26
|
-
export async function testPath(env, dir, file
|
|
26
|
+
export async function testPath(env, dir, file,
|
|
27
|
+
/** The view of the start directory; a server passes one that reuses a current scan. */
|
|
28
|
+
viewOf = (cwd) => scanDirectory(env, cwd)) {
|
|
27
29
|
const cwd = resolve(dir);
|
|
28
30
|
const target = isAbsolute(file) ? resolve(file) : resolve(cwd, file);
|
|
29
31
|
if (target === cwd || !isWithin(cwd, target))
|
|
30
32
|
return { file: target, inside: false, exists: false, matches: [] };
|
|
31
|
-
const view = await
|
|
33
|
+
const view = await viewOf(cwd);
|
|
32
34
|
const projectRoot = view.gitRoot ?? cwd;
|
|
33
35
|
const matches = [];
|
|
34
36
|
const excludes = stringsIn(view.effective.find((e) => e.key === "claudeMdExcludes")?.value);
|
|
@@ -46,13 +48,14 @@ export async function testPath(env, dir, file) {
|
|
|
46
48
|
}
|
|
47
49
|
// Folders below the start directory load their own instructions, rules and skills on demand.
|
|
48
50
|
const mode = view.agentsMdMode;
|
|
51
|
+
const startSkills = new Set(view.inventory.skills.filter((s) => s.scope !== "plugin").map((s) => s.name));
|
|
49
52
|
const between = [];
|
|
50
53
|
for (let d = dirname(target); d !== cwd && isWithin(cwd, d); d = dirname(d))
|
|
51
54
|
between.unshift(d);
|
|
52
55
|
for (const folder of between) {
|
|
53
56
|
const nested = async (name, scope) => {
|
|
54
57
|
const path = join(folder, name);
|
|
55
|
-
if (excluded(path) || !(await readTextFile(path)))
|
|
58
|
+
if (excluded(path) || !(await readTextFile(env, path)))
|
|
56
59
|
return false;
|
|
57
60
|
matches.push({ kind: "nested", path, scope });
|
|
58
61
|
return true;
|
|
@@ -69,7 +72,7 @@ export async function testPath(env, dir, file) {
|
|
|
69
72
|
if (agents)
|
|
70
73
|
await nested("AGENTS.md", "project");
|
|
71
74
|
for (const path of await walkMarkdown(join(folder, ".claude", "rules"))) {
|
|
72
|
-
const text = await readTextFile(path);
|
|
75
|
+
const text = await readTextFile(env, path);
|
|
73
76
|
if (!text || excluded(path))
|
|
74
77
|
continue;
|
|
75
78
|
const globs = toStringList(parseFrontmatter(text.text).data.paths);
|
|
@@ -77,23 +80,42 @@ export async function testPath(env, dir, file) {
|
|
|
77
80
|
if (globs.length === 0 || glob)
|
|
78
81
|
matches.push({ kind: "nested", path, scope: "project", ...(glob ? { glob } : {}) });
|
|
79
82
|
}
|
|
80
|
-
// Only skills Claude may pick itself: not off, user-invocable-only or disable-model-invocation
|
|
83
|
+
// Only skills Claude may pick itself: not off, user-invocable-only or disable-model-invocation;
|
|
84
|
+
// one with paths only for a matching file, its globs read from its folder like a rule's.
|
|
81
85
|
for (const name of await listDir(join(folder, ".claude", "skills"))) {
|
|
82
|
-
const skill = await readSkill(join(folder, ".claude", "skills", name), "project");
|
|
86
|
+
const skill = await readSkill(env, join(folder, ".claude", "skills", name), "project");
|
|
83
87
|
if (!skill)
|
|
84
88
|
continue;
|
|
85
89
|
applySkillOverrides([skill], overrides);
|
|
86
|
-
if (skill.listed
|
|
87
|
-
|
|
90
|
+
if (skill.listed === false)
|
|
91
|
+
continue;
|
|
92
|
+
const glob = skill.paths && firstMatch(skill.paths, slashed(relative(folder, target)));
|
|
93
|
+
if (skill.paths && !glob)
|
|
94
|
+
continue;
|
|
95
|
+
// A nested skill whose folder name another skill has is called by its folder as well, as
|
|
96
|
+
// apps/web:deploy (docs: skills#how-a-skill-gets-its-command-name).
|
|
97
|
+
matches.push({
|
|
98
|
+
kind: "skill",
|
|
99
|
+
path: skill.path,
|
|
100
|
+
scope: "project",
|
|
101
|
+
name: startSkills.has(name) ? `${slashed(relative(cwd, folder))}:${name}` : skill.name,
|
|
102
|
+
...(glob ? { glob } : {}),
|
|
103
|
+
});
|
|
88
104
|
}
|
|
89
105
|
}
|
|
90
106
|
// Skills Claude can pick on its own; one with disable-model-invocation only runs when you call it.
|
|
91
107
|
for (const skill of view.inventory.skills) {
|
|
92
|
-
if (!skill.paths || skill.listed === false)
|
|
108
|
+
if (!skill.paths || skill.listed === false || skill.shadowedBy)
|
|
93
109
|
continue;
|
|
94
110
|
const glob = firstMatch(skill.paths, slashed(relative(projectRoot, target)));
|
|
95
111
|
if (glob)
|
|
96
|
-
matches.push({
|
|
112
|
+
matches.push({
|
|
113
|
+
kind: "skill",
|
|
114
|
+
path: skill.path,
|
|
115
|
+
scope: skill.scope,
|
|
116
|
+
glob,
|
|
117
|
+
name: skill.id ?? skill.name,
|
|
118
|
+
});
|
|
97
119
|
}
|
|
98
120
|
return { file: target, inside: true, exists: await isFile(target), matches };
|
|
99
121
|
}
|
|
@@ -3,17 +3,27 @@ import {} from "../../shared/types.js";
|
|
|
3
3
|
import { isRecord, stringsIn } from "./files.js";
|
|
4
4
|
import { ignoredModeReason } from "./settings.js";
|
|
5
5
|
/**
|
|
6
|
-
* Whether a file's trust-gated settings (permission grants,
|
|
6
|
+
* Whether a file's trust-gated settings (permission grants, autoMemoryDirectory) wait for
|
|
7
7
|
* the workspace trust dialog. Claude Code runs git to tell a private local file from a committed one
|
|
8
|
-
* only once the folder or a parent is trusted,
|
|
9
|
-
* your configuration home
|
|
8
|
+
* only once the folder or a parent is trusted, or in claude -p and the SDK, which count as accepted;
|
|
9
|
+
* until then the local file waits too, except in your configuration home. A committed local file
|
|
10
|
+
* waits like project settings, which -p and the SDK never apply in a folder you have not trusted
|
|
11
|
+
* (docs: permissions#when-your-local-settings-file-needs-trust, #what-runs-before-you-trust-a-folder).
|
|
10
12
|
*/
|
|
11
|
-
export function trustWaits({ trust, inConfigHome, localFromRepository, }) {
|
|
12
|
-
const
|
|
13
|
+
export function trustWaits({ trust, inConfigHome, localFromRepository, profile, }) {
|
|
14
|
+
const gitChecked = profile === "print" || trust.inheritedFrom !== undefined;
|
|
15
|
+
const localWaits = !inConfigHome && !trust.trusted && (!gitChecked || localFromRepository);
|
|
13
16
|
return (scope) => (scope === "project" && !trust.trusted) || (scope === "local" && localWaits);
|
|
14
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* An MCP rule with a parameter, which Claude Code skips when it reads a settings file; such a rule
|
|
20
|
+
* works only from --disallowedTools (docs: permissions#match-specific-tool-uses).
|
|
21
|
+
*/
|
|
22
|
+
export function isSkippedMcpRule(rule) {
|
|
23
|
+
return /^mcp__[^(]*\(/.test(rule);
|
|
24
|
+
}
|
|
15
25
|
/** Claude Code checks every file's rules together: deny, then ask, then allow (docs: permissions). */
|
|
16
|
-
const CHECK_ORDER = ["deny", "ask", "allow"];
|
|
26
|
+
export const CHECK_ORDER = ["deny", "ask", "allow"];
|
|
17
27
|
/**
|
|
18
28
|
* Every permission entry from every settings file, each marked with whether Claude Code applies it
|
|
19
29
|
* (docs: permissions#project-allow-rules-and-workspace-trust, settings-reference#permissions-defaultmode,
|
|
@@ -33,6 +43,12 @@ export function permissionsOf(layers, options) {
|
|
|
33
43
|
const notTrusted = "Waits until you trust this folder in Claude Code";
|
|
34
44
|
const rules = sources.flatMap(({ scope, path, permissions }) => CHECK_ORDER.flatMap((list) => stringsIn(permissions[list]).map((rule) => {
|
|
35
45
|
const base = { kind: "rule", list, rule, scope, path };
|
|
46
|
+
if (isSkippedMcpRule(rule))
|
|
47
|
+
return {
|
|
48
|
+
...base,
|
|
49
|
+
inEffect: false,
|
|
50
|
+
note: "Claude Code skips an MCP rule with parentheses in a settings file; pass it with --disallowedTools",
|
|
51
|
+
};
|
|
36
52
|
if (managedOnly && scope !== "managed")
|
|
37
53
|
return {
|
|
38
54
|
...base,
|
|
@@ -6,13 +6,20 @@ import { isRecord, isWithin, readJsonObject, resolvedPath, stringField } from ".
|
|
|
6
6
|
function manifestPath(plugin) {
|
|
7
7
|
return join(plugin.installPath, ".claude-plugin", "plugin.json");
|
|
8
8
|
}
|
|
9
|
-
async function manifest(plugin) {
|
|
10
|
-
const result = await readJsonObject(manifestPath(plugin));
|
|
9
|
+
async function manifest(env, plugin) {
|
|
10
|
+
const result = await readJsonObject(env, manifestPath(plugin));
|
|
11
11
|
return result.ok ? result.data : {};
|
|
12
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
|
+
}
|
|
13
20
|
/** Installed plugins that apply in `projectDir`, with whether each is enabled and by which file. */
|
|
14
21
|
export async function readPlugins(env, layers, projectDir, gitRoot) {
|
|
15
|
-
const installed = await readJsonObject(join(env.claudeDir, "plugins", "installed_plugins.json"));
|
|
22
|
+
const installed = await readJsonObject(env, join(env.claudeDir, "plugins", "installed_plugins.json"));
|
|
16
23
|
if (!installed.ok || !isRecord(installed.data.plugins))
|
|
17
24
|
return [];
|
|
18
25
|
const out = [];
|
|
@@ -46,7 +53,7 @@ export async function readPlugins(env, layers, projectDir, gitRoot) {
|
|
|
46
53
|
plugin.note = `${plugin.enabled ? "Enabled" : "Turned off"} in ${setting.scope} settings`;
|
|
47
54
|
}
|
|
48
55
|
else {
|
|
49
|
-
plugin.enabled = (await manifest(plugin)).defaultEnabled !== false;
|
|
56
|
+
plugin.enabled = (await manifest(env, plugin)).defaultEnabled !== false;
|
|
50
57
|
plugin.note = plugin.enabled
|
|
51
58
|
? "On by default (no enabledPlugins entry)"
|
|
52
59
|
: "Off by default (no enabledPlugins entry)";
|
|
@@ -62,8 +69,8 @@ async function inside(plugin, path) {
|
|
|
62
69
|
return isWithin(root, await resolvedPath(target)) ? target : null;
|
|
63
70
|
}
|
|
64
71
|
/** The paths a manifest key names (one path or a list), inside the plugin; null when unset. */
|
|
65
|
-
export async function manifestPaths(plugin, key) {
|
|
66
|
-
const value = (await manifest(plugin))[key];
|
|
72
|
+
export async function manifestPaths(env, plugin, key) {
|
|
73
|
+
const value = (await manifest(env, plugin))[key];
|
|
67
74
|
const list = typeof value === "string" ? [value] : Array.isArray(value) ? value : null;
|
|
68
75
|
if (!list)
|
|
69
76
|
return null;
|
|
@@ -78,8 +85,8 @@ export async function manifestPaths(plugin, key) {
|
|
|
78
85
|
return out;
|
|
79
86
|
}
|
|
80
87
|
/** The value of a manifest key that is a path, an inline object, or a list of either. */
|
|
81
|
-
export async function manifestConfigs(plugin, key) {
|
|
82
|
-
const value = (await manifest(plugin))[key];
|
|
88
|
+
export async function manifestConfigs(env, plugin, key) {
|
|
89
|
+
const value = (await manifest(env, plugin))[key];
|
|
83
90
|
const out = [];
|
|
84
91
|
for (const item of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
|
|
85
92
|
if (isRecord(item))
|
|
@@ -87,15 +94,15 @@ export async function manifestConfigs(plugin, key) {
|
|
|
87
94
|
if (typeof item !== "string")
|
|
88
95
|
continue;
|
|
89
96
|
const path = await inside(plugin, item);
|
|
90
|
-
const file = path ? await readJsonObject(path) : null;
|
|
97
|
+
const file = path ? await readJsonObject(env, path) : null;
|
|
91
98
|
if (path && file?.ok)
|
|
92
99
|
out.push({ value: file.data[key] ?? file.data, path });
|
|
93
100
|
}
|
|
94
101
|
return out;
|
|
95
102
|
}
|
|
96
103
|
/** The manifest's `commands` object form: command name to a file or inline content. */
|
|
97
|
-
export async function manifestCommandMap(plugin) {
|
|
98
|
-
const value = (await manifest(plugin)).commands;
|
|
104
|
+
export async function manifestCommandMap(env, plugin) {
|
|
105
|
+
const value = (await manifest(env, plugin)).commands;
|
|
99
106
|
if (!isRecord(value))
|
|
100
107
|
return [];
|
|
101
108
|
const out = [];
|
|
@@ -2,10 +2,11 @@ import { createReadStream } from "node:fs";
|
|
|
2
2
|
import { stat } from "node:fs/promises";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
|
+
import { storeDirName } from "../../shared/memory.js";
|
|
5
6
|
import {} from "../../shared/types.js";
|
|
6
7
|
import {} from "./env.js";
|
|
7
|
-
import { isDir, isRecord,
|
|
8
|
-
import { readMemoryStore
|
|
8
|
+
import { isCredentials, isDir, isRecord, listDir } from "./files.js";
|
|
9
|
+
import { readMemoryStore } from "./memory.js";
|
|
9
10
|
/** Directories Claude Code has been used in: its project stores, `~/.claude.json`, and the launch directory. */
|
|
10
11
|
export async function listProjects(env, claudeJson, launchDir) {
|
|
11
12
|
const byPath = new Map();
|
|
@@ -17,11 +18,11 @@ export async function listProjects(env, claudeJson, launchDir) {
|
|
|
17
18
|
continue;
|
|
18
19
|
const sessions = await newestFirst(storeDir, (await listDir(storeDir)).filter((f) => f.endsWith(".jsonl")));
|
|
19
20
|
const path = knownPaths.find((p) => storeDirName(p) === name) ??
|
|
20
|
-
(await sessionCwd(storeDir, sessions)) ??
|
|
21
|
+
(await sessionCwd(env, storeDir, sessions)) ??
|
|
21
22
|
null;
|
|
22
23
|
if (!path)
|
|
23
24
|
continue;
|
|
24
|
-
const store = await readMemoryStore(join(storeDir, "memory"), path);
|
|
25
|
+
const store = await readMemoryStore(env, join(storeDir, "memory"), path);
|
|
25
26
|
byPath.set(path, {
|
|
26
27
|
path,
|
|
27
28
|
store,
|
|
@@ -70,9 +71,9 @@ async function newestFirst(dir, files) {
|
|
|
70
71
|
return out.sort((a, b) => b.mtime - a.mtime);
|
|
71
72
|
}
|
|
72
73
|
/** The `cwd` recorded in the newest transcripts, for store names that do not map back cleanly. */
|
|
73
|
-
async function sessionCwd(storeDir, sessions) {
|
|
74
|
+
async function sessionCwd(env, storeDir, sessions) {
|
|
74
75
|
for (const { file } of sessions.slice(0, 3)) {
|
|
75
|
-
if (await
|
|
76
|
+
if (await isCredentials(env, join(storeDir, file)))
|
|
76
77
|
continue;
|
|
77
78
|
const input = createReadStream(join(storeDir, file), { encoding: "utf8" });
|
|
78
79
|
const lines = createInterface({ input });
|
|
@@ -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
|
+
}
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
import { lstat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { allowedScopes, ignoredReason } from "../../shared/scopes.js";
|
|
3
|
+
import { allowedScopes, EVERY_FILE, ignoredReason } from "../../shared/scopes.js";
|
|
4
4
|
import {} from "../../shared/types.js";
|
|
5
5
|
import {} from "./env.js";
|
|
6
6
|
import { isRecord, listDir, mainWorktreeRoot, readJsonObject, redactSecrets } from "./files.js";
|
|
7
|
+
import { sandboxFilter } from "./sandbox.js";
|
|
7
8
|
/** Lowest precedence first, so later layers override earlier ones. */
|
|
8
9
|
const PRECEDENCE = ["user", "project", "local", "managed"];
|
|
9
10
|
/** Keys whose whole value comes from the highest layer that sets it, even though they are lists. */
|
|
10
11
|
const WHOLE_VALUE_KEYS = new Set(["fallbackModel", "modelPicker"]);
|
|
11
12
|
/** Maps whose entries are taken whole from the highest layer that has them, never field by field. */
|
|
12
13
|
const WHOLE_ENTRY_KEYS = new Set(["managedMcpServers", "extraKnownMarketplaces", "modelSettings"]);
|
|
13
|
-
const ANY_FILE = ["user", "project", "local", "managed"];
|
|
14
14
|
/**
|
|
15
15
|
* Keys, some nested, where Claude Code honours the more restrictive value from some files even over
|
|
16
16
|
* a higher file (docs: settings#exceptions-to-managed-settings-precedence).
|
|
17
17
|
*/
|
|
18
18
|
const STRICTEST = {
|
|
19
|
-
disableClaudeAiConnectors: { ladder: [false, true], from:
|
|
20
|
-
isolatePeerMachines: { ladder: [false, true], from:
|
|
21
|
-
enableArtifact: { ladder: [true, false], from:
|
|
22
|
-
disableArtifact: { ladder: [false, true], from:
|
|
23
|
-
"permissions.blockReadsOutsideWorkingDirectories": { ladder: [false, true], from:
|
|
19
|
+
disableClaudeAiConnectors: { ladder: [false, true], from: EVERY_FILE },
|
|
20
|
+
isolatePeerMachines: { ladder: [false, true], from: EVERY_FILE },
|
|
21
|
+
enableArtifact: { ladder: [true, false], from: EVERY_FILE },
|
|
22
|
+
disableArtifact: { ladder: [false, true], from: EVERY_FILE },
|
|
23
|
+
"permissions.blockReadsOutsideWorkingDirectories": { ladder: [false, true], from: EVERY_FILE },
|
|
24
24
|
"autoMode.classifyAllShell": { ladder: [false, true], from: ["user"] },
|
|
25
|
-
maxEffortLevel: { ladder: ["max", "xhigh", "high", "medium", "low"], from:
|
|
25
|
+
maxEffortLevel: { ladder: ["max", "xhigh", "high", "medium", "low"], from: EVERY_FILE },
|
|
26
26
|
useAutoModeDuringPlan: { ladder: [true, false], from: ["managed", "user", "local"] },
|
|
27
27
|
syncClaudeAiSkills: { ladder: [true, false], from: ["managed", "user", "local"] },
|
|
28
28
|
syncClaudeAiPlugins: { ladder: [true, false], from: ["managed", "user", "local"] },
|
|
@@ -44,14 +44,16 @@ export function ignoredModeReason(scope, mode) {
|
|
|
44
44
|
return undefined;
|
|
45
45
|
}
|
|
46
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) {
|
|
47
|
+
function withoutIgnoredFields(key, scope, value, filterSandbox) {
|
|
48
|
+
if (key === "sandbox")
|
|
49
|
+
return filterSandbox(scope, value);
|
|
48
50
|
if (key !== "permissions" || !isRecord(value))
|
|
49
|
-
return { value };
|
|
51
|
+
return { value, dropped: [] };
|
|
50
52
|
const reason = ignoredModeReason(scope, value.defaultMode);
|
|
51
53
|
if (!reason)
|
|
52
|
-
return { value };
|
|
54
|
+
return { value, dropped: [] };
|
|
53
55
|
const { defaultMode, ...rest } = value;
|
|
54
|
-
return { value: rest, dropped: { value: { defaultMode }, reason } };
|
|
56
|
+
return { value: rest, dropped: [{ value: { defaultMode }, reason }] };
|
|
55
57
|
}
|
|
56
58
|
/**
|
|
57
59
|
* The value that wins for a key with a strictness exception, given its values lowest precedence
|
|
@@ -138,7 +140,7 @@ export async function readSettingsLayers(env, dir) {
|
|
|
138
140
|
layers.push(await readManagedLayer(env, file.path));
|
|
139
141
|
continue;
|
|
140
142
|
}
|
|
141
|
-
const result = await readJsonObject(file.path);
|
|
143
|
+
const result = await readJsonObject(env, file.path);
|
|
142
144
|
if (file.legacy && !result.ok && result.missing)
|
|
143
145
|
continue;
|
|
144
146
|
const layer = { scope: file.scope, path: file.path, exists: true, data: null };
|
|
@@ -166,21 +168,25 @@ export async function readManagedSettings(env) {
|
|
|
166
168
|
.map((name) => join(dropInDir, name));
|
|
167
169
|
const sources = [];
|
|
168
170
|
const errors = [];
|
|
171
|
+
let blocksStart = false;
|
|
169
172
|
let data = null;
|
|
170
173
|
for (const source of [join(env.managedDir, "managed-settings.json"), ...dropIns]) {
|
|
171
|
-
|
|
174
|
+
// An empty managed file counts as {}.
|
|
175
|
+
const result = await readJsonObject(env, source, { emptyIsObject: true });
|
|
172
176
|
if (!result.ok) {
|
|
173
177
|
if (!result.missing)
|
|
174
178
|
errors.push(`${source}: ${result.error}`);
|
|
179
|
+
if (result.unparsed)
|
|
180
|
+
blocksStart = true;
|
|
175
181
|
continue;
|
|
176
182
|
}
|
|
177
183
|
sources.push(source);
|
|
178
184
|
data = data ? mergeSettings(data, result.data) : result.data;
|
|
179
185
|
}
|
|
180
|
-
return { data, sources, dropIns, errors };
|
|
186
|
+
return { data, sources, dropIns, errors, blocksStart };
|
|
181
187
|
}
|
|
182
188
|
async function readManagedLayer(env, path) {
|
|
183
|
-
const { data, sources, dropIns, errors } = await readManagedSettings(env);
|
|
189
|
+
const { data, sources, dropIns, errors, blocksStart } = await readManagedSettings(env);
|
|
184
190
|
const layer = { scope: "managed", path, exists: false, data: null };
|
|
185
191
|
if (data)
|
|
186
192
|
layer.data = redactSecrets(data);
|
|
@@ -189,6 +195,8 @@ async function readManagedLayer(env, path) {
|
|
|
189
195
|
layer.sources = sources;
|
|
190
196
|
if (errors.length > 0)
|
|
191
197
|
layer.error = errors.join("; ");
|
|
198
|
+
if (blocksStart)
|
|
199
|
+
layer.blocksStart = true;
|
|
192
200
|
return layer;
|
|
193
201
|
}
|
|
194
202
|
/** The value Claude Code ends up with for each top-level key, and where it came from. */
|
|
@@ -202,6 +210,7 @@ export function effectiveSettings(layers) {
|
|
|
202
210
|
byScope.set(layer.scope, higher ? mergeSettings(layer.data, higher) : layer.data);
|
|
203
211
|
}
|
|
204
212
|
const keys = new Set([...byScope.values()].flatMap((data) => Object.keys(data)));
|
|
213
|
+
const filterSandbox = sandboxFilter((key, scope) => allowedScopes(key).includes(scope) ? byScope.get(scope)?.[key] : undefined);
|
|
205
214
|
const out = [];
|
|
206
215
|
for (const key of [...keys].sort()) {
|
|
207
216
|
const allowed = allowedScopes(key);
|
|
@@ -226,9 +235,9 @@ export function effectiveSettings(layers) {
|
|
|
226
235
|
continue;
|
|
227
236
|
}
|
|
228
237
|
const values = defined.map((scope) => {
|
|
229
|
-
const { value, dropped } = withoutIgnoredFields(key, scope, valueIn(scope));
|
|
230
|
-
|
|
231
|
-
ignored.push({ scope, ...
|
|
238
|
+
const { value, dropped } = withoutIgnoredFields(key, scope, valueIn(scope), filterSandbox);
|
|
239
|
+
for (const d of dropped)
|
|
240
|
+
ignored.push({ scope, ...d });
|
|
232
241
|
return { scope, value };
|
|
233
242
|
});
|
|
234
243
|
const top = values[values.length - 1];
|
|
@@ -240,12 +249,16 @@ export function effectiveSettings(layers) {
|
|
|
240
249
|
const decided = strict && strictWinner(strict, values);
|
|
241
250
|
if (strict && decided) {
|
|
242
251
|
const { winner } = decided;
|
|
252
|
+
// Only files that may just tighten it set the key, and none tightens it past the default.
|
|
253
|
+
if (!winner)
|
|
254
|
+
for (const v of values)
|
|
255
|
+
ignored.push({ ...v, reason: "it is not stricter than Claude Code's default" });
|
|
243
256
|
out.push({
|
|
244
257
|
key,
|
|
245
258
|
value: winner?.value,
|
|
246
259
|
from: winner ? [winner.scope] : [],
|
|
247
260
|
merged: false,
|
|
248
|
-
overridden: values.filter((v) => v !== winner).reverse(),
|
|
261
|
+
overridden: winner ? values.filter((v) => v !== winner).reverse() : [],
|
|
249
262
|
ignored,
|
|
250
263
|
note: strictNote(strict),
|
|
251
264
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import table from "../../shared/builtin-tools.json" with { type: "json" };
|
|
2
2
|
import {} from "../../shared/types.js";
|
|
3
3
|
import { wildcard } from "./files.js";
|
|
4
|
+
import { CHECK_ORDER } from "./permissions.js";
|
|
4
5
|
/**
|
|
5
6
|
* The other tools a rule for some uses covers: Bash rules apply to Monitor, Claude Code checks file
|
|
6
7
|
* paths against Edit and Read rules only, and a Read deny also blocks Edit and Write on that path
|
|
@@ -49,7 +50,6 @@ function matcherCovers(matcher, name) {
|
|
|
49
50
|
return false;
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
|
-
const ORDER = ["deny", "ask", "allow"];
|
|
53
53
|
/** Every built-in tool, with the rules and hooks that apply to it here. */
|
|
54
54
|
export function builtinTools(permissions, hooks) {
|
|
55
55
|
const rules = permissions.flatMap((p) => p.kind === "rule" && p.inEffect ? [{ list: p.list, rule: p.rule, scope: p.scope }] : []);
|
|
@@ -64,6 +64,10 @@ export function builtinTools(permissions, hooks) {
|
|
|
64
64
|
if (specifier === null) {
|
|
65
65
|
if (namesTool(r.list, tool, name))
|
|
66
66
|
whole.push(r);
|
|
67
|
+
// Monitor's commands follow the Bash rules; its WebSocket watches do not
|
|
68
|
+
// (docs: tools-reference#monitor-tool).
|
|
69
|
+
else if (name === "Monitor" && namesTool(r.list, tool, "Bash"))
|
|
70
|
+
scoped.push(r);
|
|
67
71
|
}
|
|
68
72
|
else if (tool === name && UNCHECKED_PATH_RULE_TOOLS.has(tool)) {
|
|
69
73
|
scoped.push({ ...r, unchecked: true });
|
|
@@ -71,7 +75,7 @@ export function builtinTools(permissions, hooks) {
|
|
|
71
75
|
else if (tool === name || coveredTools(tool, r.list).includes(name))
|
|
72
76
|
scoped.push(r);
|
|
73
77
|
}
|
|
74
|
-
const decidedBy =
|
|
78
|
+
const decidedBy = CHECK_ORDER.map((list) => whole.find((r) => r.list === list)).find(Boolean);
|
|
75
79
|
const tool = {
|
|
76
80
|
name,
|
|
77
81
|
description,
|
|
@@ -79,7 +83,7 @@ export function builtinTools(permissions, hooks) {
|
|
|
79
83
|
verdict: decidedBy
|
|
80
84
|
? { deny: "denied", ask: "asks", allow: "allowed" }[decidedBy.list]
|
|
81
85
|
: "default",
|
|
82
|
-
scoped: scoped.sort((a, b) =>
|
|
86
|
+
scoped: scoped.sort((a, b) => CHECK_ORDER.indexOf(a.list) - CHECK_ORDER.indexOf(b.list)),
|
|
83
87
|
hooks: name === EXEMPT ? 0 : preToolUse.filter((h) => matcherCovers(h.matcher, name)).length,
|
|
84
88
|
};
|
|
85
89
|
if (decidedBy)
|