ccdesk 0.1.0 → 0.2.1

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +84 -67
  3. package/dist/server/agent-edit.js +77 -0
  4. package/dist/server/backups.js +129 -52
  5. package/dist/server/cli.js +21 -8
  6. package/dist/server/mcp-edit.js +263 -0
  7. package/dist/server/mutations.js +8 -3
  8. package/dist/server/scan/compare.js +34 -0
  9. package/dist/server/scan/context.js +6 -5
  10. package/dist/server/scan/env.js +8 -3
  11. package/dist/server/scan/files.js +118 -30
  12. package/dist/server/scan/index.js +29 -15
  13. package/dist/server/scan/instructions.js +11 -7
  14. package/dist/server/scan/inventory.js +134 -42
  15. package/dist/server/scan/mcp.js +88 -30
  16. package/dist/server/scan/memory.js +8 -9
  17. package/dist/server/scan/path-test.js +32 -10
  18. package/dist/server/scan/permissions.js +22 -6
  19. package/dist/server/scan/plugins.js +18 -11
  20. package/dist/server/scan/projects.js +7 -6
  21. package/dist/server/scan/sandbox.js +161 -0
  22. package/dist/server/scan/settings.js +33 -20
  23. package/dist/server/scan/tools.js +7 -3
  24. package/dist/server/scan/usage.js +119 -46
  25. package/dist/server/schema.js +15 -0
  26. package/dist/server/secrets.js +63 -0
  27. package/dist/server/server.js +222 -128
  28. package/dist/server/settings-edit.js +90 -21
  29. package/dist/server/watch.js +52 -10
  30. package/dist/server/write.js +58 -20
  31. package/dist/shared/compare.js +1 -0
  32. package/dist/shared/hook-events.json +35 -0
  33. package/dist/shared/mcp-server.schema.json +25 -0
  34. package/dist/shared/memory.js +4 -0
  35. package/dist/shared/scopes.js +3 -1
  36. package/dist/shared/types.js +1 -1
  37. package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
  38. package/dist/ui/assets/index-D5XqP5c_.css +1 -0
  39. package/dist/ui/assets/index-XqJwI3k9.js +60 -0
  40. package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  41. package/dist/ui/index.html +2 -2
  42. package/package.json +10 -4
  43. package/dist/ui/assets/index-BM4K7hSO.css +0 -1
  44. 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 scanDirectory(env, cwd);
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 !== false)
87
- matches.push({ kind: "skill", path: skill.path, scope: "project", name: skill.name });
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({ kind: "skill", path: skill.path, scope: skill.scope, glob, name: skill.name });
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, hooks, autoMemoryDirectory) wait for
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, so until then the local file waits too, except in
9
- * your configuration home; a committed local file waits like project settings.
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 localWaits = !inConfigHome && (trust.trusted ? false : !trust.inheritedFrom || localFromRepository);
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, leadsToSecret, listDir } from "./files.js";
8
- import { readMemoryStore, storeDirName } from "./memory.js";
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 leadsToSecret(join(storeDir, file)))
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: 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 },
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: ANY_FILE },
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
- const result = await readJsonObject(source);
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
- if (dropped)
231
- ignored.push({ scope, ...dropped });
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 = ORDER.map((list) => whole.find((r) => r.list === list)).find(Boolean);
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) => ORDER.indexOf(a.list) - ORDER.indexOf(b.list)),
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)