@yagni-app/code 1.0.7 → 1.0.9

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.
@@ -1,23 +1,29 @@
1
1
  /**
2
2
  * Permission-rule settings loader.
3
3
  *
4
- * Reads `permissions: { allow, deny, ask }` from the two YAGNI Code settings
5
- * files, mirroring the hooks loader (hooks.ts) shape exactly:
6
- * - user: ~/.yagni-code/config.json — always active
7
- * - project: .yagni-code/config.json — deny/ask always active;
8
- * allow gated on workspace trust (Claude Code semantics:
9
- * "deny and ask rules apply right away; allow rules from a
10
- * project file wait for trust")
4
+ * Reads `permissions: { allow, deny, ask }` from the three YAGNI Code
5
+ * settings files, mirroring the hooks loader (hooks.ts) shape exactly:
6
+ * - user: ~/.yagni-code/config.json — always active
7
+ * - project: .yagni-code/config.json — deny/ask always active;
8
+ * allow gated on workspace trust (Claude Code semantics:
9
+ * "deny and ask rules apply right away; allow rules from a
10
+ * project file wait for trust")
11
+ * - local: .yagni-code/config.local.json — the personal per-project
12
+ * tier; trust-gated EXACTLY like project (a hostile repo can
13
+ * commit a local file — the gitignore convention only covers
14
+ * untracked files). Deliberately stricter than Claude Code,
15
+ * which does not trust-gate localSettings.
11
16
  *
12
17
  * Lists UNION across sources (Claude Code: lists merge, never replace).
13
- * User-source rules are ordered before project-source rules so path-pattern
14
- * negation (`!exception`) behaves like gitignore lines across files.
18
+ * User-source rules are ordered before project-source rules, project before
19
+ * local, so path-pattern negation (`!exception`) behaves like gitignore
20
+ * lines across files — higher-precedence sources come later (later wins).
15
21
  *
16
22
  * Fail-soft: a malformed config file is skipped whole with a warning (same
17
23
  * posture as hooks.ts). Malformed individual rule strings parse as bare
18
24
  * tool names (parser degrades, never throws) and are flagged.
19
25
  */
20
- export type RuleSource = "user" | "project";
26
+ export type RuleSource = "user" | "project" | "local";
21
27
  export type RuleBehavior = "allow" | "deny" | "ask";
22
28
  /** A parsed, sourced permission rule. */
