@yagni-app/code-staging 1.0.7-staging.1268.1 → 1.0.7-staging.1272.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.
@@ -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();