@yagni-app/code 1.0.7 → 1.0.8
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/README.md +8 -1
- package/dist/extension/askUserQuestionTool.js +7 -2
- package/dist/extension/config.d.ts +6 -0
- package/dist/extension/hooks.d.ts +3 -3
- package/dist/extension/hooks.js +30 -5
- package/dist/extension/index.d.ts +6 -0
- package/dist/extension/index.js +56 -38
- package/dist/extension/permission/gate.d.ts +5 -1
- package/dist/extension/permission/gate.js +138 -44
- package/dist/extension/permissionRules/loadConfig.d.ts +23 -12
- package/dist/extension/permissionRules/loadConfig.js +29 -14
- package/dist/extension/permissionRules/pathRules.d.ts +9 -7
- package/dist/extension/permissionRules/pathRules.js +10 -8
- package/dist/extension/sandbox/config.d.ts +15 -14
- package/dist/extension/sandbox/config.js +62 -40
- package/dist/extension/sandbox/manager.d.ts +10 -0
- package/dist/extension/sandbox/manager.js +28 -1
- package/dist/extension/sandbox/session.js +150 -96
- package/dist/extension/settingsFiles.d.ts +50 -0
- package/dist/extension/settingsFiles.js +206 -0
- package/dist/extension/telemetry/config.d.ts +5 -1
- package/dist/extension/telemetry/register.d.ts +7 -0
- package/dist/extension/telemetry/register.js +15 -0
- package/dist/upgrade.js +10 -1
- package/package.json +2 -2
|
@@ -12,18 +12,20 @@
|
|
|
12
12
|
* for a session restart, the same way Claude persists the toggle.
|
|
13
13
|
*/
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
|
-
import { existsSync
|
|
16
|
-
import {
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
17
|
import { Type } from "typebox";
|
|
18
18
|
import { Key } from "@earendil-works/pi-tui";
|
|
19
19
|
import { createBashToolDefinition, getShellConfig, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { logEvent } from "../errorSink.js";
|
|
21
21
|
import { codeStateHome } from "../stateHome.js";
|
|
22
22
|
import { isDebug } from "../diagnostics.js";
|
|
23
|
+
import { mutateConfigJson, mutateLocalConfig } from "../settingsFiles.js";
|
|
23
24
|
import { loadSandboxSettings } from "./config.js";
|
|
24
25
|
import { annotateCommandOutput, makeSandboxSpawnHook, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
|
|
25
26
|
import { YagniSandboxManager } from "./manager.js";
|
|
26
27
|
import { SandboxPanel, buildPanelState } from "./panel.js";
|
|
28
|
+
import { effectiveRules } from "../permissionRules/loadConfig.js";
|
|
27
29
|
/**
|
|
28
30
|
* Build the sandbox bash composition: given ANY stock bash definition (pi's
|
|
29
31
|
* own or condensedTools'), return the sandbox-aware version — schema gains
|
|
@@ -177,6 +179,33 @@ export function registerSandbox(pi, opts) {
|
|
|
177
179
|
if (ctx?.ui)
|
|
178
180
|
uiBridge = ctx;
|
|
179
181
|
};
|
|
182
|
+
// Trust snapshot for the sandbox rule merge (D3): an untrusted repo's
|
|
183
|
+
// committed allow rules (project OR local) must not widen the OS-level
|
|
184
|
+
// allowWrite/deniedDomains sets — the same filtering the permission gate
|
|
185
|
+
// applies. Captured from the latest ctx; defaults trusted (parity with
|
|
186
|
+
// the gate's fail-open posture on a missing isProjectTrusted).
|
|
187
|
+
let trustedSnapshot = true;
|
|
188
|
+
const captureTrust = (ctx) => {
|
|
189
|
+
try {
|
|
190
|
+
if (typeof ctx?.isProjectTrusted === "function")
|
|
191
|
+
trustedSnapshot = ctx.isProjectTrusted();
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
// Keep the prior snapshot (fail-open parity with the gate) but make
|
|
195
|
+
// the throw VISIBLE: warn (not debug) — readSessionTrail filters debug
|
|
196
|
+
// lines, so a debug event would never reach /feedback; a trust-probe
|
|
197
|
+
// failure silently pinning the wrong posture must be surfacable.
|
|
198
|
+
// Scrub-safe: error class only, no thrown message.
|
|
199
|
+
logEvent({
|
|
200
|
+
source: "sandbox",
|
|
201
|
+
level: "warn",
|
|
202
|
+
event: "trust_probe_failed",
|
|
203
|
+
fields: { error: err instanceof Error ? err.constructor.name : typeof err },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
/** Rules filtered to what the sandbox may enforce OS-wide (trust-aware). */
|
|
208
|
+
const effectiveRulesForSandbox = () => effectiveRules(rules, trustedSnapshot);
|
|
180
209
|
const sessionDomainGrants = new Set();
|
|
181
210
|
const askHandler = async (host) => {
|
|
182
211
|
if (sessionDomainGrants.has(host))
|
|
@@ -212,9 +241,11 @@ export function registerSandbox(pi, opts) {
|
|
|
212
241
|
try {
|
|
213
242
|
persistDomainGrant(opts.projectRoot ?? opts.cwd, host, resolvedStateHome);
|
|
214
243
|
}
|
|
215
|
-
catch {
|
|
244
|
+
catch (err) {
|
|
216
245
|
// fail-soft: the session grant already holds; the persistence just
|
|
217
|
-
// did not — the user chose it, so surface it
|
|
246
|
+
// did not — the user chose it, so surface it (notify + sink, same
|
|
247
|
+
// posture as persistToggleAndNotify) rather than stay silent.
|
|
248
|
+
logGrantPersistFailure(host, "project", err);
|
|
218
249
|
ui.ui.notify?.("Could not persist the domain grant to the config file — it applies to this session only.", "warning");
|
|
219
250
|
}
|
|
220
251
|
}
|
|
@@ -222,12 +253,13 @@ export function registerSandbox(pi, opts) {
|
|
|
222
253
|
try {
|
|
223
254
|
persistDomainGrant(null, host, resolvedStateHome);
|
|
224
255
|
}
|
|
225
|
-
catch {
|
|
256
|
+
catch (err) {
|
|
257
|
+
logGrantPersistFailure(host, "user", err);
|
|
226
258
|
ui.ui.notify?.("Could not persist the domain grant to the config file — it applies to this session only.", "warning");
|
|
227
259
|
}
|
|
228
260
|
}
|
|
229
261
|
// The grant must reach the live srt config before the retry.
|
|
230
|
-
manager.refreshConfig([...
|
|
262
|
+
manager.refreshConfig([...effectiveRulesForSandbox()]);
|
|
231
263
|
return true;
|
|
232
264
|
};
|
|
233
265
|
// --- bash composition ---
|
|
@@ -254,7 +286,7 @@ export function registerSandbox(pi, opts) {
|
|
|
254
286
|
},
|
|
255
287
|
grantDomainSession: (domain) => {
|
|
256
288
|
sessionDomainGrants.add(domain);
|
|
257
|
-
manager.refreshConfig([...
|
|
289
|
+
manager.refreshConfig([...effectiveRulesForSandbox()]);
|
|
258
290
|
},
|
|
259
291
|
sessionDomains: () => [...sessionDomainGrants],
|
|
260
292
|
composeBash,
|
|
@@ -322,10 +354,12 @@ export function registerSandbox(pi, opts) {
|
|
|
322
354
|
// (fired from inside bash execution, no event ctx) can prompt.
|
|
323
355
|
pi.on("tool_call", async (_event, ctx) => {
|
|
324
356
|
captureUi(ctx);
|
|
357
|
+
captureTrust(ctx);
|
|
325
358
|
return;
|
|
326
359
|
});
|
|
327
360
|
pi.on("session_start", async (_event, ctx) => {
|
|
328
361
|
captureUi(ctx);
|
|
362
|
+
captureTrust(ctx);
|
|
329
363
|
currentSettings = load();
|
|
330
364
|
// --no-sandbox skips init in EVERY mode — headless included. The notify
|
|
331
365
|
// is UI-gated, the SKIP never is (a UI-only skip would silently keep the
|
|
@@ -338,7 +372,7 @@ export function registerSandbox(pi, opts) {
|
|
|
338
372
|
if (!currentSettings.enabled)
|
|
339
373
|
return;
|
|
340
374
|
manager.setAskHandler(askHandler);
|
|
341
|
-
const err = await manager.initialize(
|
|
375
|
+
const err = await manager.initialize(effectiveRulesForSandbox());
|
|
342
376
|
if (err) {
|
|
343
377
|
logEvent({
|
|
344
378
|
source: "sandbox",
|
|
@@ -391,21 +425,57 @@ export function registerSandbox(pi, opts) {
|
|
|
391
425
|
};
|
|
392
426
|
/**
|
|
393
427
|
* Session-level toggle. OFF is always immediate (manager.reset unwraps the
|
|
394
|
-
* next command)
|
|
428
|
+
* next command) and — when the session started ENABLED — PERSISTS the
|
|
429
|
+
* disable to the local config (Claude's toggle persists both directions).
|
|
430
|
+
* ON is split by how the session started:
|
|
395
431
|
* - config-enabled session: re-initialize (force past nothing — it was on)
|
|
396
|
-
|
|
432
|
+
* - default-off session: the wrapped bash may not have registered at load
|
|
397
433
|
* (condensedTools owns the registration in the common TUI and reads disk
|
|
398
434
|
* settings, not this session's toggle). So /sandbox enable PERSISTS
|
|
399
|
-
* sandbox.enabled=true to the
|
|
435
|
+
* sandbox.enabled=true to the local config (Claude's own panel persists
|
|
400
436
|
* its mode choice the same way) and asks for a restart — the honest
|
|
401
437
|
* behavior; a silent no-op would be a security lie.
|
|
402
438
|
*/
|
|
439
|
+
/** Persist-or-notify for the enable/disable toggles — keeps
|
|
440
|
+
* setSessionToggle flat: try the local write, return the success notice
|
|
441
|
+
* on success or the failure notice on throw (the caller decides level +
|
|
442
|
+
* side effects like the sessionToggle reset). A failed persist also logs
|
|
443
|
+
* sandbox_persist_failed with the thrown message (path/reason text from
|
|
444
|
+
* mutateConfigJson) — same diagnosability posture as rule_save_failed. */
|
|
445
|
+
const persistToggleAndNotify = (enabled, ctx) => {
|
|
446
|
+
try {
|
|
447
|
+
persistEnabledFlip(enabled, opts.cwd);
|
|
448
|
+
return {
|
|
449
|
+
ok: true,
|
|
450
|
+
notice: enabled
|
|
451
|
+
? "Sandbox enabled in .yagni-code/config.local.json — restart the session (or /new) to apply it to the model's bash tool. Your ! commands are NOT sandboxed until then."
|
|
452
|
+
: "Sandbox disabled for this session and set to off in .yagni-code/config.local.json",
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
logEvent({
|
|
457
|
+
source: "sandbox",
|
|
458
|
+
level: "warn",
|
|
459
|
+
event: "sandbox_persist_failed",
|
|
460
|
+
fields: {
|
|
461
|
+
enabled,
|
|
462
|
+
error: err instanceof Error ? err.message : String(err),
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
notice: enabled
|
|
468
|
+
? 'Could not write the sandbox enable to .yagni-code/config.local.json — set "sandbox": { "enabled": true } there and restart.'
|
|
469
|
+
: "Sandbox disabled for this session (could not persist the off state to config.local.json — a restart may re-arm it)",
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
};
|
|
403
473
|
const setSessionToggle = async (on, ctx) => {
|
|
404
474
|
sessionToggle = on;
|
|
405
475
|
if (on) {
|
|
406
476
|
const startedEnabled = load().enabled;
|
|
407
477
|
if (startedEnabled) {
|
|
408
|
-
const err = await manager.initialize(
|
|
478
|
+
const err = await manager.initialize(effectiveRulesForSandbox(), { force: true });
|
|
409
479
|
if (err) {
|
|
410
480
|
ctx.ui.notify(`Sandbox unavailable: ${err}`, "error");
|
|
411
481
|
sessionToggle = null;
|
|
@@ -424,13 +494,13 @@ export function registerSandbox(pi, opts) {
|
|
|
424
494
|
else {
|
|
425
495
|
// Persist the enable (fail-soft: on write failure tell the user to
|
|
426
496
|
// edit the file — never claim success falsely).
|
|
427
|
-
|
|
428
|
-
|
|
497
|
+
const r = persistToggleAndNotify(true, ctx);
|
|
498
|
+
if (r.ok) {
|
|
429
499
|
ctx.ui.setStatus?.("sandbox", "sandbox on (restart to apply)");
|
|
430
|
-
ctx.ui.notify(
|
|
500
|
+
ctx.ui.notify(r.notice, "warning");
|
|
431
501
|
}
|
|
432
|
-
|
|
433
|
-
ctx.ui.notify(
|
|
502
|
+
else {
|
|
503
|
+
ctx.ui.notify(r.notice, "error");
|
|
434
504
|
sessionToggle = null;
|
|
435
505
|
}
|
|
436
506
|
}
|
|
@@ -438,19 +508,32 @@ export function registerSandbox(pi, opts) {
|
|
|
438
508
|
else {
|
|
439
509
|
await manager.reset();
|
|
440
510
|
ctx.ui.setStatus?.("sandbox", "");
|
|
441
|
-
|
|
511
|
+
// Persist the disable when the session started enabled — a restart
|
|
512
|
+
// must not silently re-arm the sandbox the user just turned off.
|
|
513
|
+
// Fail-soft: the session-level reset above already applies NOW.
|
|
514
|
+
if (load().enabled) {
|
|
515
|
+
const r = persistToggleAndNotify(false, ctx);
|
|
516
|
+
// Error (not warning): a failed off-persist silently re-arms the
|
|
517
|
+
// sandbox on restart — the security-relevant direction, at least as
|
|
518
|
+
// loud as the enable failure.
|
|
519
|
+
ctx.ui.notify(r.notice, r.ok ? "warning" : "error");
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
ctx.ui.notify("Sandbox disabled for this session", "warning");
|
|
523
|
+
}
|
|
442
524
|
}
|
|
443
525
|
};
|
|
444
526
|
/**
|
|
445
527
|
* The interactive /sandbox panel (TUI only). Selections persist to the
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
* via notify after the panel closes,
|
|
449
|
-
* enable-from-default-off keeps the
|
|
528
|
+
* PROJECT-LOCAL config (.yagni-code/config.local.json — the personal
|
|
529
|
+
* per-project tier, Claude Code's own panel destination); the
|
|
530
|
+
* confirmation message is surfaced via notify after the panel closes,
|
|
531
|
+
* mirroring Claude's onComplete flow. enable-from-default-off keeps the
|
|
532
|
+
* honest restart advice.
|
|
450
533
|
*/
|
|
451
534
|
const openSandboxPanel = async (ctx) => {
|
|
452
535
|
const deps = manager.checkDependencies();
|
|
453
|
-
const state = buildPanelState(currentSettings, sessionToggle === false,
|
|
536
|
+
const state = buildPanelState(currentSettings, sessionToggle === false, effectiveRulesForSandbox(), {
|
|
454
537
|
cwd: opts.cwd,
|
|
455
538
|
userStateHome: resolvedStateHome,
|
|
456
539
|
projectRoot: opts.projectRoot ?? null,
|
|
@@ -460,11 +543,11 @@ export function registerSandbox(pi, opts) {
|
|
|
460
543
|
onModeSelect: async (choice) => {
|
|
461
544
|
const wasEnabled = load().enabled === true;
|
|
462
545
|
try {
|
|
463
|
-
persistSandboxMode(choice,
|
|
546
|
+
persistSandboxMode(choice, opts.cwd);
|
|
464
547
|
}
|
|
465
548
|
catch {
|
|
466
549
|
return {
|
|
467
|
-
text: `Could not write the sandbox setting to
|
|
550
|
+
text: `Could not write the sandbox setting to .yagni-code/config.local.json — edit it directly ("sandbox": { "enabled": ${choice !== "disabled"} }) and restart.`,
|
|
468
551
|
level: "error",
|
|
469
552
|
};
|
|
470
553
|
}
|
|
@@ -484,37 +567,37 @@ export function registerSandbox(pi, opts) {
|
|
|
484
567
|
// the new mode to the model's bash tool.
|
|
485
568
|
ctx.ui.setStatus?.("sandbox", "sandbox on (restart to apply)");
|
|
486
569
|
return {
|
|
487
|
-
text: `${message} — saved to
|
|
570
|
+
text: `${message} — saved to project-local settings (.yagni-code/config.local.json); restart the session (or /new) to apply it to the model's bash tool.`,
|
|
488
571
|
level: "info",
|
|
489
572
|
};
|
|
490
573
|
}
|
|
491
574
|
// Already-enabled session changing autoAllow only: refresh the
|
|
492
575
|
// live merge so the next bash call sees the new decision posture.
|
|
493
576
|
if (manager.initialized)
|
|
494
|
-
manager.refreshConfig(
|
|
577
|
+
manager.refreshConfig(effectiveRulesForSandbox());
|
|
495
578
|
ctx.ui.setStatus?.("sandbox", "sandbox on");
|
|
496
579
|
return {
|
|
497
|
-
text: `${message} — saved to
|
|
580
|
+
text: `${message} — saved to project-local settings (.yagni-code/config.local.json).`,
|
|
498
581
|
level: "info",
|
|
499
582
|
};
|
|
500
583
|
},
|
|
501
584
|
onOverrideSelect: async (choice) => {
|
|
502
585
|
try {
|
|
503
|
-
persistSandboxOverride(choice,
|
|
586
|
+
persistSandboxOverride(choice, opts.cwd);
|
|
504
587
|
}
|
|
505
588
|
catch {
|
|
506
589
|
return {
|
|
507
|
-
text: `Could not write the sandbox override to
|
|
590
|
+
text: `Could not write the sandbox override to .yagni-code/config.local.json — edit it directly ("sandbox": { "allowUnsandboxedCommands": ... }) and restart.`,
|
|
508
591
|
level: "error",
|
|
509
592
|
};
|
|
510
593
|
}
|
|
511
594
|
currentSettings = load();
|
|
512
595
|
if (manager.initialized)
|
|
513
|
-
manager.refreshConfig(
|
|
596
|
+
manager.refreshConfig(effectiveRulesForSandbox());
|
|
514
597
|
return {
|
|
515
598
|
text: choice === "open"
|
|
516
|
-
? "✓ Unsandboxed fallback allowed — commands can run outside the sandbox when necessary — saved to
|
|
517
|
-
: "✓ Strict sandbox mode — all commands must run in the sandbox or be excluded via the excludedCommands option — saved to
|
|
599
|
+
? "✓ Unsandboxed fallback allowed — commands can run outside the sandbox when necessary — saved to project-local settings"
|
|
600
|
+
: "✓ Strict sandbox mode — all commands must run in the sandbox or be excluded via the excludedCommands option — saved to project-local settings",
|
|
518
601
|
level: "info",
|
|
519
602
|
};
|
|
520
603
|
},
|
|
@@ -580,55 +663,25 @@ export function registerSandbox(pi, opts) {
|
|
|
580
663
|
* Persist a domain grant to the user or project sandbox config (fail-soft:
|
|
581
664
|
* the session grant already applies even if the write fails).
|
|
582
665
|
*/
|
|
583
|
-
/**
|
|
584
|
-
* Shared safe config mutation for the isolated user/project config.json:
|
|
585
|
-
* - a PARSE FAILURE aborts (never treats a corrupt/half-written file as
|
|
586
|
-
* empty — that would wipe activeProfile/permissions);
|
|
587
|
-
* - the write is ATOMIC (tmp file + rename) so a crash never truncates;
|
|
588
|
-
* - symlinked targets are rejected (a hostile repo's config symlink must
|
|
589
|
-
* not redirect grants to arbitrary paths);
|
|
590
|
-
* - the mutator runs only on a parsed object; a thrown mutator aborts.
|
|
591
|
-
* Throws on any failure — callers decide fail-soft messaging.
|
|
592
|
-
*/
|
|
593
|
-
function mutateConfigJson(target, mutate) {
|
|
594
|
-
let parsed;
|
|
595
|
-
try {
|
|
596
|
-
parsed = JSON.parse(readFileSync(target, "utf-8"));
|
|
597
|
-
}
|
|
598
|
-
catch (err) {
|
|
599
|
-
if (err.code === "ENOENT") {
|
|
600
|
-
parsed = {}; // genuinely fresh file — fine
|
|
601
|
-
}
|
|
602
|
-
else {
|
|
603
|
-
throw new Error(`config.json at ${target} is not valid JSON — refusing to rewrite it`);
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
if (!isPlainRecord(parsed))
|
|
607
|
-
throw new Error(`config.json at ${target} is not an object`);
|
|
608
|
-
const config = { ...parsed };
|
|
609
|
-
mutate(config);
|
|
610
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
611
|
-
// Reject symlinked targets: lstat must show a regular file (or absent).
|
|
612
|
-
try {
|
|
613
|
-
const st = lstatSync(target);
|
|
614
|
-
if (!st.isFile())
|
|
615
|
-
throw new Error(`${target} is not a regular file (symlink?) — refusing`);
|
|
616
|
-
}
|
|
617
|
-
catch (err) {
|
|
618
|
-
if (err.code !== "ENOENT")
|
|
619
|
-
throw err;
|
|
620
|
-
}
|
|
621
|
-
const tmp = `${target}.sandbox-tmp-${process.pid}`;
|
|
622
|
-
writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
623
|
-
// chmod: writeFile honors mode only on create — guarantee 0600 even when a
|
|
624
|
-
// same-pid retry reuses an existing tmp file (same posture as
|
|
625
|
-
// persistUserRule's config write).
|
|
626
|
-
chmodSync(tmp, 0o600);
|
|
627
|
-
renameSync(tmp, target);
|
|
628
|
-
}
|
|
666
|
+
/** Local helper for the grant/target writers below (shared shape). */
|
|
629
667
|
function isPlainRecord(v) {
|
|
630
668
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
631
669
|
}
|
|
670
|
+
/** Sink line for a failed domain-grant persist — the thrown message is
|
|
671
|
+
* mutateConfigJson's own path/reason text (diagnosable, no file content),
|
|
672
|
+
* same posture as sandbox_persist_failed / rule_save_failed. */
|
|
673
|
+
function logGrantPersistFailure(host, scope, err) {
|
|
674
|
+
logEvent({
|
|
675
|
+
source: "sandbox",
|
|
676
|
+
level: "warn",
|
|
677
|
+
event: "sandbox_persist_failed",
|
|
678
|
+
fields: {
|
|
679
|
+
grant: host,
|
|
680
|
+
scope,
|
|
681
|
+
error: err instanceof Error ? err.message : String(err),
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
}
|
|
632
685
|
/** Persist a domain grant to the user or project sandbox config. */
|
|
633
686
|
function persistDomainGrant(projectRoot, domain, stateHome) {
|
|
634
687
|
const target = projectRoot
|
|
@@ -645,25 +698,27 @@ function persistDomainGrant(projectRoot, domain, stateHome) {
|
|
|
645
698
|
config.sandbox = sandbox;
|
|
646
699
|
});
|
|
647
700
|
}
|
|
648
|
-
/**
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
701
|
+
/**
|
|
702
|
+
* Persist sandbox.enabled to the PROJECT-LOCAL config — the personal
|
|
703
|
+
* per-project tier, Claude Code's own destination for these writes
|
|
704
|
+
* (settings.local.json). Local writes fire-and-forget the global-gitignore
|
|
705
|
+
* helper so the file is never committed by accident.
|
|
706
|
+
*/
|
|
707
|
+
function persistEnabledFlip(enabled, cwd) {
|
|
708
|
+
mutateLocalConfig(cwd, (config) => {
|
|
652
709
|
const sandbox = isPlainRecord(config.sandbox) ? { ...config.sandbox } : {};
|
|
653
710
|
sandbox.enabled = enabled;
|
|
654
711
|
config.sandbox = sandbox;
|
|
655
|
-
});
|
|
712
|
+
}, "sandbox");
|
|
656
713
|
}
|
|
657
714
|
/**
|
|
658
715
|
* Persist a panel Mode choice (enabled + autoAllowBashIfSandboxed pair) to
|
|
659
|
-
* the
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
* confirmation message states honestly.
|
|
716
|
+
* the PROJECT-LOCAL config — Claude Code's own panel persists its mode
|
|
717
|
+
* choice to settings.local.json; ours now matches. Also fire-and-forgets
|
|
718
|
+
* the global-gitignore helper on first write.
|
|
663
719
|
*/
|
|
664
|
-
function persistSandboxMode(choice,
|
|
665
|
-
|
|
666
|
-
mutateConfigJson(target, (config) => {
|
|
720
|
+
function persistSandboxMode(choice, cwd) {
|
|
721
|
+
mutateLocalConfig(cwd, (config) => {
|
|
667
722
|
const sandbox = isPlainRecord(config.sandbox) ? { ...config.sandbox } : {};
|
|
668
723
|
if (choice === "auto-allow") {
|
|
669
724
|
sandbox.enabled = true;
|
|
@@ -678,17 +733,16 @@ function persistSandboxMode(choice, stateHome) {
|
|
|
678
733
|
sandbox.autoAllowBashIfSandboxed = false;
|
|
679
734
|
}
|
|
680
735
|
config.sandbox = sandbox;
|
|
681
|
-
});
|
|
736
|
+
}, "sandbox");
|
|
682
737
|
}
|
|
683
738
|
/** Persist a panel Overrides choice (allowUnsandboxedCommands) — same
|
|
684
739
|
* destination + honesty contract as persistSandboxMode. */
|
|
685
|
-
function persistSandboxOverride(choice,
|
|
686
|
-
|
|
687
|
-
mutateConfigJson(target, (config) => {
|
|
740
|
+
function persistSandboxOverride(choice, cwd) {
|
|
741
|
+
mutateLocalConfig(cwd, (config) => {
|
|
688
742
|
const sandbox = isPlainRecord(config.sandbox) ? { ...config.sandbox } : {};
|
|
689
743
|
sandbox.allowUnsandboxedCommands = choice === "open";
|
|
690
744
|
config.sandbox = sandbox;
|
|
691
|
-
});
|
|
745
|
+
}, "sandbox");
|
|
692
746
|
}
|
|
693
747
|
/**
|
|
694
748
|
* Wait for a spawned child's exit without hanging on inherited stdio —
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared settings-file plumbing for the three YAGNI Code settings tiers:
|
|
3
|
+
* user ~/.yagni-code/config.json (always trusted)
|
|
4
|
+
* project .yagni-code/config.json under the cwd (shared, committed)
|
|
5
|
+
* local .yagni-code/config.local.json under cwd (personal, gitignored)
|
|
6
|
+
*
|
|
7
|
+
* Owns the atomic config mutation (promoted from sandbox/session.ts, the
|
|
8
|
+
* single copy now) and the global-gitignore helper (Claude Code
|
|
9
|
+
* addFileGlobRuleToGitignore parity): on the first write to the local file,
|
|
10
|
+
* the LOCAL_GITIGNORE_ENTRY glob lands in the GLOBAL git ignore file so
|
|
11
|
+
* personal settings are never committed. Fail-soft by design — a gitignore
|
|
12
|
+
* failure never fails the settings write.
|
|
13
|
+
*/
|
|
14
|
+
export declare function mutateConfigJson(target: string, mutate: (config: Record<string, unknown>) => void): void;
|
|
15
|
+
/** The project-local settings path for a session cwd. */
|
|
16
|
+
export declare function localConfigPath(cwd: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Mutate the project-local settings file. Every write fire-and-forgets
|
|
19
|
+
* ensureLocalGitignored (Claude parity: the local tier is added to the
|
|
20
|
+
* global git ignore on write, not on session start). The helper is
|
|
21
|
+
* fire-and-forget BY DESIGN — the write's success notice is not gated on
|
|
22
|
+
* two git spawns; when the gitignore step is skipped or fails, the sink
|
|
23
|
+
* warn (gitignore_skipped / gitignore_update_failed) is the designed
|
|
24
|
+
* signal, surfaced via the diagnostics trail.
|
|
25
|
+
*
|
|
26
|
+
* `sinkSource` attributes those sink events to the caller's surface
|
|
27
|
+
* ("permission-rules" for rule saves, "sandbox" for panel/toggle writes) —
|
|
28
|
+
* the diagnostics trail then says which surface's write failed to get
|
|
29
|
+
* ignore-protected, not a blanket permission-rules line for every write.
|
|
30
|
+
*/
|
|
31
|
+
export declare function mutateLocalConfig(cwd: string, mutate: (config: Record<string, unknown>) => void, sinkSource?: string): void;
|
|
32
|
+
/**
|
|
33
|
+
* The global git ignore path git actually reads. Git consults
|
|
34
|
+
* $XDG_CONFIG_HOME/git/ignore (defaulting ~/.config/git/ignore) — honoring
|
|
35
|
+
* XDG is a deliberate divergence from Claude (which hardcodes ~/.config):
|
|
36
|
+
* writing a file git will not read when XDG is set would be a silent no-op.
|
|
37
|
+
*/
|
|
38
|
+
export declare function globalGitignorePath(env?: NodeJS.ProcessEnv): string;
|
|
39
|
+
/**
|
|
40
|
+
* Ensure the LOCAL_GITIGNORE_ENTRY glob (covering config.local.json at any
|
|
41
|
+
* depth) is ignored by the user's GLOBAL git config so local settings are
|
|
42
|
+
* never committed. Skip when:
|
|
43
|
+
* - cwd is not inside a git repo (nothing to protect);
|
|
44
|
+
* - `git check-ignore` already matches (local or global patterns cover it);
|
|
45
|
+
* - the global ignore file already carries the literal entry.
|
|
46
|
+
* Any failure logs one warn sink event and returns — the settings write it
|
|
47
|
+
* accompanies has already succeeded and must not be walked back.
|
|
48
|
+
*/
|
|
49
|
+
export declare function ensureLocalGitignored(cwd: string, env?: NodeJS.ProcessEnv, sinkSource?: string): Promise<void>;
|
|
50
|
+
//# sourceMappingURL=settingsFiles.d.ts.map
|