@yagni-app/code-staging 1.1.1-staging.1355.1 → 1.1.1-staging.1358.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.
@@ -51,6 +51,8 @@ export interface ApprovedPrefixFile {
51
51
  version: 1;
52
52
  grants: ApprovedPrefixGrant[];
53
53
  }
54
+ /** Tools whose second token is a subcommand worth capturing in a prefix. */
55
+ export declare const MULTI_SUBCOMMAND_TOOLS: Set<string>;
54
56
  /**
55
57
  * Prefixes that must never be grantable (Claude Code's BARE_SHELL_PREFIXES
56
58
  * line, plus the destruction family we keep fenced beyond it).
@@ -152,7 +154,7 @@ export declare function heredocPrefix(command: string): string | null;
152
154
  * Returns grants WITHOUT repoKey/addedAt/cwd (the gate fills them in) —
153
155
  * seeds are hypothetical until the user picks remember.
154
156
  */
155
- export declare function deriveRememberSeeds(command: string, uncoveredCanonicalSegments: readonly string[], repoKey?: string): {
157
+ export declare function deriveRememberSeeds(command: string, uncoveredSegments: readonly string[], repoKey?: string): {
156
158
  seeds: ApprovedPrefixGrant[];
157
159
  description: string;
158
160
  } | null;
@@ -29,11 +29,11 @@ import { execFileSync } from "node:child_process";
29
29
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
30
30
  import { dirname, join } from "node:path";
31
31
  import { classifyCommand, isSafeRedirect, shellParse, tokenize } from "./execPolicy.js";
32
- import { SAFE_ENV_VARS, splitSubcommands } from "../permissionRules/shellRules.js";
32
+ import { SAFE_ENV_VARS } from "../permissionRules/shellRules.js";
33
33
  import { codeStateHome } from "../stateHome.js";
34
34
  // --- Derivation ---
35
35
  /** Tools whose second token is a subcommand worth capturing in a prefix. */
36
- const MULTI_SUBCOMMAND_TOOLS = new Set([
36
+ export const MULTI_SUBCOMMAND_TOOLS = new Set([
37
37
  "git", "gh", "npm", "pnpm", "yarn", "docker", "kubectl", "fly", "cargo", "go",
38
38
  ]);
39
39
  /**
@@ -211,13 +211,35 @@ export function storagePrefix(command) {
211
211
  function hasGitPushRefspecDanger(tokens) {
212
212
  return tokens.slice(2).some((t) => t.startsWith("+") || (!t.startsWith("-") && t.includes(":")));
213
213
  }
214
+ /** The push shapes a grant pattern covers: a two-token [git, push] grant
215
+ * covers only `git push …`; a single-token [git] grant (the flag-first
216
+ * fallback — `git -C repo push …`, or a user-typed bare `git` in the custom
217
+ * field) covers EVERY subcommand, push included. The push fence must fire
218
+ * for both shapes or the fencing invariant is defeated by the shorter
219
+ * pattern. Deliberate deviation from Claude Code: their Bash(git:*) also
220
+ * matches `git push --force`, but their allow rules never grant UNSANDBOXED
221
+ * execution (permission and sandbox-wrap are orthogonal — an allowed
222
+ * command still runs sandboxed). Our escape-flow grants are standing
223
+ * unsandboxed-run authorizations, so the fence is load-bearing for us. */
224
+ function grantCoversPush(pattern, tokens) {
225
+ if (tokens[0] !== "git")
226
+ return false;
227
+ if (pattern.length >= 2)
228
+ return pattern[1] === "push";
229
+ // single-token [git]: covers a push when the COMMAND is one — any bare
230
+ // `push` word in token position (not a flag value), which also catches
231
+ // flag-first shapes (`git -C /x push --force-with-lease …`). Over-fencing
232
+ // in the safe direction: a benign `git push -c push=…`-style flag value
233
+ // reads as covered-but-fenced only for that command shape, not a grant.
234
+ return tokens.some((t) => t === "push");
235
+ }
214
236
  /**
215
237
  * Flags that must never ride a grant even though the exec policy leaves them
216
238
  * in the prompt band (e.g. --force-with-lease is Guardian-reviewable but a
217
239
  * standing grant for it would be a silent force-push license).
218
240
  */
219
241
  function hasGrantFencedFlag(pattern, tokens) {
220
- if (pattern[0] === "git" && pattern[1] === "push") {
242
+ if (grantCoversPush(pattern, tokens)) {
221
243
  return tokens.some((t) => t.startsWith("--force") || t === "-f");
222
244
  }
223
245
  return false;
@@ -341,7 +363,7 @@ export function matchesGrant(command, grants, repoKey) {
341
363
  continue;
342
364
  if (hasGrantFencedFlag(grant.pattern, tokens))
343
365
  continue;
344
- if (grant.pattern[0] === "git" && grant.pattern[1] === "push" && hasGitPushRefspecDanger(tokens))
366
+ if (grantCoversPush(grant.pattern, tokens) && hasGitPushRefspecDanger(tokens))
345
367
  continue;
346
368
  return grant;
347
369
  }
@@ -412,6 +434,17 @@ export function heredocPrefix(command) {
412
434
  }
413
435
  return prefix.length > 0 ? prefix : null;
414
436
  }
437
+ /**
438
+ * Self-match proof for a literal seed: does the seed, as a grant, cover the
439
+ * very command it was derived from? The single gate every literal rung
440
+ * shares — a seed that fails it would hand the user a "don't ask again"
441
+ * option that never works (the dead-rule bug: the multiline -Atc "\"SQL class
442
+ * matched nothing, ever, and re-asked every invocation). Pure.
443
+ */
444
+ function literalSeedSelfMatches(command, literal) {
445
+ const grant = { pattern: [], literal, repoKey: "", addedAt: "", cwd: "" };
446
+ return matchesGrant(command, [grant], "") !== null;
447
+ }
415
448
  /**
416
449
  * The FULL remember ladder for one ask, in order (all five rungs — a shape
417
450
  * never asks twice):
@@ -424,18 +457,27 @@ export function heredocPrefix(command) {
424
457
  * Returns grants WITHOUT repoKey/addedAt/cwd (the gate fills them in) —
425
458
  * seeds are hypothetical until the user picks remember.
426
459
  */
427
- export function deriveRememberSeeds(command, uncoveredCanonicalSegments, repoKey) {
428
- const derivable = uncoveredCanonicalSegments
460
+ export function deriveRememberSeeds(command, uncoveredSegments, repoKey) {
461
+ // Rung 1 derives and self-matches on the CANONICAL form of each raw
462
+ // segment (the anti-dead-rule invariant — the same normalization
463
+ // matchesGrant applies at match time). The raw segments arrive from
464
+ // evaluateCompoundForEscape's splitSubcommandsQuoted, which preserves
465
+ // quoting so a quoted arg body (a `;` inside `-c "…SQL…"`) reads as ONE
466
+ // argument; canonicalization then strips safe decorations the same way
467
+ // matching does, keeping the token rung reachable for exactly the plain
468
+ // single commands it exists for (the psql case).
469
+ const canonicalSegments = uncoveredSegments.map((seg) => canonicalizeForGrants(seg));
470
+ const derivable = canonicalSegments
429
471
  .map((seg) => derivePrefix(seg))
430
472
  .filter((p) => p !== null);
431
473
  // Rung 1: every uncovered segment token-derivable AND every seed
432
474
  // self-matches its own segment (the fencing proof: a `git push +x:y` shape
433
475
  // derives [git, push] but the fenced command never matches it, so the
434
476
  // remember option is never offered for a shape the grant can't cover).
435
- if (derivable.length === uncoveredCanonicalSegments.length && derivable.length > 0) {
477
+ if (derivable.length === canonicalSegments.length && derivable.length > 0) {
436
478
  const key = repoKey ?? "";
437
479
  const seeds = derivable.map((pattern) => ({ pattern, repoKey: key, addedAt: "", cwd: "" }));
438
- const allSelfMatch = uncoveredCanonicalSegments.every((seg, i) => matchesGrant(seg, [seeds[i]], key) !== null);
480
+ const allSelfMatch = canonicalSegments.every((seg, i) => matchesGrant(seg, [seeds[i]], key) !== null);
439
481
  if (allSelfMatch) {
440
482
  return {
441
483
  seeds: derivable.map((pattern) => ({ pattern, repoKey: "", addedAt: "", cwd: "" })),
@@ -445,9 +487,13 @@ export function deriveRememberSeeds(command, uncoveredCanonicalSegments, repoKey
445
487
  // A fenced segment kills the token rung — fall through to the literal
446
488
  // rungs below (a narrower prefix may still be rememberable).
447
489
  }
448
- // Rung 2: heredoc prefix.
490
+ // Rung 2: heredoc prefix — self-match checked (a literal seed is offered
491
+ // ONLY when it provably matches the very command being asked about; the
492
+ // heredoc rung's remainder is its heredoc tail, which the inert-remainder
493
+ // check admits, so this proof is structural — but it runs anyway: a
494
+ // shape change upstream must never resurrect a dead seed).
449
495
  const hp = heredocPrefix(command);
450
- if (hp) {
496
+ if (hp && literalSeedSelfMatches(command, hp)) {
451
497
  return {
452
498
  seeds: [{ pattern: [], literal: hp, repoKey: "", addedAt: "", cwd: "" }],
453
499
  description: `${truncateSeedLabel(hp)} …`,
@@ -455,10 +501,15 @@ export function deriveRememberSeeds(command, uncoveredCanonicalSegments, repoKey
455
501
  }
456
502
  // Rung 3: first line of a multiline command — but NEVER an assignment-only
457
503
  // first line (`X=…\nreal cmd`): that literal is a dead rule (it matches the
458
- // assignment, grants nothing about the real segment).
504
+ // assignment, grants nothing about the real segment). Self-match checked
505
+ // too: a first-line prefix whose remainder is NOT inert (the multiline
506
+ // `-Atc "` SQL body class) matches nothing, ever — offering it would hand
507
+ // the user a remember option that never works (the dead-rule bug).
459
508
  if (command.includes("\n")) {
460
509
  const firstLine = command.split("\n")[0].trim();
461
- if (firstLine.length > 0 && !/^([A-Za-z_][A-Za-z0-9_]*=\S*)$/.test(firstLine)) {
510
+ if (firstLine.length > 0 &&
511
+ !/^([A-Za-z_][A-Za-z0-9_]*=\S*)$/.test(firstLine) &&
512
+ literalSeedSelfMatches(command, firstLine)) {
462
513
  return {
463
514
  seeds: [{ pattern: [], literal: firstLine, repoKey: "", addedAt: "", cwd: "" }],
464
515
  description: `${truncateSeedLabel(firstLine)} …`,
@@ -517,30 +568,89 @@ export function validateGrantForEscape(command, policy, repoKey) {
517
568
  // seed every uncovered one — the compound remember unit. A compound whose
518
569
  // uncovered segments are all token-derivable AND self-matching gets the
519
570
  // multi-grant; otherwise the literal rungs apply to the FULL command.
571
+ // (uncovered now carries the RAW quote-preserving segments — see
572
+ // evaluateCompoundForEscape — so the token rung sees quoted arg bodies
573
+ // as ONE plain command, not a dequoted compound.)
520
574
  const compound = evaluateCompoundForEscape(command, [], repoKey, policy);
521
575
  if (!compound.forbidden && compound.uncovered.length > 0) {
522
576
  const res = deriveRememberSeeds(command, compound.uncovered, repoKey);
523
577
  if (res && res.seeds.length > 0)
524
578
  return res;
525
579
  }
526
- const canonical = canonicalizeForGrants(command);
527
- const single = isSinglePlainCommand(canonical);
580
+ // Single-command token rung — derived and self-matched against the RAW
581
+ // command (the quote-preserving form; the canonical form drops quoting
582
+ // and makes single commands look compound — the dead-literal bug).
583
+ const single = isSinglePlainCommand(command);
528
584
  if (single) {
529
- const pattern = derivePrefix(canonical);
585
+ const pattern = derivePrefix(command);
530
586
  if (pattern) {
531
587
  const candidate = { pattern, repoKey, addedAt: new Date().toISOString(), cwd: "" };
532
- if (matchesGrant(canonical, [candidate], repoKey)) {
588
+ if (matchesGrant(command, [candidate], repoKey)) {
533
589
  return { seeds: [candidate], description: describePrefix(pattern) };
534
590
  }
535
591
  }
536
592
  }
537
- // literal rungs (heredoc/first-line/full) — validated by construction:
538
- // the seed is a prefix of this very command, so it self-matches.
539
- return deriveRememberSeeds(command, [canonical]);
593
+ // literal rungs (heredoc/first-line/full) — each rung self-match-proved
594
+ // by deriveRememberSeeds; a rung that cannot cover THIS command is not
595
+ // offered (null = no remember option at all).
596
+ return deriveRememberSeeds(command, [command]);
597
+ }
598
+ /**
599
+ * Quote-preserving segment splitter for the ESCAPE flow (a local, faithful
600
+ * counterpart to shellRules' splitSubcommands — which rejoins tokens with
601
+ * plain spaces and LOSES the quoting: a `;` inside `-c "…SQL…"` comes back
602
+ * out as a bare unquoted `;`, and every downstream pass then reads a
603
+ * single command as a compound — the dead-literal bug). Here, string tokens
604
+ * carrying shell metacharacters are re-quoted on the join so the segment
605
+ * string parses back to the SAME tokens; redirect-out operators keep their
606
+ * fd and target (2>&1, >>file) and background renders as `&`. Known lossy
607
+ * renderings, none grant-relevant: an input redirect renders as a bare
608
+ * `<` (target dropped — a construct, never a command word; the segment
609
+ * falls to the literal rungs, fail-closed), and tokens containing a single
610
+ * quote use the bash `'​''` idiom our own shellParse does not
611
+ * implement on re-parse — the command WORD never carries one, so the
612
+ * derived prefix is unaffected (fail-closed at the rung if it ever did).
613
+ */
614
+ function splitSubcommandsQuoted(command) {
615
+ const tokens = shellParse(command);
616
+ const segments = [];
617
+ let current = [];
618
+ const flush = () => {
619
+ if (current.length > 0) {
620
+ segments.push(current.join(" "));
621
+ current = [];
622
+ }
623
+ };
624
+ const renderString = (t) => /[\s;|&<>'"\\$`]/.test(t) ? `'${t.replace(/'/g, `'\\''`)}'` : t;
625
+ const renderToken = (t) => {
626
+ if (typeof t === "string")
627
+ return renderString(t);
628
+ if (t.op === "redirect") {
629
+ if (t.direction === "out") {
630
+ const fd = t.fd === "stderr" ? "2" : "1";
631
+ return `${fd}${t.append ? ">>" : ">"}${renderString(t.target)}`;
632
+ }
633
+ return "<";
634
+ }
635
+ if (t.op === "background")
636
+ return "&";
637
+ return ""; // pipe/and/or/semi/substitution are boundaries — never rendered
638
+ };
639
+ for (const t of tokens) {
640
+ if (typeof t === "object" && "op" in t && (t.op === "pipe" || t.op === "and" || t.op === "or" || t.op === "semi" || t.op === "substitution")) {
641
+ flush();
642
+ continue;
643
+ }
644
+ const rendered = renderToken(t);
645
+ if (rendered !== "")
646
+ current.push(rendered);
647
+ }
648
+ flush();
649
+ return segments;
540
650
  }
541
651
  export function evaluateCompoundForEscape(command, grants, repoKey, policy) {
542
652
  const isHeredoc = /<<[-~]?\s*(["']?)(\w+)\1/.test(command);
543
- const rawSegments = isHeredoc ? [command] : splitSubcommands(command);
653
+ const rawSegments = isHeredoc ? [command] : splitSubcommandsQuoted(command);
544
654
  const segments = [];
545
655
  const uncovered = [];
546
656
  let forbidden = false;
@@ -579,7 +689,16 @@ export function evaluateCompoundForEscape(command, grants, repoKey, policy) {
579
689
  continue;
580
690
  }
581
691
  segments.push({ segment: canonical, kind: "uncovered" });
582
- uncovered.push(canonical);
692
+ // The seed ladder consumes the RAW trimmed segment, not the canonical
693
+ // form: canonicalizeForGrants runs the quote-aware tokenizer, and its
694
+ // re-join DROPS the quoting (a `;` inside `-c "…SQL…"` comes back out as
695
+ // a bare unquoted `;`) — derivePrefix's isSinglePlainCommand then sees a
696
+ // compound and kills the token rung, so flag-first single commands
697
+ // (psql -h … -Atc "…") fall to the dead first-line literal rung
698
+ // (`… -Atc "` — a rule that matches nothing, ever). The RAW segment
699
+ // preserves the quoting the tokenizer needs to see the command as ONE
700
+ // plain command and derive ["psql"].
701
+ uncovered.push(trimmed);
583
702
  }
584
703
  return { forbidden, uncovered, segments };
585
704
  }
@@ -26,7 +26,7 @@
26
26
  * When the mode leaves plan, stale plan-context messages are filtered out of
27
27
  * the context so the model doesn't keep believing it is restricted.
28
28
  */
29
- import { describePrefix, evaluateCompoundForEscape, matchesGrant, storagePrefix, validateGrant, validateGrantForEscape, } from "./approvedPrefixes.js";
29
+ import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, storagePrefix, validateGrant, validateGrantForEscape, } from "./approvedPrefixes.js";
30
30
  import { logEvent } from "../errorSink.js";
31
31
  import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
32
32
  import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
@@ -505,6 +505,28 @@ export function registerPermissionGate(pi, deps = {}) {
505
505
  // escape_ask_headless_blocked, escape_aborted, escape_ask_failed — policy,
506
506
  // infra, and abort outcomes never read as a user action in the trail.
507
507
  };
508
+ /** Persist a grant, fail-soft WITH a trail: the in-memory grant still
509
+ * applies for this session, but a silent persist failure would leave the
510
+ * user believing "don't ask again" survived the restart while the same
511
+ * dialog re-appears next session with zero trace — one warn sink line
512
+ * (error class only, never the thrown message: appendGrant failures can
513
+ * carry file paths, and the sink's content discipline is class-level)
514
+ * makes it observable. */
515
+ const persistGrantFailSoft = (grant) => {
516
+ try {
517
+ deps.persistGrant?.(grant);
518
+ }
519
+ catch (err) {
520
+ logEvent({
521
+ source: "permission-rules",
522
+ level: "warn",
523
+ event: "grant_persist_failed",
524
+ fields: {
525
+ error: err instanceof Error ? err.constructor.name : typeof err,
526
+ },
527
+ });
528
+ }
529
+ };
508
530
  const emitGateEvent = (slot, event) => {
509
531
  const mapped = GUARDIAN_OUTCOME_SOURCE[event.outcome];
510
532
  if (mapped)
@@ -1152,12 +1174,7 @@ export function registerPermissionGate(pi, deps = {}) {
1152
1174
  addedAt: new Date().toISOString(),
1153
1175
  };
1154
1176
  grants.push(grantRecord);
1155
- try {
1156
- deps.persistGrant?.(grantRecord);
1157
- }
1158
- catch {
1159
- // Fail-soft: the in-memory grant still applies this session.
1160
- }
1177
+ persistGrantFailSoft(grantRecord);
1161
1178
  emitGateEvent(slot, {
1162
1179
  ...eventBase,
1163
1180
  outcome: "ask_approved_remembered",
@@ -1536,15 +1553,41 @@ export function registerPermissionGate(pi, deps = {}) {
1536
1553
  }
1537
1554
  if (!inputThrew && custom !== undefined && custom.trim().length > 0) {
1538
1555
  const trimmedCustom = custom.trim();
1539
- // The custom literal self-matches by construction (startsWith), but
1540
- // refuse banned/fenced shapes the same way every rung does.
1541
- const customGrant = { pattern: [], literal: trimmedCustom, repoKey, addedAt: new Date().toISOString(), cwd };
1542
- if (matchesGrant(command, [customGrant], repoKey)) {
1556
+ // The custom field accepts two shapes:
1557
+ // (a) a clean command word (`npx`, `psql`) the first-word rung of
1558
+ // the ladder validated by derivePrefix's shape check and
1559
+ // self-matched as a TOKEN grant (covers every later `npx …` /
1560
+ // `psql …` regardless of arguments; Claude's `psql:*` semantics).
1561
+ // A bare word can never pass the literal-remainder check (the
1562
+ // words after it are "not inert"), which is why the field used
1563
+ // to reject exactly this input with a dead-rule warning. The
1564
+ // git-push force/refspec fence is pattern-length-aware (a
1565
+ // bare `git` grant covers pushes too) — see matchesGrant.
1566
+ // (b) anything longer — a literal string prefix, validated as
1567
+ // before (must cover THIS command, banned/fenced shapes
1568
+ // refused the same way every rung does).
1569
+ const tokenPattern = trimmedCustom.includes(" ") || trimmedCustom.includes("\n")
1570
+ ? null
1571
+ : derivePrefix(trimmedCustom);
1572
+ const customGrant = tokenPattern
1573
+ ? { pattern: tokenPattern, repoKey, addedAt: new Date().toISOString(), cwd }
1574
+ : { pattern: [], literal: trimmedCustom, repoKey, addedAt: new Date().toISOString(), cwd };
1575
+ // Validation must be compound-aware: the escaped command is often a
1576
+ // compound (`S=…; curl … && echo done`), and matchesGrant alone can
1577
+ // never match a single token grant against a compound. A bare-word
1578
+ // token grant is valid when EVERY segment of the compound is covered
1579
+ // — by the token grant or an existing grant — exactly the ladder's
1580
+ // rung-1 self-match, applied to the user's custom word.
1581
+ const customValid = tokenPattern
1582
+ ? (() => {
1583
+ const evalPolicy = deps.policy?.execPolicy ?? DEFAULT_EXEC_POLICY;
1584
+ const ev = evaluateCompoundForEscape(command, [...grants, customGrant], repoKey, evalPolicy);
1585
+ return !ev.forbidden && ev.uncovered.length === 0;
1586
+ })()
1587
+ : matchesGrant(command, [customGrant], repoKey);
1588
+ if (customValid) {
1543
1589
  grants.push(customGrant);
1544
- try {
1545
- deps.persistGrant?.(customGrant);
1546
- }
1547
- catch { /* fail-soft */ }
1590
+ persistGrantFailSoft(customGrant);
1548
1591
  rememberApproved(cwd, command);
1549
1592
  emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false });
1550
1593
  return {};
@@ -1617,10 +1660,7 @@ export function registerPermissionGate(pi, deps = {}) {
1617
1660
  cwd,
1618
1661
  };
1619
1662
  grants.push(grantRecord);
1620
- try {
1621
- deps.persistGrant?.(grantRecord);
1622
- }
1623
- catch { /* fail-soft: in-memory grant applies this session */ }
1663
+ persistGrantFailSoft(grantRecord);
1624
1664
  }
1625
1665
  rememberApproved(cwd, command);
1626
1666
  emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false });
@@ -299,7 +299,7 @@ export function networkDenialHint(output) {
299
299
  return {
300
300
  cls: "ipc-listen",
301
301
  hint: "This looks like a sandbox network-posture denial (a socket bind the sandbox profile denies — test-runner IPC is the usual case). " +
302
- "The user can fix it in the /sandbox panel (Network tab: the Engineering preset or the unix-sockets toggle); retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
302
+ "The unix-sockets knob is ON by default — this denial means it was turned off; the user can re-enable it in the /sandbox panel (Network tab). Retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
303
303
  };
304
304
  }
305
305
  }
@@ -307,7 +307,7 @@ export function networkDenialHint(output) {
307
307
  return {
308
308
  cls: "tls-trustd",
309
309
  hint: "This looks like a sandbox TLS-verification denial (the trustd.agent mach lookup is denied — Go CLIs like gh verify certs through it even on allowlisted domains). " +
310
- "The user can fix it in the /sandbox panel (Network tab: the Engineering preset or the trustd toggle); retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
310
+ "The trustd knob is ON by default — this denial means it was turned off; the user can re-enable it in the /sandbox panel (Network tab). Retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
311
311
  };
312
312
  }
313
313
  // Loopback connect denials: EPERM/not-permitted text with a loopback
@@ -319,8 +319,8 @@ export function networkDenialHint(output) {
319
319
  /(connect|curl|psql|wget|fetch|Failed to connect|Couldn't connect)/i.test(output)) {
320
320
  return {
321
321
  cls: "loopback",
322
- hint: "This looks like a sandbox loopback denial (loopback bypasses the network allowlist, so localhost services are blocked by default). " +
323
- "The user can fix it in the /sandbox panel (Network tab: the Engineering preset or the local-binding toggle); retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
322
+ hint: "This looks like a sandbox loopback denial (loopback bypasses the network allowlist, so localhost services need the local-binding knob). " +
323
+ "The local-binding knob is ON by default — this denial means it was turned off; the user can re-enable it in the /sandbox panel (Network tab). Retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
324
324
  };
325
325
  }
326
326
  return null;
@@ -18,6 +18,16 @@
18
18
  * allow-only (default-deny, /tmp included). Network: proxy-enforced
19
19
  * allowlist; loopback needs allowLocalBinding (allowedDomains cannot open it
20
20
  * — loopback bypasses the proxy via no_proxy).
21
+ *
22
+ * Engineering default (deliberate deviation from Claude Code's opt-in
23
+ * posture): the three network-posture knobs resolve ON when unset —
24
+ * allowLocalBinding (loopback bind+connect), the temp-dir unix-socket
25
+ * posture (macOS: ["$TMPDIR"] path-scoped; Linux: allowAllUnixSockets —
26
+ * path-scoped entries cannot exist there), and enableWeakerNetworkIsolation
27
+ * (macOS trustd for Go TLS). An induced knob-off denial makes the model
28
+ * retry with dangerouslyDisableSandbox, and the escaped command runs with
29
+ * full user authority — strictly worse than the sandboxed posture the knob
30
+ * would have allowed. Explicit opt-out (false / []) at any tier still wins.
21
31
  */
22
32
  import { type WorktreeGitAccess } from "./worktreeGit.js";
23
33
  import type { PermissionRule } from "../permissionRules/loadConfig.js";
@@ -68,8 +78,9 @@ export declare function readSandboxSettingsFromFile(configPath: string, warnings
68
78
  /**
69
79
  * Load + merge sandbox settings from all three config files. Scalars: local
70
80
  * beats project beats user; arrays: union. Defaults for scalars land here
71
- * too (Claude parity): autoAllowBashIfSandboxed true, allowUnsandboxedCommands
72
- * true, everything else unset/false.
81
+ * too: autoAllowBashIfSandboxed true, allowUnsandboxedCommands true, and the
82
+ * three network-posture knobs ON when unset (the Engineering default — see
83
+ * the file header; an explicit false/[] at any tier still wins).
73
84
  */
74
85
  export declare function loadSandboxSettings(opts?: {
75
86
  cwd?: string;
@@ -18,6 +18,16 @@
18
18
  * allow-only (default-deny, /tmp included). Network: proxy-enforced
19
19
  * allowlist; loopback needs allowLocalBinding (allowedDomains cannot open it
20
20
  * — loopback bypasses the proxy via no_proxy).
21
+ *
22
+ * Engineering default (deliberate deviation from Claude Code's opt-in
23
+ * posture): the three network-posture knobs resolve ON when unset —
24
+ * allowLocalBinding (loopback bind+connect), the temp-dir unix-socket
25
+ * posture (macOS: ["$TMPDIR"] path-scoped; Linux: allowAllUnixSockets —
26
+ * path-scoped entries cannot exist there), and enableWeakerNetworkIsolation
27
+ * (macOS trustd for Go TLS). An induced knob-off denial makes the model
28
+ * retry with dangerouslyDisableSandbox, and the escaped command runs with
29
+ * full user authority — strictly worse than the sandboxed posture the knob
30
+ * would have allowed. Explicit opt-out (false / []) at any tier still wins.
21
31
  */
22
32
  import { existsSync, readFileSync } from "node:fs";
23
33
  import { tmpdir } from "node:os";
@@ -219,16 +229,47 @@ function union(...lists) {
219
229
  return undefined;
220
230
  return [...new Set(present.flat())];
221
231
  }
232
+ /** The Engineering network-posture defaults — the single flip point for
233
+ * the default-on decision. If the escape-rate rationale proves wrong in
234
+ * the field, reverting the posture is editing THIS constant (and the
235
+ * enableWeakerNetworkIsolation fallback below), not the merge expressions.
236
+ * Platform-shaped: macOS path-scopes socket binds to the temp dirs;
237
+ * Linux cannot path-scope (seccomp), so its door is the broader allow-all. */
238
+ function ENGINEERING_NETWORK_DEFAULTS(isDarwin) {
239
+ return {
240
+ allowUnixSockets: isDarwin ? ["$TMPDIR"] : [],
241
+ allowAllUnixSockets: !isDarwin,
242
+ allowLocalBinding: true,
243
+ };
244
+ }
222
245
  function mergeNetwork(user, project, local) {
223
246
  const u = user ?? {};
224
247
  const p = project ?? {};
225
248
  const l = local ?? {};
249
+ // Engineering default (deliberate deviation from Claude Code's opt-in
250
+ // posture): when NO tier sets a posture knob, it resolves ON. Rationale —
251
+ // a knob-off default does not buy safety: the induced OS denial makes the
252
+ // model retry with dangerouslyDisableSandbox, and the escaped command runs
253
+ // with FULL user authority (no network allowlist, no filesystem fences) —
254
+ // strictly worse than a sandboxed process that can bind an IPC pipe under
255
+ // $TMPDIR or connect to loopback. An explicit `false` / `[]` from any tier
256
+ // still wins: opt-out is first-class.
257
+ //
258
+ // Platform shape of the DEFAULT socket posture (mirrors the Engineering
259
+ // preset): macOS path-scopes binds to the temp dirs via `allowUnixSockets:
260
+ // ["$TMPDIR"]` with allow-all OFF (srt's allow-all would emit a broader
261
+ // `path-regex ^/` rule that overrides the per-path list); Linux has no
262
+ // path-scoped unix sockets (seccomp cannot path-filter), so its default
263
+ // is the broader `allowAllUnixSockets` — the known, documented cost of the
264
+ // Linux default (it admits /var/run/docker.sock).
265
+ const isDarwin = process.platform === "darwin";
266
+ const defaults = ENGINEERING_NETWORK_DEFAULTS(isDarwin);
226
267
  return {
227
268
  allowedDomains: union(u.allowedDomains, p.allowedDomains, l.allowedDomains) ?? [],
228
269
  deniedDomains: union(u.deniedDomains, p.deniedDomains, l.deniedDomains) ?? [],
229
- allowUnixSockets: union(u.allowUnixSockets, p.allowUnixSockets, l.allowUnixSockets),
230
- allowAllUnixSockets: l.allowAllUnixSockets ?? p.allowAllUnixSockets ?? u.allowAllUnixSockets,
231
- allowLocalBinding: l.allowLocalBinding ?? p.allowLocalBinding ?? u.allowLocalBinding,
270
+ allowUnixSockets: union(u.allowUnixSockets, p.allowUnixSockets, l.allowUnixSockets) ?? defaults.allowUnixSockets,
271
+ allowAllUnixSockets: l.allowAllUnixSockets ?? p.allowAllUnixSockets ?? u.allowAllUnixSockets ?? defaults.allowAllUnixSockets,
272
+ allowLocalBinding: l.allowLocalBinding ?? p.allowLocalBinding ?? u.allowLocalBinding ?? defaults.allowLocalBinding,
232
273
  httpProxyPort: l.httpProxyPort ?? p.httpProxyPort ?? u.httpProxyPort,
233
274
  socksProxyPort: l.socksProxyPort ?? p.socksProxyPort ?? u.socksProxyPort,
234
275
  };
@@ -247,8 +288,9 @@ function mergeFilesystem(user, project, local) {
247
288
  /**
248
289
  * Load + merge sandbox settings from all three config files. Scalars: local
249
290
  * beats project beats user; arrays: union. Defaults for scalars land here
250
- * too (Claude parity): autoAllowBashIfSandboxed true, allowUnsandboxedCommands
251
- * true, everything else unset/false.
291
+ * too: autoAllowBashIfSandboxed true, allowUnsandboxedCommands true, and the
292
+ * three network-posture knobs ON when unset (the Engineering default — see
293
+ * the file header; an explicit false/[] at any tier still wins).
252
294
  */
253
295
  export function loadSandboxSettings(opts = {}) {
254
296
  const warnings = [];
@@ -290,7 +332,8 @@ export function loadSandboxSettings(opts = {}) {
290
332
  userSettings?.enableWeakerNestedSandbox,
291
333
  enableWeakerNetworkIsolation: localSettings?.enableWeakerNetworkIsolation ??
292
334
  projectSettings?.enableWeakerNetworkIsolation ??
293
- userSettings?.enableWeakerNetworkIsolation,
335
+ userSettings?.enableWeakerNetworkIsolation ??
336
+ true,
294
337
  };
295
338
  return { settings, diagnostics: { warnings, unknownKeys } };
296
339
  }
@@ -73,8 +73,11 @@ export declare function overrideOptions(current: SandboxOverrideChoice): SelectI
73
73
  /** Per-mode explanation (Claude's copy, adapted to our surfaces). */
74
74
  export declare function modeExplanation(mode: SandboxModeChoice): string;
75
75
  export declare function overrideExplanation(choice: SandboxOverrideChoice): string;
76
- /** The three knobs' resolved state, derived from settings (undefined =
77
- * unset, which resolves OFF at the runtime). */
76
+ /** The three knobs' resolved state, derived from the settings this layer
77
+ * is given. NOTE: the panel is fed the MERGED settings (loadSandboxSettings
78
+ * output — the Engineering default resolves the three knobs ON when unset);
79
+ * a raw `{}` here would read all-off, which never happens in production
80
+ * wiring (session.ts builds the panel state from the merged load). */
78
81
  export interface NetworkKnobState {
79
82
  trustd: boolean;
80
83
  /** macOS: the $TMPDIR entry is present in allowUnixSockets; Linux:
@@ -101,8 +101,8 @@ export function networkOptions(knobs, platform) {
101
101
  const items = [
102
102
  {
103
103
  value: "engineering-preset",
104
- label: `Engineering preset: tests + gh + localhost in one click${presetApplied(knobs, platform) ? " (applied)" : ""}`,
105
- description: "Unix sockets in the temp dir (test-runner IPC), macOS TLS chain verification, and loopback connections — the routine engineering-work knobs",
104
+ label: `Engineering defaults: tests + gh + localhost (on by default)${presetApplied(knobs, platform) ? " (applied)" : ""}`,
105
+ description: "Unix sockets in the temp dir (test-runner IPC), macOS TLS chain verification, and loopback connections — ON by default for every session; applying writes the explicit form to this project's local settings",
106
106
  },
107
107
  ];
108
108
  if (isMac) {
@@ -148,12 +148,12 @@ export function networkExplanation(item, platform) {
148
148
  : "Applies: all unix sockets (no path filtering on Linux — docker.sock included) + localhost connections. Held work (test runs, local DB clients) runs sandboxed instead of escaping.";
149
149
  case "trustd":
150
150
  case "trustd-off":
151
- return "Needed for Go TLS verification (gh, gcloud, terraform). Enabling opens a potential data exfiltration vector through the trustd service — off by default.";
151
+ return "Needed for Go TLS verification (gh, gcloud, terraform). Enabling opens a potential data exfiltration vector through the trustd service — on by default; turn it off here if you don't use Go CLIs.";
152
152
  case "unix-sockets":
153
153
  case "unix-sockets-off":
154
154
  return platform === "darwin"
155
155
  ? "Allows binding unix sockets under $TMPDIR only — every tsx/vitest/node test-runner IPC socket lives there. Writes to $TMPDIR are already sandbox-allowed, so this stays low-risk."
156
- : "All-or-nothing on Linux: seccomp cannot filter socket paths, so allowing test-runner IPC also allows docker.sock. Off by default; excludedCommands can keep specific commands out of the sandbox instead.";
156
+ : "All-or-nothing on Linux: seccomp cannot filter socket paths, so allowing test-runner IPC also allows docker.sock. On by default (the Engineering default); excludedCommands can keep specific commands out of the sandbox instead.";
157
157
  case "local-binding":
158
158
  case "local-binding-off":
159
159
  return "psql/redis to localhost, dev servers on :4567, and any 127.0.0.1 client. Loopback bypasses the per-domain allowlist — this is all-or-nothing for localhost.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.1-staging.1355.1",
3
+ "version": "1.1.1-staging.1358.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "e319781c1b81806a857f6a846d15b7e9ace2447e"
61
+ "yagniSourceSha": "d3f818e827670951d1a080060578f482cbdd9fbc"
62
62
  }