23
29
  export interface PermissionRule {
@@ -40,7 +46,7 @@ export interface LoadedRules {
40
46
  }
41
47
  /** The tools a rule can actually be consulted for at the gate. */
42
48
  export declare const KNOWN_RULE_TOOLS: Set<string>;
43
- /** Load + union rules from both files. Pure I/O; no throw. */
49
+ /** Load + union rules from all three files. Pure I/O; no throw. */
44
50
  export declare function loadPermissionRules(opts?: {
45
51
  cwd?: string;
46
52
  env?: NodeJS.ProcessEnv;
@@ -48,6 +54,11 @@ export declare function loadPermissionRules(opts?: {
48
54
  /** Override the user state home (tests). */
49
55
  stateHomeOverride?: string | null;
50
56
  }): LoadedRules;
51
- /** Filter rules by behavior + trust (project allow needs trust; deny/ask always). */
57
+ /**
58
+ * Filter rules by behavior + trust. Untrusted repos keep deny/ask from every
59
+ * source but lose allow rules from BOTH project and local files — a
60
+ * committed config.local.json must not smuggle allow rules past the trust
61
+ * gate (see the module header; stricter than Claude Code's localSettings).
62
+ */
52
63
  export declare function effectiveRules(rules: readonly PermissionRule[], isProjectTrusted: boolean): PermissionRule[];
53
64
  //# sourceMappingURL=loadConfig.d.ts.map
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * Permission-rule settings loader.
3
3
  *
4
- * Reads `permissions: { allow, deny, ask }` from the two YAGNI Code settings
5
- * files, mirroring the hooks loader (hooks.ts) shape exactly:
6
- * - user: ~/.yagni-code/config.json — always active
7
- * - project: .yagni-code/config.json — deny/ask always active;
8
- * allow gated on workspace trust (Claude Code semantics:
9
- * "deny and ask rules apply right away; allow rules from a
10
- * project file wait for trust")
4
+ * Reads `permissions: { allow, deny, ask }` from the three YAGNI Code
5
+ * settings files, mirroring the hooks loader (hooks.ts) shape exactly:
6
+ * - user: ~/.yagni-code/config.json — always active
7
+ * - project: .yagni-code/config.json — deny/ask always active;
8
+ * allow gated on workspace trust (Claude Code semantics:
9
+ * "deny and ask rules apply right away; allow rules from a
10
+ * project file wait for trust")
11
+ * - local: .yagni-code/config.local.json — the personal per-project
12
+ * tier; trust-gated EXACTLY like project (a hostile repo can
13
+ * commit a local file — the gitignore convention only covers
14
+ * untracked files). Deliberately stricter than Claude Code,
15
+ * which does not trust-gate localSettings.
11
16
  *
12
17
  * Lists UNION across sources (Claude Code: lists merge, never replace).
13
- * User-source rules are ordered before project-source rules so path-pattern
14
- * negation (`!exception`) behaves like gitignore lines across files.
18
+ * User-source rules are ordered before project-source rules, project before
19
+ * local, so path-pattern negation (`!exception`) behaves like gitignore
20
+ * lines across files — higher-precedence sources come later (later wins).
15
21
  *
16
22
  * Fail-soft: a malformed config file is skipped whole with a warning (same
17
23
  * posture as hooks.ts). Malformed individual rule strings parse as bare
@@ -64,27 +70,36 @@ function readRulesFromFile(path, source, behavior, out, warnings) {
64
70
  warnings.push(`${source} settings: malformed JSON — permissions skipped (${path})`);
65
71
  }
66
72
  }
67
- /** Load + union rules from both files. Pure I/O; no throw. */
73
+ /** Load + union rules from all three files. Pure I/O; no throw. */
68
74
  export function loadPermissionRules(opts = {}) {
69
75
  const warnings = [];
70
76
  const rules = [];
71
77
  const stateHome = opts.stateHomeOverride ?? codeStateHome(null, opts.env, opts.userHome);
78
+ const cwd = opts.cwd ?? process.cwd();
72
79
  const userPath = join(stateHome, "config.json");
73
- const projectPath = join(opts.cwd ?? process.cwd(), ".yagni-code", "config.json");
74
- // User first, project second — ordering matters for path-rule negation.
80
+ const projectPath = join(cwd, ".yagni-code", "config.json");
81
+ const localPath = join(cwd, ".yagni-code", "config.local.json");
82
+ // User, then project, then local — ordering matters for path-rule
83
+ // negation (later sources win, matching scalar precedence).
75
84
  for (const behavior of ["deny", "ask", "allow"]) {
76
85
  readRulesFromFile(userPath, "user", behavior, rules, warnings);
77
86
  readRulesFromFile(projectPath, "project", behavior, rules, warnings);
87
+ readRulesFromFile(localPath, "local", behavior, rules, warnings);
78
88
  }
79
89
  const neverConsultedTools = [
80
90
  ...new Set(rules.map((r) => r.toolName).filter((t) => !KNOWN_RULE_TOOLS.has(t) && !t.startsWith("mcp__"))),
81
91
  ];
82
92
  return { rules, diagnostics: { warnings, neverConsultedTools } };
83
93
  }
84
- /** Filter rules by behavior + trust (project allow needs trust; deny/ask always). */
94
+ /**
95
+ * Filter rules by behavior + trust. Untrusted repos keep deny/ask from every
96
+ * source but lose allow rules from BOTH project and local files — a
97
+ * committed config.local.json must not smuggle allow rules past the trust
98
+ * gate (see the module header; stricter than Claude Code's localSettings).
99
+ */
85
100
  export function effectiveRules(rules, isProjectTrusted) {
86
101
  if (isProjectTrusted)
87
102
  return [...rules];
88
- return rules.filter((r) => !(r.source === "project" && r.behavior === "allow"));
103
+ return rules.filter((r) => !(r.source !== "user" && r.behavior === "allow"));
89
104
  }
90
105
  //# sourceMappingURL=loadConfig.js.map
@@ -8,15 +8,17 @@
8
8
  * //path absolute from filesystem root
9
9
  * ~/path relative to the user's home directory
10
10
  * /path relative to the SETTINGS FILE's directory (user config →
11
- * ~/.yagni-code/, project config → the project root)
11
+ * ~/.yagni-code/, project AND local configs → the project root
12
+ * local lives in the same .yagni-code dir as project)
12
13
  * path relative to the session cwd; a bare filename matches at ANY
13
14
  * depth (`.env` equals `**` / `.env` in glob terms)
14
15
  *
15
16
  * Compile semantics: all patterns for one (tool-class, behavior) compile
16
17
  * into ONE ordered `ignore` instance — user-source patterns first, then
17
- * project — so gitignore's later-line-wins and `!exception` negation work
18
- * across rules from both files (Claude Code's matchingRuleForInput builds
19
- * one ignore().add(patterns) per root the same way).
18
+ * project, then local — so gitignore's later-line-wins and `!exception`
19
+ * negation work across rules from all three files (Claude Code's
20
+ * matchingRuleForInput builds one ignore().add(patterns) per root the same
21
+ * way; local patterns are the last/highest-precedence "lines").
20
22
  *
21
23
  * Behavior asymmetry (Claude Code parity): allow rules with a rootless
22
24
  * single-segment pattern anchor at the settings dir (narrow); deny/ask
@@ -40,9 +42,9 @@ export interface ResolvedPathPattern {
40
42
  }
41
43
  /**
42
44
  * Split an anchored pattern into (root, relativePattern). Mirrors Claude
43
- * Code's patternWithRoot. Project-source `/` anchors at the project root
44
- * (passed in); user-source `/` anchors at the YAGNI Code state home
45
- * (~/.yagni-code).
45
+ * Code's patternWithRoot. Project- and local-source `/` anchors at the
46
+ * project root (passed in) local shares project's .yagni-code dir;
47
+ * user-source `/` anchors at the YAGNI Code state home (~/.yagni-code).
46
48
  */
47
49
  export declare function resolvePatternRoot(pattern: string, source: RuleSource, opts: {
48
50
  userStateHome: string;
@@ -8,15 +8,17 @@
8
8
  * //path absolute from filesystem root
9
9
  * ~/path relative to the user's home directory
10
10
  * /path relative to the SETTINGS FILE's directory (user config →
11
- * ~/.yagni-code/, project config → the project root)
11
+ * ~/.yagni-code/, project AND local configs → the project root
12
+ * local lives in the same .yagni-code dir as project)
12
13
  * path relative to the session cwd; a bare filename matches at ANY
13
14
  * depth (`.env` equals `**` / `.env` in glob terms)
14
15
  *
15
16
  * Compile semantics: all patterns for one (tool-class, behavior) compile
16
17
  * into ONE ordered `ignore` instance — user-source patterns first, then
17
- * project — so gitignore's later-line-wins and `!exception` negation work
18
- * across rules from both files (Claude Code's matchingRuleForInput builds
19
- * one ignore().add(patterns) per root the same way).
18
+ * project, then local — so gitignore's later-line-wins and `!exception`
19
+ * negation work across rules from all three files (Claude Code's
20
+ * matchingRuleForInput builds one ignore().add(patterns) per root the same
21
+ * way; local patterns are the last/highest-precedence "lines").
20
22
  *
21
23
  * Behavior asymmetry (Claude Code parity): allow rules with a rootless
22
24
  * single-segment pattern anchor at the settings dir (narrow); deny/ask
@@ -42,9 +44,9 @@ export function pathClassForTool(toolName) {
42
44
  }
43
45
  /**
44
46
  * Split an anchored pattern into (root, relativePattern). Mirrors Claude
45
- * Code's patternWithRoot. Project-source `/` anchors at the project root
46
- * (passed in); user-source `/` anchors at the YAGNI Code state home
47
- * (~/.yagni-code).
47
+ * Code's patternWithRoot. Project- and local-source `/` anchors at the
48
+ * project root (passed in) local shares project's .yagni-code dir;
49
+ * user-source `/` anchors at the YAGNI Code state home (~/.yagni-code).
48
50
  */
49
51
  export function resolvePatternRoot(pattern, source, opts) {
50
52
  if (pattern.startsWith("//")) {
@@ -55,7 +57,7 @@ export function resolvePatternRoot(pattern, source, opts) {
55
57
  return { relativePattern: rel, root: opts.homeDir ?? homedir(), source };
56
58
  }
57
59
  if (pattern.startsWith("/") && !pattern.startsWith("//")) {
58
- const base = source === "project" ? opts.projectRoot : opts.userStateHome;
60
+ const base = source === "user" ? opts.userStateHome : opts.projectRoot;
59
61
  return { relativePattern: pattern, root: base ?? opts.userStateHome, source };
60
62
  }
61
63
  // Rootless: `./x` normalized to `x`; bare names keep any-depth semantics.
@@ -1,16 +1,17 @@
1
1
  /**
2
- * Sandbox settings schema + layered config load (user + project), shaped after
3
- * Claude Code's SandboxSettingsSchema subset so a Claude `sandbox` block
4
- * copied verbatim parses and resolves identically.
2
+ * Sandbox settings schema + layered config load (user + project + local),
3
+ * shaped after Claude Code's SandboxSettingsSchema subset so a Claude
4
+ * `sandbox` block copied verbatim parses and resolves identically.
5
5
  *
6
- * Files: the `sandbox` key in ~/.yagni-code/config.json (user) and
7
- * .yagni-code/config.json (project) the same pair permissionRules reads,
8
- * so one settings file drives both the permission layer and the sandbox.
6
+ * Files: the `sandbox` key in ~/.yagni-code/config.json (user),
7
+ * .yagni-code/config.json (project), and .yagni-code/config.local.json
8
+ * (local the personal per-project tier; the /sandbox panel and toggle
9
+ * persist HERE).
9
10
  *
10
- * Merge semantics (Claude parity): scalars — project wins over user over
11
- * defaults; arrays — union + dedupe across sources (lists merge, never
12
- * replace). Fail-soft: a malformed file is skipped whole with a warning
13
- * (same posture as permissionRules/loadConfig.ts).
11
+ * Merge semantics (Claude parity): scalars — local wins over project over
12
+ * user over defaults; arrays — union + dedupe across sources (lists merge,
13
+ * never replace). Fail-soft: a malformed file is skipped whole with a
14
+ * warning (same posture as permissionRules/loadConfig.ts).
14
15
  *
15
16
  * Read/write filesystem semantics (srt, verified in the M0 spike): reads are
16
17
  * allowed by default with denyRead regions + allowRead re-allow; writes are
@@ -64,10 +65,10 @@ export interface LoadedSandboxConfig {
64
65
  */
65
66
  export declare function readSandboxSettingsFromFile(configPath: string, warnings: string[], unknownKeys: string[]): SandboxSettings | undefined;
66
67
  /**
67
- * Load + merge sandbox settings from both config files. Scalars: project
68
- * beats user; arrays: union. Defaults for scalars land here too (Claude
69
- * parity): autoAllowBashIfSandboxed true, allowUnsandboxedCommands true,
70
- * everything else unset/false.
68
+ * Load + merge sandbox settings from all three config files. Scalars: local
69
+ * beats project beats user; arrays: union. Defaults for scalars land here
70
+ * too (Claude parity): autoAllowBashIfSandboxed true, allowUnsandboxedCommands
71
+ * true, everything else unset/false.
71
72
  */
72
73
  export declare function loadSandboxSettings(opts?: {
73
74
  cwd?: string;
@@ -1,16 +1,17 @@
1
1
  /**
2
- * Sandbox settings schema + layered config load (user + project), shaped after
3
- * Claude Code's SandboxSettingsSchema subset so a Claude `sandbox` block
4
- * copied verbatim parses and resolves identically.
2
+ * Sandbox settings schema + layered config load (user + project + local),
3
+ * shaped after Claude Code's SandboxSettingsSchema subset so a Claude
4
+ * `sandbox` block copied verbatim parses and resolves identically.
5
5
  *
6
- * Files: the `sandbox` key in ~/.yagni-code/config.json (user) and
7
- * .yagni-code/config.json (project) the same pair permissionRules reads,
8
- * so one settings file drives both the permission layer and the sandbox.
6
+ * Files: the `sandbox` key in ~/.yagni-code/config.json (user),
7
+ * .yagni-code/config.json (project), and .yagni-code/config.local.json
8
+ * (local the personal per-project tier; the /sandbox panel and toggle
9
+ * persist HERE).
9
10
  *
10
- * Merge semantics (Claude parity): scalars — project wins over user over
11
- * defaults; arrays — union + dedupe across sources (lists merge, never
12
- * replace). Fail-soft: a malformed file is skipped whole with a warning
13
- * (same posture as permissionRules/loadConfig.ts).
11
+ * Merge semantics (Claude parity): scalars — local wins over project over
12
+ * user over defaults; arrays — union + dedupe across sources (lists merge,
13
+ * never replace). Fail-soft: a malformed file is skipped whole with a
14
+ * warning (same posture as permissionRules/loadConfig.ts).
14
15
  *
15
16
  * Read/write filesystem semantics (srt, verified in the M0 spike): reads are
16
17
  * allowed by default with denyRead regions + allowRead re-allow; writes are
@@ -68,7 +69,11 @@ export function readSandboxSettingsFromFile(configPath, warnings, unknownKeys) {
68
69
  parsed = JSON.parse(readFileSync(configPath, "utf-8"));
69
70
  }
70
71
  catch (err) {
71
- warnings.push(`sandbox: could not parse ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
72
+ // Error CLASS only, never the thrown message: V8's JSON.parse message
73
+ // embeds a snippet of the file's own content, and these warnings are
74
+ // forwarded verbatim into the error sink — a corrupt/hostile config's
75
+ // contents must not ride the trail. Path + class stays diagnosable.
76
+ warnings.push(`sandbox: could not parse ${configPath}: ${err instanceof Error ? err.constructor.name : typeof err}`);
72
77
  return undefined;
73
78
  }
74
79
  if (!isPlainObject(parsed) || parsed.sandbox === undefined)
@@ -178,54 +183,69 @@ function union(...lists) {
178
183
  return undefined;
179
184
  return [...new Set(present.flat())];
180
185
  }
181
- function mergeNetwork(user, project) {
186
+ function mergeNetwork(user, project, local) {
182
187
  const u = user ?? {};
183
188
  const p = project ?? {};
189
+ const l = local ?? {};
184
190
  return {
185
- allowedDomains: union(u.allowedDomains, p.allowedDomains) ?? [],
186
- deniedDomains: union(u.deniedDomains, p.deniedDomains) ?? [],
187
- allowUnixSockets: union(u.allowUnixSockets, p.allowUnixSockets),
188
- allowAllUnixSockets: p.allowAllUnixSockets ?? u.allowAllUnixSockets,
189
- allowLocalBinding: p.allowLocalBinding ?? u.allowLocalBinding,
190
- httpProxyPort: p.httpProxyPort ?? u.httpProxyPort,
191
- socksProxyPort: p.socksProxyPort ?? u.socksProxyPort,
191
+ allowedDomains: union(u.allowedDomains, p.allowedDomains, l.allowedDomains) ?? [],
192
+ deniedDomains: union(u.deniedDomains, p.deniedDomains, l.deniedDomains) ?? [],
193
+ allowUnixSockets: union(u.allowUnixSockets, p.allowUnixSockets, l.allowUnixSockets),
194
+ allowAllUnixSockets: l.allowAllUnixSockets ?? p.allowAllUnixSockets ?? u.allowAllUnixSockets,
195
+ allowLocalBinding: l.allowLocalBinding ?? p.allowLocalBinding ?? u.allowLocalBinding,
196
+ httpProxyPort: l.httpProxyPort ?? p.httpProxyPort ?? u.httpProxyPort,
197
+ socksProxyPort: l.socksProxyPort ?? p.socksProxyPort ?? u.socksProxyPort,
192
198
  };
193
199
  }
194
- function mergeFilesystem(user, project) {
200
+ function mergeFilesystem(user, project, local) {
195
201
  const u = user ?? {};
196
202
  const p = project ?? {};
203
+ const l = local ?? {};
197
204
  return {
198
- allowRead: union(u.allowRead, p.allowRead),
199
- denyRead: union(u.denyRead, p.denyRead) ?? [],
200
- allowWrite: union(u.allowWrite, p.allowWrite) ?? [],
201
- denyWrite: union(u.denyWrite, p.denyWrite) ?? [],
205
+ allowRead: union(u.allowRead, p.allowRead, l.allowRead),
206
+ denyRead: union(u.denyRead, p.denyRead, l.denyRead) ?? [],
207
+ allowWrite: union(u.allowWrite, p.allowWrite, l.allowWrite) ?? [],
208
+ denyWrite: union(u.denyWrite, p.denyWrite, l.denyWrite) ?? [],
202
209
  };
203
210
  }
204
211
  /**
205
- * Load + merge sandbox settings from both config files. Scalars: project
206
- * beats user; arrays: union. Defaults for scalars land here too (Claude
207
- * parity): autoAllowBashIfSandboxed true, allowUnsandboxedCommands true,
208
- * everything else unset/false.
212
+ * Load + merge sandbox settings from all three config files. Scalars: local
213
+ * beats project beats user; arrays: union. Defaults for scalars land here
214
+ * too (Claude parity): autoAllowBashIfSandboxed true, allowUnsandboxedCommands
215
+ * true, everything else unset/false.
209
216
  */
210
217
  export function loadSandboxSettings(opts = {}) {
211
218
  const warnings = [];
212
219
  const unknownKeys = [];
213
220
  const stateHome = opts.stateHomeOverride ?? codeStateHome(null, opts.env, opts.userHome);
221
+ const cwd = opts.cwd ?? process.cwd();
214
222
  const userPath = join(stateHome, "config.json");
215
- const projectPath = join(opts.cwd ?? process.cwd(), ".yagni-code", "config.json");
223
+ const projectPath = join(cwd, ".yagni-code", "config.json");
224
+ const localPath = join(cwd, ".yagni-code", "config.local.json");
216
225
  const userSettings = readSandboxSettingsFromFile(userPath, warnings, unknownKeys);
217
226
  const projectSettings = readSandboxSettingsFromFile(projectPath, warnings, unknownKeys);
227
+ const localSettings = readSandboxSettingsFromFile(localPath, warnings, unknownKeys);
218
228
  const settings = {
219
- enabled: projectSettings?.enabled ?? userSettings?.enabled ?? false,
220
- autoAllowBashIfSandboxed: projectSettings?.autoAllowBashIfSandboxed ?? userSettings?.autoAllowBashIfSandboxed ?? true,
221
- allowUnsandboxedCommands: projectSettings?.allowUnsandboxedCommands ?? userSettings?.allowUnsandboxedCommands ?? true,
222
- failIfUnavailable: projectSettings?.failIfUnavailable ?? userSettings?.failIfUnavailable ?? false,
223
- excludedCommands: union(userSettings?.excludedCommands, projectSettings?.excludedCommands) ?? [],
224
- network: mergeNetwork(userSettings?.network, projectSettings?.network),
225
- filesystem: mergeFilesystem(userSettings?.filesystem, projectSettings?.filesystem),
226
- ignoreViolations: projectSettings?.ignoreViolations ?? userSettings?.ignoreViolations,
227
- enableWeakerNestedSandbox: projectSettings?.enableWeakerNestedSandbox ?? userSettings?.enableWeakerNestedSandbox,
228
- enableWeakerNetworkIsolation: projectSettings?.enableWeakerNetworkIsolation ?? userSettings?.enableWeakerNetworkIsolation,
229
+ enabled: localSettings?.enabled ?? projectSettings?.enabled ?? userSettings?.enabled ?? false,
230
+ autoAllowBashIfSandboxed: localSettings?.autoAllowBashIfSandboxed ??
231
+ projectSettings?.autoAllowBashIfSandboxed ??
232
+ userSettings?.autoAllowBashIfSandboxed ??
233
+ true,
234
+ allowUnsandboxedCommands: localSettings?.allowUnsandboxedCommands ??
235
+ projectSettings?.allowUnsandboxedCommands ??
236
+ userSettings?.allowUnsandboxedCommands ??
237
+ true,
238
+ failIfUnavailable: localSettings?.failIfUnavailable ?? projectSettings?.failIfUnavailable ?? userSettings?.failIfUnavailable ?? false,
239
+ excludedCommands: union(userSettings?.excludedCommands, projectSettings?.excludedCommands, localSettings?.excludedCommands) ?? [],
240
+ network: mergeNetwork(userSettings?.network, projectSettings?.network, localSettings?.network),
241
+ filesystem: mergeFilesystem(userSettings?.filesystem, projectSettings?.filesystem, localSettings?.filesystem),
242
+ ignoreViolations: localSettings?.ignoreViolations ?? projectSettings?.ignoreViolations ?? userSettings?.ignoreViolations,
243
+ enableWeakerNestedSandbox: localSettings?.enableWeakerNestedSandbox ??
244
+ projectSettings?.enableWeakerNestedSandbox ??
245
+ userSettings?.enableWeakerNestedSandbox,
246
+ enableWeakerNetworkIsolation: localSettings?.enableWeakerNetworkIsolation ??
247
+ projectSettings?.enableWeakerNetworkIsolation ??
248
+ userSettings?.enableWeakerNetworkIsolation,
229
249
  };
230
250
  return { settings, diagnostics: { warnings, unknownKeys } };
231
251
  }
@@ -343,8 +363,10 @@ export function mergeRulesIntoSandbox(settings, rules, base) {
343
363
  // Protected paths: always denyWrite, never exempted (Claude parity + our
344
364
  // own surfaces). Note srt denyWrite also denies read-of-ignored writes.
345
365
  denyWrite.add(join(base.userStateHome, "config.json"));
346
- if (base.projectRoot)
366
+ if (base.projectRoot) {
347
367
  denyWrite.add(join(base.projectRoot, ".yagni-code", "config.json"));
368
+ denyWrite.add(join(base.projectRoot, ".yagni-code", "config.local.json"));
369
+ }
348
370
  denyWrite.add(base.userStateHome);
349
371
  if (base.projectRoot)
350
372
  denyWrite.add(join(base.projectRoot, ".yagni-code"));
@@ -46,7 +46,17 @@ export declare class YagniSandboxManager {
46
46
  private state;
47
47
  private askHandler;
48
48
  private readonly opts;
49
+ /** Config warnings already logged this process — buildRuntimeMerge runs
50
+ * per grant/setRules refresh, and re-logging the same corrupt-file warning
51
+ * per call would flood the trail with one line per grant. */
52
+ private loggedConfigWarnings;
49
53
  constructor(opts: SandboxSessionOptions);
54
+ /** Surface loader diagnostics (warnings + unknown keys) once per string
55
+ * per process: a corrupt or wrong-shaped sandbox block in any of the
56
+ * three config files is skipped whole (fail-soft), and a silently ignored
57
+ * config (or a typo'd key) needs its signal — same posture as index.ts
58
+ * surfacing the rule-loader warnings. */
59
+ private surfaceConfigDiagnostics;
50
60
  get initialized(): boolean;
51
61
  get settings(): SandboxSettings | null;
52
62
  get dependencies(): SandboxDependencyStatus | null;
@@ -24,10 +24,33 @@ export class YagniSandboxManager {
24
24
  state = { initialized: false, dependencies: null, settings: null };
25
25
  askHandler = null;
26
26
  opts;
27
+ /** Config warnings already logged this process — buildRuntimeMerge runs
28
+ * per grant/setRules refresh, and re-logging the same corrupt-file warning
29
+ * per call would flood the trail with one line per grant. */
30
+ loggedConfigWarnings = new Set();
27
31
  constructor(opts) {
28
32
  this.opts = opts;
29
33
  this.askHandler = opts.askHandler ?? null;
30
34
  }
35
+ /** Surface loader diagnostics (warnings + unknown keys) once per string
36
+ * per process: a corrupt or wrong-shaped sandbox block in any of the
37
+ * three config files is skipped whole (fail-soft), and a silently ignored
38
+ * config (or a typo'd key) needs its signal — same posture as index.ts
39
+ * surfacing the rule-loader warnings. */
40
+ surfaceConfigDiagnostics(diagnostics) {
41
+ const messages = [
42
+ ...diagnostics.warnings,
43
+ ...(diagnostics.unknownKeys.length > 0
44
+ ? [`sandbox: unknown keys ignored: ${diagnostics.unknownKeys.join(", ")}`]
45
+ : []),
46
+ ];
47
+ for (const message of messages) {
48
+ if (this.loggedConfigWarnings.has(message))
49
+ continue;
50
+ this.loggedConfigWarnings.add(message);
51
+ logEvent({ source: "sandbox", level: "warn", event: "sandbox_config_warning", fields: { warning: message } });
52
+ }
53
+ }
31
54
  get initialized() { return this.state.initialized; }
32
55
  get settings() { return this.state.settings; }
33
56
  get dependencies() { return this.state.dependencies; }
@@ -75,12 +98,13 @@ export class YagniSandboxManager {
75
98
  * tests; initialize() consumes it internally.
76
99
  */
77
100
  buildRuntimeMerge(rules) {
78
- const { settings } = loadSandboxSettings({
101
+ const { settings, diagnostics } = loadSandboxSettings({
79
102
  cwd: this.opts.cwd,
80
103
  env: this.opts.env,
81
104
  userHome: this.opts.userHome,
82
105
  stateHomeOverride: this.opts.stateHomeOverride,
83
106
  });
107
+ this.surfaceConfigDiagnostics(diagnostics);
84
108
  return mergeRulesIntoSandbox(settings, rules, {
85
109
  cwd: this.opts.cwd,
86
110
  userStateHome: this.opts.stateHomeOverride ?? codeStateHome(null, this.opts.env, this.opts.userHome),
@@ -134,6 +158,9 @@ export class YagniSandboxManager {
134
158
  userHome: this.opts.userHome,
135
159
  stateHomeOverride: this.opts.stateHomeOverride,
136
160
  });
161
+ // Same warning surfacing as buildRuntimeMerge — initialize() skips
162
+ // buildRuntimeMerge on the disabled path, so its own load reports too.
163
+ this.surfaceConfigDiagnostics(load.diagnostics);
137
164
  if (!load.settings.enabled && !opts.force)
138
165
  return "sandbox disabled by config";
139
166
  const deps = this.checkDependencies();