@yagni-app/code-staging 1.1.2-staging.1368.1 → 1.1.2-staging.1369.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.
@@ -127,13 +127,64 @@ export declare function shouldUseSandboxForUserCommand(command: string, manager:
127
127
  * advisory only, never a grant. Uninitialized manager ⇒ passthrough, no
128
128
  * hint (no annotation context, and the sink is not wired yet).
129
129
  */
130
- export declare function annotateCommandOutput(manager: YagniSandboxManager, command: string, output: string): {
130
+ export declare function annotateCommandOutput(manager: YagniSandboxManager, command: string, output: string, fsScope?: {
131
+ cwd: string;
132
+ scope: FsDenialScope;
133
+ }): {
131
134
  text: string;
132
135
  net: {
133
136
  cls: NetworkDenialClass;
134
137
  hint: string;
135
138
  } | null;
139
+ fs: {
140
+ cls: FsDenialClass;
141
+ hint: string;
142
+ } | null;
136
143
  };
144
+ /** The filesystem denial classes this classifier knows (closed set — rides
145
+ * the sink line as a low-cardinality field, the same contract as
146
+ * NetworkDenialClass). write-scope-protected is a distinct class (not a
147
+ * sub-case) because its REMEDY differs: protected paths can never be
148
+ * admitted with an allow rule, so the hint must not point at the knob. */
149
+ export type FsDenialClass = "write-scope" | "write-scope-protected" | "heredoc-cwd";
150
+ /** The writable/denied root lists the fs classifier needs, passed in by the
151
+ * caller so the classifier stays pure (the established pattern — see
152
+ * networkDenialHint). allowWrite: the sandbox's writable roots (session cwd,
153
+ * the temp dirs, config allowWrite). denyWrite: the protected paths that are
154
+ * denied even when a wider allow would cover them (config files, state dirs). */
155
+ export interface FsDenialScope {
156
+ allowWrite: readonly string[];
157
+ denyWrite: readonly string[];
158
+ }
159
+ /**
160
+ * Classify a sandbox filesystem denial from command + output. Pure;
161
+ * advisory-only (the fs twin of networkDenialHint — never widens anything,
162
+ * the remedy is model-side or a user config edit, never a grant). Matches
163
+ * the signatures observed live and in the session review:
164
+ * - heredoc-cwd: `cannot create temp file for here document` /
165
+ * `/dev/fd/NN: Operation not permitted` — bash 3.2
166
+ * writes heredoc temp files relative to the CURRENT
167
+ * directory, so a cd outside the writable roots makes
168
+ * every heredoc fail even when the target is writable
169
+ * (reproduced live; the e2b /dev/fd/62 case is the
170
+ * process-substitution sibling of the same mechanism)
171
+ * - write-scope: an EPERM whose line names a write TARGET path
172
+ * outside the writable roots — the literal `/tmp`
173
+ * writes observed in the sessions (`cat > /tmp/x`,
174
+ * `> /tmp/after.txt`, python PermissionError on a
175
+ * non-root path)
176
+ * - write-scope-protected: same shape but the target is a PROTECTED path
177
+ * (denyWrite) — the config.json / state-dir denies.
178
+ * Protected paths can never be admitted with allow
179
+ * rules, so the hint copy differs.
180
+ * Returns null when the output carries no fs-denial signature — a network
181
+ * denial must NOT get an fs hint (the pinned negative in networkDenialHint
182
+ * is symmetric here).
183
+ */
184
+ export declare function fsDenialHint(command: string, output: string, cwd: string, scope: FsDenialScope): {
185
+ cls: FsDenialClass;
186
+ hint: string;
187
+ } | null;
137
188
  /** cwd must exist before spawn (guard borrowed from pi's local ops). */
138
189
  export declare function assertSpawnableCwd(cwd: string): void;
139
190
  /**
@@ -21,6 +21,7 @@
21
21
  * lessons (process-group kill, stdio release).
22
22
  */
23
23
  import { existsSync } from "node:fs";
24
+ import { join } from "node:path";
24
25
  import { logEvent } from "../errorSink.js";
25
26
  import { scrubSecrets } from "../pipeline/scrubSecrets.js";
26
27
  function stripLeadingSafeEnvVars(command) {
@@ -347,18 +348,128 @@ export function shouldUseSandboxForUserCommand(command, manager, settings) {
347
348
  * advisory only, never a grant. Uninitialized manager ⇒ passthrough, no
348
349
  * hint (no annotation context, and the sink is not wired yet).
349
350
  */
350
- export function annotateCommandOutput(manager, command, output) {
351
+ export function annotateCommandOutput(manager, command, output, fsScope) {
351
352
  if (!manager.initialized)
352
- return { text: output, net: null };
353
+ return { text: output, net: null, fs: null };
353
354
  const net = networkDenialHint(output);
354
355
  if (!output.includes("Operation not permitted") && !net)
355
- return { text: output, net: null };
356
+ return { text: output, net: null, fs: null };
356
357
  const annotated = output.includes("Operation not permitted")
357
358
  ? manager.annotateStderrWithSandboxFailures(command, output)
358
359
  : output;
359
- if (!net)
360
- return { text: annotated, net: null };
361
- return { text: `${annotated}\n\n[sandbox] ${net.hint}`, net };
360
+ // The fs classification: same regex surface, same composition position,
361
+ // but the NETWORK hint wins when both classify (network denials have a
362
+ // user-flippable knob; fs remedies are model-side). When net wins, fs
363
+ // returns null — the callers log BOTH returns unconditionally, so a
364
+ // non-null loser would emit a second census line for one denial.
365
+ const fs = net
366
+ ? null
367
+ : fsScope
368
+ ? fsDenialHint(command, output, fsScope.cwd, fsScope.scope)
369
+ : null;
370
+ const hint = net ?? fs;
371
+ if (!hint)
372
+ return { text: annotated, net: null, fs: null };
373
+ return { text: `${annotated}\n\n[sandbox] ${hint.hint}`, net, fs };
374
+ }
375
+ /** Is `p` at or under one of the roots? Prefix match on path-segment
376
+ * boundaries (`/var/folders/T` must not admit `/var/folders/T-evil`). */
377
+ function underRoot(p, roots) {
378
+ return roots.some((r) => {
379
+ if (p === r)
380
+ return true;
381
+ return p.startsWith(r.endsWith("/") ? r : r + "/");
382
+ });
383
+ }
384
+ /** Does the command carry a heredoc marker or a process substitution, plus a
385
+ * cd to a directory outside the writable roots? The discriminator for the
386
+ * heredoc-cwd drift shape: the temp-file write follows the shell's CURRENT
387
+ * directory, so a heredoc from inside the roots never has this failure —
388
+ * only the cd-drifted form does. */
389
+ function hasHeredocCwdDrift(command, cwd, scope) {
390
+ if (!/<<|<\(/.test(command))
391
+ return false;
392
+ // find every cd target and require at least one OUTSIDE the writable roots
393
+ const cdRe = /(^|[\n;&|(\s])cd\s+([^\n;&|)\s]+)/g;
394
+ let m;
395
+ while ((m = cdRe.exec(command)) !== null) {
396
+ const target = m[2].replace(/^("|')+|("|')+$/g, "");
397
+ // Resolve the same way a shell would for the classification question:
398
+ // absolute as-is, ~ against HOME (the common drift target), relative
399
+ // against the session cwd (a relative cd stays inside the roots only if
400
+ // the resolved path is under them).
401
+ let resolved = null;
402
+ if (target.startsWith("/"))
403
+ resolved = target;
404
+ else if (target.startsWith("~"))
405
+ resolved = target.replace(/^~/, process.env.HOME ?? "~");
406
+ else if (target !== "-" && !target.startsWith("$"))
407
+ resolved = join(cwd, target);
408
+ if (resolved && !underRoot(resolved, scope.allowWrite))
409
+ return true;
410
+ }
411
+ return false;
412
+ }
413
+ /**
414
+ * Classify a sandbox filesystem denial from command + output. Pure;
415
+ * advisory-only (the fs twin of networkDenialHint — never widens anything,
416
+ * the remedy is model-side or a user config edit, never a grant). Matches
417
+ * the signatures observed live and in the session review:
418
+ * - heredoc-cwd: `cannot create temp file for here document` /
419
+ * `/dev/fd/NN: Operation not permitted` — bash 3.2
420
+ * writes heredoc temp files relative to the CURRENT
421
+ * directory, so a cd outside the writable roots makes
422
+ * every heredoc fail even when the target is writable
423
+ * (reproduced live; the e2b /dev/fd/62 case is the
424
+ * process-substitution sibling of the same mechanism)
425
+ * - write-scope: an EPERM whose line names a write TARGET path
426
+ * outside the writable roots — the literal `/tmp`
427
+ * writes observed in the sessions (`cat > /tmp/x`,
428
+ * `> /tmp/after.txt`, python PermissionError on a
429
+ * non-root path)
430
+ * - write-scope-protected: same shape but the target is a PROTECTED path
431
+ * (denyWrite) — the config.json / state-dir denies.
432
+ * Protected paths can never be admitted with allow
433
+ * rules, so the hint copy differs.
434
+ * Returns null when the output carries no fs-denial signature — a network
435
+ * denial must NOT get an fs hint (the pinned negative in networkDenialHint
436
+ * is symmetric here).
437
+ */
438
+ export function fsDenialHint(command, output, cwd, scope) {
439
+ // heredoc-cwd first (most specific): the temp-file EPERM signatures, gated
440
+ // on the command actually carrying the drift shape.
441
+ if (/(cannot create temp file for here document|\/dev\/fd\/\d+: Operation not permitted)/.test(output) &&
442
+ hasHeredocCwdDrift(command, cwd, scope)) {
443
+ return {
444
+ cls: "heredoc-cwd",
445
+ hint: "This looks like a heredoc temp-file denial — bash writes heredoc temp files relative to the current directory, and a cd outside the writable roots makes every heredoc fail even when the heredoc target is writable. " +
446
+ "Run the heredoc from the working directory (no cd first), or use printf or a file in the working directory instead. Retrying with dangerouslyDisableSandbox is NOT needed.",
447
+ };
448
+ }
449
+ // write-target EPERM: the bash line shape (reusing extractBlockedWritePath's
450
+ // regex family) plus the python PermissionError shape.
451
+ const blocked = extractBlockedWritePath(output) ??
452
+ (output.match(/PermissionError: \[Errno \d+\] Operation not permitted: '([^']+)'/)?.[1] ?? null);
453
+ if (!blocked)
454
+ return null;
455
+ // A target inside the writable roots is NOT a write-scope denial — the
456
+ // scratchpad lives under the temp dir and its writes never hit the fence
457
+ // (the observed non-bug; pinned by test).
458
+ if (underRoot(blocked, scope.allowWrite))
459
+ return null;
460
+ if (underRoot(blocked, scope.denyWrite)) {
461
+ return {
462
+ cls: "write-scope-protected",
463
+ hint: "This looks like a sandbox write to a protected path — this path is denied even with allow rules (it holds the agent's own configuration). " +
464
+ "Do not write here; ask the user or use a different location. Retrying with dangerouslyDisableSandbox will still ask for permission and is the wrong tool for this — the protection is deliberate.",
465
+ };
466
+ }
467
+ return {
468
+ cls: "write-scope",
469
+ hint: "This looks like a sandbox write-scope denial — the target is outside the writable roots (the working directory and $TMPDIR). " +
470
+ "Use $TMPDIR or the project directory for temporary files. To write here anyway, add the path to sandbox.filesystem.allowWrite in your settings (the /sandbox panel's Config tab shows the current scope). " +
471
+ "Retrying with dangerouslyDisableSandbox is usually NOT needed — move the target inside the scope instead.",
472
+ };
362
473
  }
363
474
  /** cwd must exist before spawn (guard borrowed from pi's local ops). */
364
475
  export function assertSpawnableCwd(cwd) {
@@ -28,7 +28,10 @@ import type { PermissionRule } from "../permissionRules/loadConfig.js";
28
28
  * here would be overwritten and the sandbox would silently never reach
29
29
  * model-driven tool calls.
30
30
  */
31
- export declare function makeBashComposition(manager: YagniSandboxManager, settings: () => SandboxSettings, cwd: string, onShellResolutionRetry?: (outcome: "recovered" | "exhausted") => void): (def: ToolDefinition) => ToolDefinition;
31
+ export declare function makeBashComposition(manager: YagniSandboxManager, settings: () => SandboxSettings, cwd: string, onShellResolutionRetry?: (outcome: "recovered" | "exhausted") => void, fsScope?: () => {
32
+ allowWrite: readonly string[];
33
+ denyWrite: readonly string[];
34
+ } | null): (def: ToolDefinition) => ToolDefinition;
32
35
  export interface SandboxSessionHandle {
33
36
  manager: YagniSandboxManager;
34
37
  settings: () => SandboxSettings;
@@ -23,7 +23,7 @@ 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, networkDenialHint, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
26
+ import { annotateCommandOutput, fsDenialHint, makeSandboxSpawnHook, networkDenialHint, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
27
27
  import { YagniSandboxManager } from "./manager.js";
28
28
  import { SandboxPanel, buildPanelState, engineeringPresetBlock } from "./panel.js";
29
29
  import { effectiveRules } from "../permissionRules/loadConfig.js";
@@ -39,7 +39,7 @@ import { effectiveRules } from "../permissionRules/loadConfig.js";
39
39
  * here would be overwritten and the sandbox would silently never reach
40
40
  * model-driven tool calls.
41
41
  */
42
- export function makeBashComposition(manager, settings, cwd, onShellResolutionRetry) {
42
+ export function makeBashComposition(manager, settings, cwd, onShellResolutionRetry, fsScope) {
43
43
  return (def) => {
44
44
  if (!settings().enabled)
45
45
  return def;
@@ -50,6 +50,12 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
50
50
  shellPath: userShellPath,
51
51
  spawnHook,
52
52
  });
53
+ // The fs-denial classification scope (advisory hint only): resolved
54
+ // lazily per annotation — a denial is rare, the merge is not free.
55
+ const fsScopeForAnnotate = () => {
56
+ const scope = fsScope?.();
57
+ return scope ? { cwd, scope } : undefined;
58
+ };
53
59
  const schema = Type.Object({
54
60
  command: Type.String({ description: "The bash command to execute" }),
55
61
  timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
@@ -138,9 +144,11 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
138
144
  // The thrown-message gate matches the result-text gate: a
139
145
  // network-signature denial in EITHER surface gets the
140
146
  // annotation + hint (node throws lowercase "operation not
141
- // permitted", which the bare substring misses).
142
- const annotated = annotateCommandOutput(manager, input.command, err.message);
147
+ // permitted", which the bare substring misses). The fs hint
148
+ // composes under the same gate (one diagnosis, network wins).
149
+ const annotated = annotateCommandOutput(manager, input.command, err.message, fsScopeForAnnotate());
143
150
  logNetworkDenialHint(annotated.net);
151
+ logFsDenialHint(annotated.fs, "thrown");
144
152
  throw new Error(annotated.text);
145
153
  }
146
154
  throw err;
@@ -151,8 +159,9 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
151
159
  if (result?.content) {
152
160
  const text = result.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
153
161
  if (text.includes("Operation not permitted") || networkDenialHint(text)) {
154
- const annotated = annotateCommandOutput(manager, input.command, text);
162
+ const annotated = annotateCommandOutput(manager, input.command, text, fsScopeForAnnotate());
155
163
  logNetworkDenialHint(annotated.net);
164
+ logFsDenialHint(annotated.fs, "result");
156
165
  result = { ...result, content: [{ type: "text", text: annotated.text }] };
157
166
  }
158
167
  }
@@ -179,6 +188,33 @@ export function registerSandbox(pi, opts) {
179
188
  }).settings;
180
189
  let currentSettings = load();
181
190
  let rules = [];
191
+ // The fs-denial classification scope for the advisory hint (the roots
192
+ // the runtime merge resolves: cwd + tmpdir + config allowWrite, denied:
193
+ // the protected paths). Lazy + memo-invalidated per call — a denial is
194
+ // rare and buildRuntimeMerge is not free; re-reading per annotation also
195
+ // picks up rules/settings changes without a cache to invalidate.
196
+ const fsScope = () => {
197
+ if (!manager.initialized)
198
+ return null;
199
+ try {
200
+ const merge = manager.buildRuntimeMerge(rules);
201
+ return { allowWrite: merge.filesystem.allowWrite, denyWrite: merge.filesystem.denyWrite };
202
+ }
203
+ catch (err) {
204
+ // Never the thrown message (it can carry command content) — error
205
+ // class only, same posture as the gate's classify-error line. Without
206
+ // this trace a persistently-throwing merge would silently strip fs
207
+ // hints from all three surfaces and the only symptom would be "hints
208
+ // disappeared" from the census the measurement depends on.
209
+ logEvent({
210
+ source: "sandbox",
211
+ level: "warn",
212
+ event: "fs_scope_error",
213
+ fields: { error: err instanceof Error ? err.constructor.name : typeof err },
214
+ });
215
+ return null; // fail-soft: no scope, no hint (the network hint still rides)
216
+ }
217
+ };
182
218
  // Optional-chained: some harnesses/mocks don't implement flag APIs; the
183
219
  // flag simply reads as unset there. Real pi registers it normally.
184
220
  pi.registerFlag?.("no-sandbox", {
@@ -326,7 +362,7 @@ export function registerSandbox(pi, opts) {
326
362
  logShellResolutionRetry(outcome);
327
363
  opts.onShellResolutionRetry?.(outcome);
328
364
  };
329
- const composeBash = makeBashComposition(manager, () => currentSettings, opts.cwd, onShellResolutionRetry);
365
+ const composeBash = makeBashComposition(manager, () => currentSettings, opts.cwd, onShellResolutionRetry, fsScope);
330
366
  const registerOwnBash = () => {
331
367
  if (!currentSettings.enabled)
332
368
  return;
@@ -409,11 +445,22 @@ export function registerSandbox(pi, opts) {
409
445
  throw new Error(`timeout:${timeout}`);
410
446
  // Post-run hint: same advisory as the tool path, appended after
411
447
  // the child's own output so the user sees the fix pointer inline.
448
+ // One diagnosis per denial: the network hint wins when both
449
+ // classify (network has a user knob; fs remedies are model-side).
450
+ // A null fsScope() (uninitialized manager, merge error) skips the
451
+ // fs classification entirely — an empty-roots fallback would
452
+ // misclassify in-roots and protected targets as write-scope.
412
453
  const net = networkDenialHint(tail);
454
+ const scope = net ? null : fsScope();
455
+ const fs = scope ? fsDenialHint(event.command, tail, opts.cwd, scope) : null;
413
456
  if (net) {
414
457
  logEvent({ source: "sandbox", level: "info", event: "network_denial_hint", fields: { class: net.cls } });
415
458
  onData(Buffer.from(`\n[sandbox] ${net.hint}`));
416
459
  }
460
+ else if (fs) {
461
+ logEvent({ source: "sandbox", level: "info", event: "fs_denial_hint", fields: { class: fs.cls, surface: "user-bash" } });
462
+ onData(Buffer.from(`\n[sandbox] ${fs.hint}`));
463
+ }
417
464
  return { exitCode };
418
465
  }
419
466
  finally {
@@ -825,6 +872,15 @@ function logNetworkDenialHint(net) {
825
872
  return;
826
873
  logEvent({ source: "sandbox", level: "info", event: "network_denial_hint", fields: { class: net.cls } });
827
874
  }
875
+ /** Sink line for the fs-denial hint (same low-cardinality contract as the
876
+ * network one — the before/after escape census reads these lines). The
877
+ * surface field distinguishes the tool-call thrown-error / result-text /
878
+ * !-command call sites in the log stream. */
879
+ function logFsDenialHint(fs, surface) {
880
+ if (!fs)
881
+ return;
882
+ logEvent({ source: "sandbox", level: "info", event: "fs_denial_hint", fields: { class: fs.cls, surface } });
883
+ }
828
884
  /** Sink line for a failed domain-grant persist — the thrown message is
829
885
  * mutateConfigJson's own path/reason text (diagnosable, no file content),
830
886
  * same posture as sandbox_persist_failed / rule_save_failed. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.2-staging.1368.1",
3
+ "version": "1.1.2-staging.1369.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": "a5f338426410c8549a0b85f6e0ac39fd3fdd3e78"
61
+ "yagniSourceSha": "806c992d00925d279ff5aa7beda45682c3875513"
62
62
  }