@yagni-app/code-staging 1.1.0-staging.1325.1 → 1.1.0-staging.1327.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.
@@ -69,6 +69,29 @@ export declare function preWrappedCommand(manager: YagniSandboxManager, command:
69
69
  * offending path when found (pi-sandbox's regex, credited MIT).
70
70
  */
71
71
  export declare function extractBlockedWritePath(output: string): string | null;
72
+ /** The three network-posture denial classes this classifier knows (closed
73
+ * set — rides the sink line as a low-cardinality field). */
74
+ export type NetworkDenialClass = "ipc-listen" | "tls-trustd" | "loopback";
75
+ /**
76
+ * Classify a sandbox network-posture denial from command output. Pure;
77
+ * advisory-only (never widens anything — the fix is a knob the USER flips,
78
+ * never an automatic grant). Matches the signatures observed live and in
79
+ * the session review:
80
+ * - ipc-listen: `listen EPERM` (node:events EPERM with syscall listen —
81
+ * a named unix-socket or TCP bind the profile denies;
82
+ * tsx/vitest test-runner IPC is the canonical case)
83
+ * - tls-trustd: `x509` + `OSStatus -26276` (Go TLS verification denied
84
+ * the trustd.agent mach lookup — gh/gcloud/terraform)
85
+ * - loopback: EPERM/"not permitted" on a connect to 127.0.0.1/
86
+ * localhost/::1 (loopback bypasses the proxy via
87
+ * no_proxy, so the allowlist can never admit it)
88
+ * Returns null when the output carries no network-posture signature — a
89
+ * plain file-write EPERM must NOT get a network hint.
90
+ */
91
+ export declare function networkDenialHint(output: string): {
92
+ cls: NetworkDenialClass;
93
+ hint: string;
94
+ } | null;
72
95
  /**
73
96
  * Wrap decision for user_bash (! commands): same rules as the tool, minus
74
97
  * the dangerouslyDisableSandbox parameter (users running ! commands are
@@ -78,9 +101,20 @@ export declare function shouldUseSandboxForUserCommand(command: string, manager:
78
101
  /**
79
102
  * Post-execution violation annotation: annotate a command's combined output
80
103
  * with sandbox violation details when the sandbox is active. Returns the
81
- * annotated output (or the original when nothing to annotate).
104
+ * annotated output (or the original when nothing to annotate) plus the
105
+ * network-posture classification (null when the output carried no network
106
+ * signature) so callers can log the sink event without a second regex pass.
107
+ * Composes the hint (networkDenialHint) after the violation detail —
108
+ * advisory only, never a grant. Uninitialized manager ⇒ passthrough, no
109
+ * hint (no annotation context, and the sink is not wired yet).
82
110
  */
83
- export declare function annotateCommandOutput(manager: YagniSandboxManager, command: string, output: string): string;
111
+ export declare function annotateCommandOutput(manager: YagniSandboxManager, command: string, output: string): {
112
+ text: string;
113
+ net: {
114
+ cls: NetworkDenialClass;
115
+ hint: string;
116
+ } | null;
117
+ };
84
118
  /** cwd must exist before spawn (guard borrowed from pi's local ops). */
85
119
  export declare function assertSpawnableCwd(cwd: string): void;
86
120
  /**
@@ -131,6 +131,97 @@ export function extractBlockedWritePath(output) {
131
131
  const match = output.match(/(?:\/bin\/bash|bash|sh): (?:line \d+: )?(\/[^\s:]+): Operation not permitted/);
132
132
  return match ? match[1] : null;
133
133
  }
134
+ /**
135
+ * Classify a sandbox network-posture denial from command output. Pure;
136
+ * advisory-only (never widens anything — the fix is a knob the USER flips,
137
+ * never an automatic grant). Matches the signatures observed live and in
138
+ * the session review:
139
+ * - ipc-listen: `listen EPERM` (node:events EPERM with syscall listen —
140
+ * a named unix-socket or TCP bind the profile denies;
141
+ * tsx/vitest test-runner IPC is the canonical case)
142
+ * - tls-trustd: `x509` + `OSStatus -26276` (Go TLS verification denied
143
+ * the trustd.agent mach lookup — gh/gcloud/terraform)
144
+ * - loopback: EPERM/"not permitted" on a connect to 127.0.0.1/
145
+ * localhost/::1 (loopback bypasses the proxy via
146
+ * no_proxy, so the allowlist can never admit it)
147
+ * Returns null when the output carries no network-posture signature — a
148
+ * plain file-write EPERM must NOT get a network hint.
149
+ */
150
+ export function networkDenialHint(output) {
151
+ // ipc-listen: EPERM on a socket/TCP bind — node prints "listen EPERM: …",
152
+ // other tools print "EPERM … listen". Both orders are the same OS
153
+ // denial, but the second marker must be a socket/TCP-bind signal, not the
154
+ // bare word "listen" — a file-write EPERM whose text merely contains
155
+ // "listen" (e.g. `bash: ./listen.sh: Operation not permitted`) must stay a
156
+ // filesystem denial (the pinned invariant). Qualification is strictly
157
+ // SAME-LINE: socket markers, host:port addresses, and the inline
158
+ // syscall:'listen' token must all sit on the denial line itself (a stray
159
+ // IP:port or detail line elsewhere in the output is not a bind denial;
160
+ // node's two-line shape qualifies because its EPERM line carries the
161
+ // .pipe/.sock path or host:port address on its own line).
162
+ const sysListen = /syscall: ['"]listen['"]/i;
163
+ // The listen+EPERM denial shape. Two spellings are deliberately NOT a
164
+ // denial: the `syscall: 'listen'` value (stripped before the test — it
165
+ // is a detail-line token, not a denial verb, and a stray detail line
166
+ // anywhere in the output must not self-qualify) and filename-like
167
+ // tokens (listen.sh, listen:) — the bare word "listen" in a path is
168
+ // not a bind attempt (the listen.sh false-positive class). A line
169
+ // carrying its own syscall token still counts when it is otherwise a
170
+ // denial shape (an inline-token denial line, not a stray).
171
+ const denialShape = (line) => {
172
+ const stripped = line
173
+ .replace(/syscall: ['"]listen['"]/gi, "")
174
+ .replace(/\blisten[.:_-]\w*/gi, "");
175
+ return /\blisten\b/.test(stripped) && /\bEPERM\b|Operation not permitted/i.test(stripped);
176
+ };
177
+ const perLineMarker = /(\.sock\b|\.pipe\b|unix[- ]socket|\bipc\b|socket-domain|AF_UNIX|\bsocket\b|\bbind\b|\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+\b|\blocalhost:\d+\b)/i;
178
+ // A line CARRYING the syscall token but no other bind evidence is a
179
+ // node error-object DETAIL line (` code: 'EPERM', … syscall: 'listen'`)
180
+ // — a continuation, never a denial line in its own right. Only an
181
+ // otherwise-denial line with its own INLINE syscall token (the one-line
182
+ // spelling) qualifies; a detail line riding on a neighbor never does.
183
+ // Socket markers and host:port addresses keep the same-line discipline.
184
+ if (/\blisten\b/.test(output)) {
185
+ const lines = output.split("\n");
186
+ // A real bind denial: a single line that is BOTH a denial shape and
187
+ // carries its own bind evidence (a socket/address marker, or the
188
+ // inline syscall token). No cross-line qualification: every observed
189
+ // node denial line carries its own evidence (the .pipe/.sock path or
190
+ // the host:port address), and letting a detail line ride on its
191
+ // neighbor is exactly how stray `syscall: 'listen'` lines from
192
+ // unrelated output false-positived. The detail line never qualifies
193
+ // in its own right.
194
+ const denialLine = lines.some((line) => denialShape(line) && (perLineMarker.test(line) || sysListen.test(line)));
195
+ if (denialLine) {
196
+ return {
197
+ cls: "ipc-listen",
198
+ hint: "This looks like a sandbox network-posture denial (a socket bind the sandbox profile denies — test-runner IPC is the usual case). " +
199
+ "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.",
200
+ };
201
+ }
202
+ }
203
+ if (/x509[^\n]*OSStatus -26276|OSStatus -26276/.test(output)) {
204
+ return {
205
+ cls: "tls-trustd",
206
+ 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). " +
207
+ "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.",
208
+ };
209
+ }
210
+ // Loopback connect denials: EPERM/not-permitted text with a loopback
211
+ // address nearby. Node prints `Error: listen EPERM ... (code: 'EPERM',
212
+ // syscall: 'connect', address: '127.0.0.1')`; curl prints the socket
213
+ // error line; generic CLIs print `connect() to 127.0.0.1 failed`.
214
+ if (/(EPERM|Operation not permitted|not permitted)/i.test(output) &&
215
+ /(127\.0\.0\.1|localhost|\[::1\]|"::1")/.test(output) &&
216
+ /(connect|curl|psql|wget|fetch|Failed to connect|Couldn't connect)/i.test(output)) {
217
+ return {
218
+ cls: "loopback",
219
+ hint: "This looks like a sandbox loopback denial (loopback bypasses the network allowlist, so localhost services are blocked by default). " +
220
+ "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.",
221
+ };
222
+ }
223
+ return null;
224
+ }
134
225
  /**
135
226
  * Wrap decision for user_bash (! commands): same rules as the tool, minus
136
227
  * the dangerouslyDisableSandbox parameter (users running ! commands are
@@ -146,14 +237,25 @@ export function shouldUseSandboxForUserCommand(command, manager, settings) {
146
237
  /**
147
238
  * Post-execution violation annotation: annotate a command's combined output
148
239
  * with sandbox violation details when the sandbox is active. Returns the
149
- * annotated output (or the original when nothing to annotate).
240
+ * annotated output (or the original when nothing to annotate) plus the
241
+ * network-posture classification (null when the output carried no network
242
+ * signature) so callers can log the sink event without a second regex pass.
243
+ * Composes the hint (networkDenialHint) after the violation detail —
244
+ * advisory only, never a grant. Uninitialized manager ⇒ passthrough, no
245
+ * hint (no annotation context, and the sink is not wired yet).
150
246
  */
151
247
  export function annotateCommandOutput(manager, command, output) {
152
248
  if (!manager.initialized)
153
- return output;
154
- if (!output.includes("Operation not permitted"))
155
- return output;
156
- return manager.annotateStderrWithSandboxFailures(command, output);
249
+ return { text: output, net: null };
250
+ const net = networkDenialHint(output);
251
+ if (!output.includes("Operation not permitted") && !net)
252
+ return { text: output, net: null };
253
+ const annotated = output.includes("Operation not permitted")
254
+ ? manager.annotateStderrWithSandboxFailures(command, output)
255
+ : output;
256
+ if (!net)
257
+ return { text: annotated, net: null };
258
+ return { text: `${annotated}\n\n[sandbox] ${net.hint}`, net };
157
259
  }
158
260
  /** cwd must exist before spawn (guard borrowed from pi's local ops). */
159
261
  export function assertSpawnableCwd(cwd) {
@@ -76,6 +76,12 @@ export declare function loadSandboxSettings(opts?: {
76
76
  env?: NodeJS.ProcessEnv;
77
77
  userHome?: string;
78
78
  stateHomeOverride?: string | null;
79
+ /** When false, the project tier's network-widening knobs are dropped
80
+ * before the merge (a hostile repo's committed config cannot grant
81
+ * all-sockets / loopback / trustd). Defaults to true (trusted) — the
82
+ * callers that read the trust snapshot pass it explicitly; read-only
83
+ * surfaces (panel display, tests) can leave it unset. */
84
+ trusted?: boolean;
79
85
  }): LoadedSandboxConfig;
80
86
  export interface SandboxRuntimePaths {
81
87
  cwd: string;
@@ -84,6 +90,9 @@ export interface SandboxRuntimePaths {
84
90
  /** Project root; null outside a repo. */
85
91
  projectRoot: string | null;
86
92
  homeDir?: string;
93
+ /** Session env (drives the srt-forced-child tmpdir half of $TMPDIR
94
+ * expansion); defaults to process.env when absent. */
95
+ env?: NodeJS.ProcessEnv;
87
96
  }
88
97
  export interface SandboxRuntimeMerge {
89
98
  network: SandboxNetworkSettings & {
@@ -23,6 +23,42 @@ import { existsSync, readFileSync } from "node:fs";
23
23
  import { tmpdir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import { codeStateHome } from "../stateHome.js";
26
+ /** Network-widening keys that must never come from a non-trusted repo's
27
+ * committed config (the project tier). All four open real surface —
28
+ * unix-socket path entries admit local services, allowAllUnixSockets admits
29
+ * docker.sock (host access), allowLocalBinding opens every loopback service,
30
+ * enableWeakerNetworkIsolation opens the trustd exfiltration vector. The
31
+ * LOCAL tier is deliberately NOT gated: a local write already needs local
32
+ * write access, and Claude Code does not trust-gate settings.local.json
33
+ * either (documented in sandboxing.md). */
34
+ const NETWORK_WIDENING_KEYS = [
35
+ "allowUnixSockets",
36
+ "allowAllUnixSockets",
37
+ "allowLocalBinding",
38
+ ];
39
+ /** Drop a tier's network-widening knobs (never call for the local tier). */
40
+ function stripNetworkWidening(settings) {
41
+ if (!settings)
42
+ return settings;
43
+ const network = settings.network ? { ...settings.network } : undefined;
44
+ let touched = false;
45
+ if (network) {
46
+ for (const k of NETWORK_WIDENING_KEYS) {
47
+ if (network[k] !== undefined) {
48
+ delete network[k];
49
+ touched = true;
50
+ }
51
+ }
52
+ }
53
+ const top = { ...settings };
54
+ if (settings.enableWeakerNetworkIsolation !== undefined) {
55
+ delete top.enableWeakerNetworkIsolation;
56
+ touched = true;
57
+ }
58
+ if (touched && network)
59
+ top.network = network;
60
+ return touched ? top : settings;
61
+ }
26
62
  const KNOWN_TOP_LEVEL_KEYS = new Set([
27
63
  "enabled",
28
64
  "autoAllowBashIfSandboxed",
@@ -223,8 +259,17 @@ export function loadSandboxSettings(opts = {}) {
223
259
  const projectPath = join(cwd, ".yagni-code", "config.json");
224
260
  const localPath = join(cwd, ".yagni-code", "config.local.json");
225
261
  const userSettings = readSandboxSettingsFromFile(userPath, warnings, unknownKeys);
226
- const projectSettings = readSandboxSettingsFromFile(projectPath, warnings, unknownKeys);
262
+ const rawProjectSettings = readSandboxSettingsFromFile(projectPath, warnings, unknownKeys);
227
263
  const localSettings = readSandboxSettingsFromFile(localPath, warnings, unknownKeys);
264
+ // Trust gate: a non-trusted repo's committed project config can set the
265
+ // network-widening knobs, which would silently open docker.sock /
266
+ // loopback / the trustd vector. Same posture as the permission-rule
267
+ // filtering: the local tier is personal (never gated), the project tier
268
+ // is only honored when the repo is trusted.
269
+ const projectSettings = opts.trusted === false ? stripNetworkWidening(rawProjectSettings) : rawProjectSettings;
270
+ if (opts.trusted === false && projectSettings !== rawProjectSettings) {
271
+ warnings.push("sandbox: network-widening keys ignored from project config (repo not trusted)");
272
+ }
228
273
  const settings = {
229
274
  enabled: localSettings?.enabled ?? projectSettings?.enabled ?? userSettings?.enabled ?? false,
230
275
  autoAllowBashIfSandboxed: localSettings?.autoAllowBashIfSandboxed ??
@@ -279,6 +324,31 @@ function resolveSandboxFsPath(pattern, base) {
279
324
  return pattern;
280
325
  return expandHome(pattern, homeDir);
281
326
  }
327
+ /** The temp dir srt FORCES onto every sandboxed child (sandbox-utils.js
328
+ * generateProxyEnvVars): CLAUDE_CODE_TMPDIR || CLAUDE_TMPDIR ||
329
+ * "/tmp/claude". The child's TMPDIR is NOT the extension host's tmpdir —
330
+ * tsx/node/test-runner children bind their IPC sockets HERE, so the
331
+ * socket allow must cover it (and the host dir, for tooling that reads
332
+ * the ambient TMPDIR). */
333
+ function srtForcedChildTmpdir(env) {
334
+ return env.CLAUDE_CODE_TMPDIR || env.CLAUDE_TMPDIR || "/tmp/claude";
335
+ }
336
+ /** allowUnixSockets entry resolution: the literal "$TMPDIR" marker expands
337
+ * to the TWO temp dirs a sandboxed session can have — the extension
338
+ * host's os.tmpdir() (env-honoring) AND the dir srt forces onto children
339
+ * (see srtForcedChildTmpdir). "$TMPDIR/..." prefixes expand against the
340
+ * HOST dir only (subpaths of the forced dir are an odd ask; keep them
341
+ * literal-precise). Anything else passes through verbatim (srt takes
342
+ * literal absolute paths). srt realpaths each entry into the SBPL subpath
343
+ * rule, so a /tmp symlink spelling composes at the OS level. */
344
+ function expandTmpDirEntries(entry, env) {
345
+ if (entry === "$TMPDIR") {
346
+ return [...new Set([tmpdir(), srtForcedChildTmpdir(env)])];
347
+ }
348
+ if (entry.startsWith("$TMPDIR/"))
349
+ return [join(tmpdir(), entry.slice("$TMPDIR/".length))];
350
+ return [entry];
351
+ }
282
352
  let homeDirDefault = () => homeDirImpl();
283
353
  import { homedir as homeDirImpl } from "node:os";
284
354
  function homeDirDefaultFn() { return homeDirImpl(); }
@@ -360,6 +430,19 @@ export function mergeRulesIntoSandbox(settings, rules, base, worktreeGit) {
360
430
  denyRead.add(resolveSandboxFsPath(p, base));
361
431
  for (const p of settings.filesystem?.allowRead ?? [])
362
432
  allowRead.add(resolveSandboxFsPath(p, base));
433
+ // allowUnixSockets: the portable "$TMPDIR" entry (what the Network tab's
434
+ // Engineering preset persists) expands to BOTH temp dirs a sandboxed session can have (the host's os.tmpdir() AND the dir srt forces onto children — they differ; see expandTmpDirEntries) —
435
+ // srt takes literal paths and emits one SBPL subpath rule per entry, so
436
+ // the expansion admits every random-named IPC socket a test runner binds
437
+ // there. Literal paths and ~/. entries pass through untouched (union
438
+ // semantics: a project/user entry plus the local "$TMPDIR" entry yield
439
+ // two paths). macOS-only surface (srt ignores the list on Linux, where
440
+ // seccomp cannot path-filter — the Linux preset uses the broader
441
+ // allowAllUnixSockets instead).
442
+ const expandEnv = base.env ?? process.env;
443
+ const allowUnixSockets = [
444
+ ...new Set((settings.network?.allowUnixSockets ?? []).flatMap((e) => expandTmpDirEntries(e, expandEnv))),
445
+ ];
363
446
  // Linked-worktree git access: allow writes to the shared common git dir
364
447
  // only (routine worktree git — index.lock, refs, objects — never writes
365
448
  // outside it), and pin hooks + config read-only within the newly allowed
@@ -392,6 +475,7 @@ export function mergeRulesIntoSandbox(settings, rules, base, worktreeGit) {
392
475
  ...settings.network,
393
476
  allowedDomains: [...allowedDomains],
394
477
  deniedDomains: [...deniedDomains],
478
+ allowUnixSockets,
395
479
  },
396
480
  filesystem: {
397
481
  allowRead: [...allowRead],
@@ -32,6 +32,9 @@ export interface SandboxSessionOptions {
32
32
  rgPath?: string;
33
33
  askHandler?: NetworkAskHandler | null;
34
34
  events?: SandboxManagerEvents;
35
+ /** Trust snapshot for the network-widening config gate (project tier is
36
+ * dropped when false). session.ts sets it per trust-probe capture. */
37
+ trusted?: boolean;
35
38
  }
36
39
  export interface SandboxSessionState {
37
40
  /** srt initialized and commands will be wrapped. */
@@ -65,6 +68,10 @@ export declare class YagniSandboxManager {
65
68
  get settings(): SandboxSettings | null;
66
69
  get dependencies(): SandboxDependencyStatus | null;
67
70
  setAskHandler(handler: NetworkAskHandler | null): void;
71
+ /** Trust snapshot setter (session.ts's ctx probes run after
72
+ * construction). Read at merge time, so a trust flip applies to the next
73
+ * initialize/refresh without a reset. */
74
+ setTrusted(trusted: boolean): void;
68
75
  /** The failure-trail wrapper handed to srt — exported for tests so the
69
76
  * catch-and-log contract (fail closed + sink line) is observable without
70
77
  * driving a real wrapped network connection. */
@@ -58,6 +58,12 @@ export class YagniSandboxManager {
58
58
  setAskHandler(handler) {
59
59
  this.askHandler = handler;
60
60
  }
61
+ /** Trust snapshot setter (session.ts's ctx probes run after
62
+ * construction). Read at merge time, so a trust flip applies to the next
63
+ * initialize/refresh without a reset. */
64
+ setTrusted(trusted) {
65
+ this.opts.trusted = trusted;
66
+ }
61
67
  /** The failure-trail wrapper handed to srt — exported for tests so the
62
68
  * catch-and-log contract (fail closed + sink line) is observable without
63
69
  * driving a real wrapped network connection. */
@@ -106,6 +112,7 @@ export class YagniSandboxManager {
106
112
  env: this.opts.env,
107
113
  userHome: this.opts.userHome,
108
114
  stateHomeOverride: this.opts.stateHomeOverride,
115
+ trusted: this.opts.trusted,
109
116
  });
110
117
  this.surfaceConfigDiagnostics(diagnostics);
111
118
  const worktreeGit = this.state.worktreeGit !== undefined
@@ -116,6 +123,7 @@ export class YagniSandboxManager {
116
123
  userStateHome: this.opts.stateHomeOverride ?? codeStateHome(null, this.opts.env, this.opts.userHome),
117
124
  projectRoot: this.opts.projectRoot ?? null,
118
125
  homeDir: this.opts.userHome,
126
+ env: this.opts.env ?? process.env,
119
127
  }, worktreeGit);
120
128
  }
121
129
  runtimeConfig(merge, settings) {
@@ -166,6 +174,7 @@ export class YagniSandboxManager {
166
174
  env: this.opts.env,
167
175
  userHome: this.opts.userHome,
168
176
  stateHomeOverride: this.opts.stateHomeOverride,
177
+ trusted: this.opts.trusted,
169
178
  });
170
179
  // Same warning surfacing as buildRuntimeMerge — initialize() skips
171
180
  // buildRuntimeMerge on the disabled path, so its own load reports too.
@@ -218,6 +227,7 @@ export class YagniSandboxManager {
218
227
  env: this.opts.env,
219
228
  userHome: this.opts.userHome,
220
229
  stateHomeOverride: this.opts.stateHomeOverride,
230
+ trusted: this.opts.trusted,
221
231
  }).settings));
222
232
  }
223
233
  /**
@@ -22,9 +22,12 @@ import { Container, type SelectItem } from "@earendil-works/pi-tui";
22
22
  import { type SandboxRuntimeMerge, type SandboxRuntimePaths, type SandboxSettings } from "./config.js";
23
23
  import type { RenderTheme } from "../subagentRender.js";
24
24
  import type { PermissionRule } from "../permissionRules/loadConfig.js";
25
- export type SandboxPanelTab = "mode" | "overrides" | "config" | "dependencies";
25
+ export type SandboxPanelTab = "mode" | "overrides" | "network" | "config" | "dependencies";
26
26
  export type SandboxModeChoice = "auto-allow" | "regular" | "disabled";
27
27
  export type SandboxOverrideChoice = "open" | "closed";
28
+ /** A Network-tab selection: the one-click Engineering preset (platform-
29
+ * shaped composition) or an individual knob toggle. */
30
+ export type SandboxNetworkChoice = "engineering-preset" | "engineering-preset-off" | "trustd" | "trustd-off" | "unix-sockets" | "unix-sockets-off" | "local-binding" | "local-binding-off";
28
31
  /** Everything the panel needs, resolved by the caller (session.ts). */
29
32
  export interface SandboxPanelState {
30
33
  settings: SandboxSettings;
@@ -44,12 +47,16 @@ export interface SandboxPanelActions {
44
47
  onModeSelect: (choice: SandboxModeChoice) => Promise<SandboxPanelOutcome>;
45
48
  /** Persist an override choice. Same contract. */
46
49
  onOverrideSelect: (choice: SandboxOverrideChoice) => Promise<SandboxPanelOutcome>;
50
+ /** Persist a network-posture choice (preset or single knob). Same
51
+ * contract; the knob state derives from the settings snapshot the panel
52
+ * was built with. */
53
+ onNetworkSelect: (choice: SandboxNetworkChoice) => Promise<SandboxPanelOutcome>;
47
54
  }
48
55
  /** What the panel hands back when it closes: the confirmation (or error)
49
56
  * the session surfaces via notify once custom() resolves. */
50
57
  export interface SandboxPanelOutcome {
51
58
  text: string;
52
- level: "info" | "error";
59
+ level: "info" | "error" | "warning";
53
60
  }
54
61
  /** Current mode derived from resolved settings (Claude's currentMode). */
55
62
  export declare function deriveCurrentMode(settings: SandboxSettings, sessionToggledOff: boolean): SandboxModeChoice;
@@ -59,6 +66,28 @@ export declare function overrideOptions(current: SandboxOverrideChoice): SelectI
59
66
  /** Per-mode explanation (Claude's copy, adapted to our surfaces). */
60
67
  export declare function modeExplanation(mode: SandboxModeChoice): string;
61
68
  export declare function overrideExplanation(choice: SandboxOverrideChoice): string;
69
+ /** The three knobs' resolved state, derived from settings (undefined =
70
+ * unset, which resolves OFF at the runtime). */
71
+ export interface NetworkKnobState {
72
+ trustd: boolean;
73
+ /** macOS: the $TMPDIR entry is present in allowUnixSockets; Linux:
74
+ * allowAllUnixSockets (path-scoped lists are macOS-only — seccomp cannot
75
+ * path-filter). */
76
+ unixSockets: boolean;
77
+ localBinding: boolean;
78
+ }
79
+ export declare function deriveNetworkKnobs(settings: SandboxSettings, platform: NodeJS.Platform): NetworkKnobState;
80
+ /** Does the platform-shape of the preset match the knobs already resolved
81
+ * on? Drives the preset row's "(applied)" label. Platform-shaped: on
82
+ * Linux the preset never sets trustd (macOS-only), so "applied" is
83
+ * sockets+loopback there; on darwin it is all three. */
84
+ export declare function presetApplied(knobs: NetworkKnobState, platform: NodeJS.Platform): boolean;
85
+ /** The literal settings block the preset persists per platform (the local
86
+ * tier; session.ts owns the write). Exported so session + tests share one
87
+ * composition — the panel never builds it ad hoc. */
88
+ export declare function engineeringPresetBlock(platform: NodeJS.Platform): SandboxSettings;
89
+ export declare function networkOptions(knobs: NetworkKnobState, platform: NodeJS.Platform): SelectItem[];
90
+ export declare function networkExplanation(item: SelectItem, platform: NodeJS.Platform): string;
62
91
  /**
63
92
  * Config tab sections, conditional like Claude's SandboxConfigTab: empty
64
93
  * sections are omitted entirely (Excluded Commands — Claude's one always-on
@@ -70,7 +99,8 @@ export interface ConfigSection {
70
99
  detail: string[];
71
100
  }
72
101
  export declare function configSections(state: SandboxPanelState): ConfigSection[];
73
- /** Tabs offered, mirroring Claude: Dependencies appears only on errors. */
102
+ /** Tabs offered, mirroring Claude plus our Network tab (the deliberate
103
+ * deviation): Dependencies appears only on errors. */
74
104
  export declare function panelTabs(dependencyErrors: string[]): SandboxPanelTab[];
75
105
  /**
76
106
  * The /sandbox panel. Structure: top border · tab strip · blank · tab
@@ -95,6 +125,7 @@ export declare class SandboxPanel extends Container {
95
125
  private buildBody;
96
126
  private buildTabContent;
97
127
  private buildModeTab;
128
+ private buildNetworkTab;
98
129
  private buildOverridesTab;
99
130
  private buildConfigTab;
100
131
  private buildDependenciesTab;
@@ -21,6 +21,7 @@
21
21
  import { Box, Container, HStack, Key, SelectList, Text, matchesKey, } from "@earendil-works/pi-tui";
22
22
  import { mergeRulesIntoSandbox } from "./config.js";
23
23
  import { resolveWorktreeGitAccess } from "./worktreeGit.js";
24
+ import { logEvent } from "../errorSink.js";
24
25
  // ---------------------------------------------------------------------------
25
26
  // Pure derivation helpers (unit-tested; no TUI dependency)
26
27
  // ---------------------------------------------------------------------------
@@ -62,6 +63,106 @@ export function overrideExplanation(choice) {
62
63
  ? "When a command fails due to sandbox restrictions, the agent can retry with dangerouslyDisableSandbox to run outside the sandbox (falling back to default permissions)."
63
64
  : "All bash commands invoked by the model must run in the sandbox unless they are explicitly listed in excludedCommands.";
64
65
  }
66
+ export function deriveNetworkKnobs(settings, platform) {
67
+ const sockets = platform === "darwin"
68
+ ? (settings.network?.allowUnixSockets ?? []).some((e) => e === "$TMPDIR" || e === "$TMPDIR/")
69
+ : settings.network?.allowAllUnixSockets === true;
70
+ return {
71
+ trustd: settings.enableWeakerNetworkIsolation === true,
72
+ unixSockets: sockets,
73
+ localBinding: settings.network?.allowLocalBinding === true,
74
+ };
75
+ }
76
+ /** Does the platform-shape of the preset match the knobs already resolved
77
+ * on? Drives the preset row's "(applied)" label. Platform-shaped: on
78
+ * Linux the preset never sets trustd (macOS-only), so "applied" is
79
+ * sockets+loopback there; on darwin it is all three. */
80
+ export function presetApplied(knobs, platform) {
81
+ if (platform === "darwin")
82
+ return knobs.trustd && knobs.unixSockets && knobs.localBinding;
83
+ return knobs.unixSockets && knobs.localBinding;
84
+ }
85
+ /** The literal settings block the preset persists per platform (the local
86
+ * tier; session.ts owns the write). Exported so session + tests share one
87
+ * composition — the panel never builds it ad hoc. */
88
+ export function engineeringPresetBlock(platform) {
89
+ if (platform === "darwin") {
90
+ return {
91
+ enableWeakerNetworkIsolation: true,
92
+ network: { allowUnixSockets: ["$TMPDIR"], allowLocalBinding: true },
93
+ };
94
+ }
95
+ return {
96
+ network: { allowAllUnixSockets: true, allowLocalBinding: true },
97
+ };
98
+ }
99
+ export function networkOptions(knobs, platform) {
100
+ const isMac = platform === "darwin";
101
+ const items = [
102
+ {
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",
106
+ },
107
+ ];
108
+ if (isMac) {
109
+ items.push({
110
+ value: "trustd",
111
+ label: `macOS TLS verification via trustd: ${knobs.trustd ? "on" : "off"}`,
112
+ description: "Go CLIs (gh, gcloud, terraform) verify TLS through com.apple.trustd.agent; allowing it opens a potential data exfiltration vector through the trustd service",
113
+ });
114
+ items.push({
115
+ value: "unix-sockets",
116
+ label: `Unix sockets in $TMPDIR: ${knobs.unixSockets ? "on" : "off"}`,
117
+ description: "Admits test-runner IPC binds (tsx, vitest, node workers) inside the sandbox temp dir only",
118
+ });
119
+ }
120
+ else {
121
+ items.push({
122
+ value: "unix-sockets",
123
+ label: `All unix sockets: ${knobs.unixSockets ? "on" : "off"}`,
124
+ description: "seccomp cannot path-filter on Linux, so this is all-or-nothing — docker.sock included. Test-runner IPC needs it",
125
+ });
126
+ }
127
+ items.push({
128
+ value: "local-binding",
129
+ label: `Localhost connections + binding: ${knobs.localBinding ? "on" : "off"}`,
130
+ description: "Loopback bypasses the domain allowlist (localhost services and dev servers/DBs become reachable without prompts)",
131
+ });
132
+ // Remove-entries ride the same list as -off variants: SelectItem values
133
+ // are matched literally by the onSelect handler.
134
+ if (knobs.trustd || knobs.unixSockets || knobs.localBinding) {
135
+ items.push({
136
+ value: "engineering-preset-off",
137
+ label: "Remove the preset knobs (set off in this project's local settings)",
138
+ description: "Writes explicit off values to .yagni-code/config.local.json; project/user entries stay as they are",
139
+ });
140
+ }
141
+ return items;
142
+ }
143
+ export function networkExplanation(item, platform) {
144
+ switch (item.value) {
145
+ case "engineering-preset":
146
+ return platform === "darwin"
147
+ ? "Applies: unix sockets in $TMPDIR + trustd TLS verification + localhost connections. Held work (pnpm/tsx test runs, gh, psql to localhost) runs sandboxed instead of escaping. The trustd knob opens a potential data exfiltration vector through the trustd service — that tradeoff is yours to accept."
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
+ case "trustd":
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.";
152
+ case "unix-sockets":
153
+ case "unix-sockets-off":
154
+ return platform === "darwin"
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.";
157
+ case "local-binding":
158
+ case "local-binding-off":
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.";
160
+ case "engineering-preset-off":
161
+ return "Writes explicit OFF values to this project's local settings — local wins over project/user on scalars, so a knob set higher up stops applying here. The $TMPDIR socket entry is removed from the local list. Entries committed to project or user config are NOT changed — edit those files directly to turn a knob back on there.";
162
+ default:
163
+ return "";
164
+ }
165
+ }
65
166
  export function configSections(state) {
66
167
  const { settings, merge } = state;
67
168
  const sections = [];
@@ -93,14 +194,24 @@ export function configSections(state) {
93
194
  const unixSockets = settings.network?.allowUnixSockets ?? [];
94
195
  if (unixSockets.length > 0)
95
196
  sections.push({ title: "Allowed Unix Sockets", detail: [unixSockets.join(", ")] });
197
+ if (merge.network.allowLocalBinding) {
198
+ sections.push({ title: "Local Binding", detail: ["loopback + private ranges allowed (localhost clients, dev servers, DB tunnels)"] });
199
+ }
200
+ if (settings.network?.allowAllUnixSockets) {
201
+ sections.push({ title: "All Unix Sockets", detail: ["allowed (Linux posture — includes docker.sock)"] });
202
+ }
203
+ if (settings.enableWeakerNetworkIsolation) {
204
+ sections.push({ title: "Weaker Network Isolation", detail: ["trustd.agent allowed (Go TLS verification; opens a trustd exfiltration vector)"] });
205
+ }
96
206
  if (state.sessionGrants.length > 0) {
97
207
  sections.push({ title: "Session Grants (memory-only)", detail: [state.sessionGrants.join(", ")] });
98
208
  }
99
209
  return sections;
100
210
  }
101
- /** Tabs offered, mirroring Claude: Dependencies appears only on errors. */
211
+ /** Tabs offered, mirroring Claude plus our Network tab (the deliberate
212
+ * deviation): Dependencies appears only on errors. */
102
213
  export function panelTabs(dependencyErrors) {
103
- const tabs = ["mode", "overrides", "config"];
214
+ const tabs = ["mode", "overrides", "network", "config"];
104
215
  if (dependencyErrors.length > 0)
105
216
  tabs.push("dependencies");
106
217
  return tabs;
@@ -111,6 +222,7 @@ export function panelTabs(dependencyErrors) {
111
222
  const TAB_LABELS = {
112
223
  mode: "Mode",
113
224
  overrides: "Overrides",
225
+ network: "Network",
114
226
  config: "Config",
115
227
  dependencies: "Dependencies",
116
228
  };
@@ -201,6 +313,8 @@ export class SandboxPanel extends Container {
201
313
  return this.buildModeTab();
202
314
  case "overrides":
203
315
  return this.buildOverridesTab();
316
+ case "network":
317
+ return this.buildNetworkTab();
204
318
  case "config":
205
319
  return this.buildConfigTab();
206
320
  case "dependencies":
@@ -240,6 +354,51 @@ export class SandboxPanel extends Container {
240
354
  wrap.addChild(explanation);
241
355
  return wrap;
242
356
  }
357
+ buildNetworkTab() {
358
+ const t = this.theme;
359
+ if (!this.state.settings.enabled) {
360
+ this.activeList = null;
361
+ return new Text(t.fg("muted", "Sandbox is not enabled. Enable sandbox to configure network posture."), 0, 0);
362
+ }
363
+ const knobs = deriveNetworkKnobs(this.state.settings, process.platform);
364
+ const options = networkOptions(knobs, process.platform);
365
+ const list = new SelectList(options, 10, selectListTheme(t));
366
+ const explanation = new Text(t.fg("muted", networkExplanation(options[0], process.platform)), 0, 1);
367
+ list.onSelectionChange = (item) => {
368
+ explanation.setText(t.fg("muted", networkExplanation(item, process.platform)));
369
+ this.invalidate();
370
+ };
371
+ list.onSelect = (item) => {
372
+ void this.actions
373
+ .onNetworkSelect(item.value)
374
+ .then((outcome) => this.done(outcome))
375
+ // A rejection AFTER a successful persist (e.g. load() or
376
+ // refreshConfig blew up) must not close the panel silently — the
377
+ // write may have landed while the live refresh did not, so the
378
+ // user gets the honest message AND the trail gets the reason
379
+ // (error class only; the thrown message is never scrubbed into
380
+ // the sink — same posture as sandbox_persist_failed).
381
+ .catch((err) => {
382
+ logEvent({
383
+ source: "sandbox",
384
+ level: "warn",
385
+ event: "sandbox_refresh_failed",
386
+ fields: { error: err instanceof Error ? err.constructor.name : typeof err },
387
+ });
388
+ this.done({
389
+ text: "Network setting saved, but applying it to the live session failed — restart the session (or /new) to make it take effect.",
390
+ level: "warning",
391
+ });
392
+ });
393
+ };
394
+ list.onCancel = () => this.done();
395
+ this.activeList = list;
396
+ const wrap = new Container();
397
+ wrap.addChild(new Text(t.bold("Network posture for engineering work:"), 0, 0));
398
+ wrap.addChild(list);
399
+ wrap.addChild(explanation);
400
+ return wrap;
401
+ }
243
402
  buildOverridesTab() {
244
403
  const t = this.theme;
245
404
  if (!this.state.settings.enabled) {
@@ -14,6 +14,7 @@
14
14
  import { type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
15
15
  import { type SandboxSettings } from "./config.js";
16
16
  import { YagniSandboxManager } from "./manager.js";
17
+ import { type SandboxNetworkChoice } from "./panel.js";
17
18
  import type { PermissionRule } from "../permissionRules/loadConfig.js";
18
19
  /**
19
20
  * Build the sandbox bash composition: given ANY stock bash definition (pi's
@@ -63,7 +64,7 @@ export interface RegisterSandboxOptions {
63
64
  stateHomeOverride?: string | null;
64
65
  projectRoot?: string | null;
65
66
  rgPath?: string;
66
- }) => Pick<YagniSandboxManager, "initialized" | "initialize" | "reset" | "refreshConfig" | "setAskHandler" | "wrapWithSandbox" | "cleanupAfterCommand" | "annotateStderrWithSandboxFailures"> & YagniSandboxManager;
67
+ }) => Pick<YagniSandboxManager, "initialized" | "initialize" | "reset" | "refreshConfig" | "setAskHandler" | "setTrusted" | "wrapWithSandbox" | "cleanupAfterCommand" | "annotateStderrWithSandboxFailures"> & YagniSandboxManager;
67
68
  /** Whether interactive prompts are possible (TUI/RPC). */
68
69
  hasUI: (ctx: ExtensionContext) => boolean;
69
70
  /**
@@ -82,4 +83,16 @@ export interface RegisterSandboxOptions {
82
83
  * sandbox (no-op registration, stock bash stays).
83
84
  */
84
85
  export declare function registerSandbox(pi: ExtensionAPI, opts: RegisterSandboxOptions): SandboxSessionHandle | null;
86
+ /** Persist a Network-tab choice (the Engineering preset or a single knob
87
+ * toggle) to the PROJECT-LOCAL config — same destination + honesty contract
88
+ * as the other panel writes. The preset composes the platform-shaped block
89
+ * from panel.ts (one source of truth for the composition) and NORMALIZES
90
+ * the other platform's knobs off (a stale broader door — or a stale trustd
91
+ * value on Linux — never survives a preset apply silently); knob toggles
92
+ * write explicit off values so local precedence overrides any project/user
93
+ * on-state. The $TMPDIR socket entry is REMOVED from the local array (not
94
+ * falsed — arrays have no false), so un-applying the preset leaves the
95
+ * local tier clean; project/user entries keep applying by union. Exported
96
+ * for tests (the per-platform branches are pinned directly). */
97
+ export declare function persistNetworkChoice(choice: SandboxNetworkChoice, cwd: string, platform?: NodeJS.Platform): void;
85
98
  //# sourceMappingURL=session.d.ts.map
@@ -23,9 +23,9 @@ import { isDebug } from "../diagnostics.js";
23
23
  import { mutateConfigJson, mutateLocalConfig } from "../settingsFiles.js";
24
24
  import { loadSandboxSettings } from "./config.js";
25
25
  import { resolveWorktreeGitAccess } from "./worktreeGit.js";
26
- import { annotateCommandOutput, makeSandboxSpawnHook, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
26
+ import { annotateCommandOutput, makeSandboxSpawnHook, networkDenialHint, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
27
27
  import { YagniSandboxManager } from "./manager.js";
28
- import { SandboxPanel, buildPanelState } from "./panel.js";
28
+ import { SandboxPanel, buildPanelState, engineeringPresetBlock } from "./panel.js";
29
29
  import { effectiveRules } from "../permissionRules/loadConfig.js";
30
30
  /**
31
31
  * Build the sandbox bash composition: given ANY stock bash definition (pi's
@@ -68,7 +68,20 @@ export function makeBashComposition(manager, settings, cwd) {
68
68
  restrictions.push(allowed.length > 0
69
69
  ? `Network: only these domains (wildcards ok): ${allowed.join(", ")}`
70
70
  : "Network: no domains pre-allowed — the first contact to each host prompts the user");
71
- restrictions.push(`Filesystem writes: working directory, /tmp, and paths granted by Edit(...) allow rules`);
71
+ restrictions.push(`Filesystem writes: working directory, the session temp dir ($TMPDIR), and paths granted by Edit(...) allow rules — /tmp itself is NOT writable; only the per-user temp dir is`);
72
+ if (s.network?.allowLocalBinding) {
73
+ restrictions.push("Localhost: connections and binds to 127.0.0.1/localhost are allowed (local DBs, dev servers, tunnels) — loopback bypasses the domain allowlist by design");
74
+ }
75
+ const hasTmpSockets = (s.network?.allowUnixSockets ?? []).some((e) => e === "$TMPDIR" || e === "$TMPDIR/");
76
+ if (s.network?.allowAllUnixSockets) {
77
+ restrictions.push("Unix sockets: ALL allowed (Linux posture — includes docker.sock; test runners and IPC clients work)");
78
+ }
79
+ else if (hasTmpSockets) {
80
+ restrictions.push("Unix sockets in the temp dir ($TMPDIR): allowed — test runners (tsx, vitest, node --test with process isolation) and other IPC that binds there work sandboxed");
81
+ }
82
+ if (s.enableWeakerNetworkIsolation) {
83
+ restrictions.push("macOS TLS verification (trustd): allowed — Go CLIs (gh, gcloud, terraform) can verify certificates against allowlisted domains");
84
+ }
72
85
  // A linked-worktree session additionally allows the shared common git
73
86
  // dir (git operations work sandboxed there); the description must say
74
87
  // so or the model under-reports what it can do.
@@ -80,9 +93,14 @@ export function makeBashComposition(manager, settings, cwd) {
80
93
  const strictNote = s.allowUnsandboxedCommands === false
81
94
  ? "All commands MUST run sandboxed — dangerouslyDisableSandbox is disabled by policy."
82
95
  : "Default to running commands in the sandbox. Set dangerouslyDisableSandbox: true ONLY with evidence of a sandbox-caused failure (\"Operation not permitted\", denied path/host, unix-socket errors) — the retry still goes through the normal permission flow. The user can adjust restrictions with /sandbox.";
96
+ // Heredoc caveat (verified live): macOS bash 3.2 writes heredoc temp
97
+ // files relative to the CURRENT DIRECTORY, not TMPDIR — so heredocs
98
+ // fail once cwd drifts outside the writable roots. Stated once, after
99
+ // the restrictions, so the model doesn't reach for it blindly.
100
+ const heredocNote = "Caveat: heredocs (<<EOF) write temp files relative to the current directory on macOS bash 3.2 — from an unwritable cwd they fail; prefer printf or redirect from a file in the working directory.";
83
101
  const wrapped = {
84
102
  ...def,
85
- description: `${def.description}\n\n## Command sandbox\nCommands run inside an OS sandbox: ${restrictions.join("; ")}. ${strictNote}`,
103
+ description: `${def.description}\n\n## Command sandbox\nCommands run inside an OS sandbox: ${restrictions.join("; ")}. ${strictNote}\n${heredocNote}`,
86
104
  parameters: schema,
87
105
  async execute(id, params, signal, onUpdate, ctx) {
88
106
  const input = params;
@@ -103,9 +121,14 @@ export function makeBashComposition(manager, settings, cwd) {
103
121
  result = await sandboxBash.execute(id, execParams, signal, onUpdate, ctx);
104
122
  }
105
123
  catch (err) {
106
- if (err instanceof Error && err.message.includes("Operation not permitted")) {
124
+ if (err instanceof Error && (err.message.includes("Operation not permitted") || networkDenialHint(err.message))) {
125
+ // The thrown-message gate matches the result-text gate: a
126
+ // network-signature denial in EITHER surface gets the
127
+ // annotation + hint (node throws lowercase "operation not
128
+ // permitted", which the bare substring misses).
107
129
  const annotated = annotateCommandOutput(manager, input.command, err.message);
108
- throw new Error(annotated);
130
+ logNetworkDenialHint(annotated.net);
131
+ throw new Error(annotated.text);
109
132
  }
110
133
  throw err;
111
134
  }
@@ -114,9 +137,10 @@ export function makeBashComposition(manager, settings, cwd) {
114
137
  }
115
138
  if (result?.content) {
116
139
  const text = result.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
117
- if (text.includes("Operation not permitted")) {
140
+ if (text.includes("Operation not permitted") || networkDenialHint(text)) {
118
141
  const annotated = annotateCommandOutput(manager, input.command, text);
119
- result = { ...result, content: [{ type: "text", text: annotated }] };
142
+ logNetworkDenialHint(annotated.net);
143
+ result = { ...result, content: [{ type: "text", text: annotated.text }] };
120
144
  }
121
145
  }
122
146
  return result;
@@ -198,6 +222,7 @@ export function registerSandbox(pi, opts) {
198
222
  trustedSnapshot = ctx.isProjectTrusted();
199
223
  }
200
224
  catch (err) {
225
+ void err;
201
226
  // Keep the prior snapshot (fail-open parity with the gate) but make
202
227
  // the throw VISIBLE: warn (not debug) — readSessionTrail filters debug
203
228
  // lines, so a debug event would never reach /feedback; a trust-probe
@@ -213,6 +238,13 @@ export function registerSandbox(pi, opts) {
213
238
  };
214
239
  /** Rules filtered to what the sandbox may enforce OS-wide (trust-aware). */
215
240
  const effectiveRulesForSandbox = () => effectiveRules(rules, trustedSnapshot);
241
+ // The network-widening config gate (project-tier knobs dropped when the
242
+ // repo is untrusted) reads the SAME snapshot — applied to the manager on
243
+ // every ctx capture (session_start AND tool_call) so a mid-session trust
244
+ // flip reaches the config gate alongside the rule gate without a reset.
245
+ const applyTrustToManager = () => {
246
+ manager.setTrusted(trustedSnapshot);
247
+ };
216
248
  const sessionDomainGrants = new Set();
217
249
  const askHandler = async (host) => {
218
250
  if (sessionDomainGrants.has(host))
@@ -335,8 +367,19 @@ export function registerSandbox(pi, opts) {
335
367
  if (timeout !== undefined && timeout > 0) {
336
368
  timeoutHandle = setTimeout(() => { timedOut = true; killGroup(); }, timeout * 1000);
337
369
  }
338
- child.stdout?.on("data", onData);
339
- child.stderr?.on("data", onData);
370
+ // Tail buffer (bounded) so a network-posture denial can be
371
+ // annotated + hinted after the child exits — the tool-call path
372
+ // annotates via annotateCommandOutput; the !-command path owns its
373
+ // own composition here (the classifier + hint ride the last 8KB).
374
+ let tail = "";
375
+ const MAX_TAIL = 8 * 1024;
376
+ const onChunk = (d) => {
377
+ const text = d.toString();
378
+ tail = (tail + text).slice(-MAX_TAIL);
379
+ onData(d);
380
+ };
381
+ child.stdout?.on("data", onChunk);
382
+ child.stderr?.on("data", onChunk);
340
383
  signal?.addEventListener("abort", killGroup, { once: true });
341
384
  try {
342
385
  const exitCode = await waitForChildProcess(child);
@@ -344,6 +387,13 @@ export function registerSandbox(pi, opts) {
344
387
  throw new Error("aborted");
345
388
  if (timedOut)
346
389
  throw new Error(`timeout:${timeout}`);
390
+ // Post-run hint: same advisory as the tool path, appended after
391
+ // the child's own output so the user sees the fix pointer inline.
392
+ const net = networkDenialHint(tail);
393
+ if (net) {
394
+ logEvent({ source: "sandbox", level: "info", event: "network_denial_hint", fields: { class: net.cls } });
395
+ onData(Buffer.from(`\n[sandbox] ${net.hint}`));
396
+ }
347
397
  return { exitCode };
348
398
  }
349
399
  finally {
@@ -362,11 +412,15 @@ export function registerSandbox(pi, opts) {
362
412
  pi.on("tool_call", async (_event, ctx) => {
363
413
  captureUi(ctx);
364
414
  captureTrust(ctx);
415
+ // Mid-session trust flips must reach the manager's CONFIG gate too, not
416
+ // just the rule gate — applyTrustToManager is cheap (a boolean set).
417
+ applyTrustToManager();
365
418
  return;
366
419
  });
367
420
  pi.on("session_start", async (_event, ctx) => {
368
421
  captureUi(ctx);
369
422
  captureTrust(ctx);
423
+ applyTrustToManager();
370
424
  currentSettings = load();
371
425
  // --no-sandbox skips init in EVERY mode — headless included. The notify
372
426
  // is UI-gated, the SKIP never is (a UI-only skip would silently keep the
@@ -418,6 +472,15 @@ export function registerSandbox(pi, opts) {
418
472
  lines.push(`deniedDomains: ${s.network.deniedDomains.join(", ")}`);
419
473
  if (s.network?.allowLocalBinding)
420
474
  lines.push("allowLocalBinding: yes (loopback + private ranges)");
475
+ const tmpSocket = (s.network?.allowUnixSockets ?? []).some((e) => e === "$TMPDIR" || e === "$TMPDIR/");
476
+ if (s.network?.allowAllUnixSockets)
477
+ lines.push("allowAllUnixSockets: yes (ALL unix sockets — includes docker.sock)");
478
+ else if (s.network?.allowUnixSockets?.length)
479
+ lines.push(`allowUnixSockets: ${s.network.allowUnixSockets.join(", ")}`);
480
+ if (tmpSocket)
481
+ lines.push(" (the $TMPDIR entry covers test-runner IPC sockets — the Engineering preset's sockets knob)");
482
+ if (s.enableWeakerNetworkIsolation)
483
+ lines.push("enableWeakerNetworkIsolation: yes (trustd allowed — Go TLS verification; opens a trustd exfiltration vector)");
421
484
  if (s.filesystem?.allowWrite?.length)
422
485
  lines.push(`allowWrite: ${s.filesystem.allowWrite.join(", ")}`);
423
486
  if (s.filesystem?.denyWrite?.length)
@@ -608,6 +671,50 @@ export function registerSandbox(pi, opts) {
608
671
  level: "info",
609
672
  };
610
673
  },
674
+ onNetworkSelect: async (choice) => {
675
+ // Knob changes apply LIVE (refreshConfig swaps the runtime config
676
+ // and srt reads the knobs at wrap time per command) — no restart.
677
+ // The bash DESCRIPTION stays stale until restart (load-time text);
678
+ // the denial hint is the mid-session discovery path. Stated in the
679
+ // outcome so the user knows the exact boundary.
680
+ try {
681
+ persistNetworkChoice(choice, opts.cwd);
682
+ }
683
+ catch (err) {
684
+ // Same diagnosability posture as sandbox_persist_failed: the
685
+ // thrown message is mutateConfigJson's own path/reason text —
686
+ // surfaced, never silently swallowed.
687
+ logEvent({
688
+ source: "sandbox",
689
+ level: "warn",
690
+ event: "sandbox_persist_failed",
691
+ fields: {
692
+ kind: "network",
693
+ choice,
694
+ error: err instanceof Error ? err.message : String(err),
695
+ },
696
+ });
697
+ return {
698
+ text: `Could not write the network setting to .yagni-code/config.local.json — edit it directly (see /sandbox docs for the keys) and restart.`,
699
+ level: "error",
700
+ };
701
+ }
702
+ currentSettings = load();
703
+ if (manager.initialized)
704
+ manager.refreshConfig(effectiveRulesForSandbox());
705
+ const applied = choice === "engineering-preset";
706
+ const detail = choice === "engineering-preset"
707
+ ? "tests, gh, and localhost clients now run sandboxed (live — no restart needed; the bash tool's description updates on the next session)"
708
+ : choice === "engineering-preset-off"
709
+ ? "explicit off values written to this project's local settings (local wins over project/user, so higher-tier knob values stop applying); files at other tiers are unchanged"
710
+ : "saved to project-local settings and applied live";
711
+ return {
712
+ text: applied
713
+ ? `✓ Engineering preset applied — ${detail}`
714
+ : `✓ Network setting — ${detail}`,
715
+ level: "info",
716
+ };
717
+ },
611
718
  };
612
719
  let outcome;
613
720
  try {
@@ -674,6 +781,17 @@ export function registerSandbox(pi, opts) {
674
781
  function isPlainRecord(v) {
675
782
  return typeof v === "object" && v !== null && !Array.isArray(v);
676
783
  }
784
+ /** Sink line when the network-posture classifier fires — the impact
785
+ * signal (paired with tool_execute_decision's useSandbox it makes the
786
+ * escape-rate before/after readable from the trail). Closed enum class,
787
+ * no command content. Takes the already-computed classification (the
788
+ * classifier runs ONCE per denial — at the annotation site) so the logged
789
+ * class can never disagree with the hint the user saw. */
790
+ function logNetworkDenialHint(net) {
791
+ if (!net)
792
+ return;
793
+ logEvent({ source: "sandbox", level: "info", event: "network_denial_hint", fields: { class: net.cls } });
794
+ }
677
795
  /** Sink line for a failed domain-grant persist — the thrown message is
678
796
  * mutateConfigJson's own path/reason text (diagnosable, no file content),
679
797
  * same posture as sandbox_persist_failed / rule_save_failed. */
@@ -751,6 +869,82 @@ function persistSandboxOverride(choice, cwd) {
751
869
  config.sandbox = sandbox;
752
870
  }, "sandbox");
753
871
  }
872
+ /** Persist a Network-tab choice (the Engineering preset or a single knob
873
+ * toggle) to the PROJECT-LOCAL config — same destination + honesty contract
874
+ * as the other panel writes. The preset composes the platform-shaped block
875
+ * from panel.ts (one source of truth for the composition) and NORMALIZES
876
+ * the other platform's knobs off (a stale broader door — or a stale trustd
877
+ * value on Linux — never survives a preset apply silently); knob toggles
878
+ * write explicit off values so local precedence overrides any project/user
879
+ * on-state. The $TMPDIR socket entry is REMOVED from the local array (not
880
+ * falsed — arrays have no false), so un-applying the preset leaves the
881
+ * local tier clean; project/user entries keep applying by union. Exported
882
+ * for tests (the per-platform branches are pinned directly). */
883
+ export function persistNetworkChoice(choice, cwd, platform = process.platform) {
884
+ mutateLocalConfig(cwd, (config) => {
885
+ const sandbox = isPlainRecord(config.sandbox) ? { ...config.sandbox } : {};
886
+ const network = isPlainRecord(sandbox.network) ? { ...sandbox.network } : {};
887
+ const sockets = isPlainRecord(network) ? (Array.isArray(network.allowUnixSockets) ? [...network.allowUnixSockets] : []) : [];
888
+ /** Copy-on-write: set/remove the $TMPDIR entry in the local socket
889
+ * array (arrays have no false — removal, not a value write). */
890
+ const setTmpdirSockets = (on) => {
891
+ const withoutTmp = sockets.filter((e) => e !== "$TMPDIR" && e !== "$TMPDIR/");
892
+ network.allowUnixSockets = on ? [...withoutTmp, "$TMPDIR"] : withoutTmp;
893
+ };
894
+ switch (choice) {
895
+ case "engineering-preset": {
896
+ const block = engineeringPresetBlock(platform);
897
+ if (block.enableWeakerNetworkIsolation !== undefined) {
898
+ sandbox.enableWeakerNetworkIsolation = true;
899
+ }
900
+ else if (sandbox.enableWeakerNetworkIsolation !== undefined) {
901
+ // Linux: the preset never touches trustd — a pre-existing local
902
+ // trustd value is NORMALIZED OFF (never silently deleted), so the
903
+ // local file keeps saying what it means.
904
+ sandbox.enableWeakerNetworkIsolation = false;
905
+ }
906
+ if (block.network?.allowUnixSockets) {
907
+ setTmpdirSockets(true);
908
+ // Normalize a pre-existing broader door OFF: the macOS preset is
909
+ // the NARROW shape, and a stale allowAllUnixSockets:true would
910
+ // keep docker.sock open while the panel shows the narrow preset.
911
+ network.allowAllUnixSockets = false;
912
+ }
913
+ if (block.network?.allowAllUnixSockets)
914
+ network.allowAllUnixSockets = true;
915
+ network.allowLocalBinding = true;
916
+ break;
917
+ }
918
+ case "engineering-preset-off":
919
+ sandbox.enableWeakerNetworkIsolation = false;
920
+ network.allowAllUnixSockets = false;
921
+ network.allowLocalBinding = false;
922
+ setTmpdirSockets(false);
923
+ break;
924
+ case "trustd":
925
+ case "trustd-off":
926
+ sandbox.enableWeakerNetworkIsolation = choice === "trustd";
927
+ break;
928
+ case "unix-sockets":
929
+ case "unix-sockets-off": {
930
+ const on = choice === "unix-sockets";
931
+ if (platform === "darwin") {
932
+ setTmpdirSockets(on);
933
+ }
934
+ else {
935
+ network.allowAllUnixSockets = on;
936
+ }
937
+ break;
938
+ }
939
+ case "local-binding":
940
+ case "local-binding-off":
941
+ network.allowLocalBinding = choice === "local-binding";
942
+ break;
943
+ }
944
+ sandbox.network = network;
945
+ config.sandbox = sandbox;
946
+ }, "sandbox");
947
+ }
754
948
  /**
755
949
  * Wait for a spawned child's exit without hanging on inherited stdio —
756
950
  * after exit, release the pipes after a short idle grace so a detached
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.0-staging.1325.1",
3
+ "version": "1.1.0-staging.1327.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": "0ba465bf11818ab831d132603f7dff64e5cf99c5"
61
+ "yagniSourceSha": "82e201e640d0f201b2e2a28a14b4259921086436"
62
62
  }