typeclaw 0.36.0 → 0.36.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.36.0",
3
+ "version": "0.36.1",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -38,6 +38,7 @@ import type {
38
38
  import {
39
39
  buildSandboxedCommand,
40
40
  canMountRealProc,
41
+ commandNeedsRealProc,
41
42
  DEFAULT_SANDBOX_ENV,
42
43
  ensureBwrapAvailable,
43
44
  ensureSessionTmpDir,
@@ -51,6 +52,7 @@ import {
51
52
  resolveProtectedZones,
52
53
  resolveSandboxSymlinks,
53
54
  resolveWritableZones,
55
+ SandboxDegradedProcError,
54
56
  type SandboxProcStrategy,
55
57
  subtractMasked,
56
58
  } from '@/sandbox'
@@ -643,6 +645,15 @@ async function applyBashSandbox(
643
645
  // it would never reach the sandboxed process (the non-sandboxed spawnHook
644
646
  // path does not run when the command is rewritten to a bwrap invocation).
645
647
  const proc = await resolveProcStrategy()
648
+ // Fail fast with an actionable error when /proc degraded to tmpfs AND the
649
+ // command needs a real /proc: under tmpfs Bun would otherwise abort deep in its
650
+ // pipeline with the opaque "NotDir", which the model retries forever. The
651
+ // SandboxDegradedProcError message tells it this is an environment limit, not
652
+ // the command's fault. Guarded on the command so non-bun bash still runs in the
653
+ // degraded mode (it does not touch /proc/self/{fd,maps}).
654
+ if (proc === 'tmpfs' && commandNeedsRealProc(command)) {
655
+ throw new SandboxDegradedProcError()
656
+ }
646
657
  const { commandString } = buildSandboxedCommand(command, {
647
658
  mounts: [
648
659
  { type: 'ro-bind', source: agentDir, dest: agentDir },
@@ -209,12 +209,16 @@ export function canBindProcSafely(options?: { bwrapPath?: string }): Promise<boo
209
209
  }
210
210
 
211
211
  // Default backoff between proc-bind safety re-probes, in ms. Array length = retry
212
- // count (2 retries after the initial attempt = 3 probes total). The probe is
212
+ // count (4 retries after the initial attempt = 5 probes total). The probe is
213
213
  // normally sub-ms; it only returns 'inconclusive' under transient CPU/IO
214
214
  // contention (e.g. a boot-time storm of concurrent LLM calls saturating the box
215
- // and tripping the probe's own timeout), so a short staggered wait lets the spike
216
- // pass before re-proving.
217
- export const PROC_BIND_RETRY_BACKOFF_MS = [250, 1_000] as const
215
+ // and tripping the probe's own timeout), so a staggered wait lets the spike pass
216
+ // before re-proving. Widened from [250,1000] after a deployed container degraded
217
+ // to tmpfs (breaking `bun install`) when a boot-time load spike outlasted the old
218
+ // two-retry budget; the longer tail rides out a sustained spike before failing
219
+ // closed. 'unsafe' still short-circuits with no retry, so this never weakens the
220
+ // leak-block guarantee — it only buys more chances to PROVE it.
221
+ export const PROC_BIND_RETRY_BACKOFF_MS = [250, 1_000, 2_000, 4_000] as const
218
222
 
219
223
  // proc-bind selection must distinguish "definitely unavailable" from "couldn't
220
224
  // verify right now". A DEFINITIVE verdict is final: 'safe'→true; a real userns
@@ -375,9 +379,13 @@ async function probeProcBind(bwrap: string): Promise<ProcBindProbe> {
375
379
  }
376
380
 
377
381
  // Cap on the in-sandbox bwrap probe so a wedged runtime cannot stall the first
378
- // low-trust bash call. The probe normally completes in a few ms; this is a
379
- // generous ceiling, not a tuning knob.
380
- const PROC_BIND_PROBE_TIMEOUT_MS = 5_000
382
+ // low-trust bash call. The probe normally completes in a few ms. Raised from 5s
383
+ // because a deployed container fell to the tmpfs degraded mode (which breaks
384
+ // `bun install` with Bun's opaque NotDir) when this timeout fired under a
385
+ // boot-time storm of concurrent LLM/tool calls — a transient 'inconclusive' that
386
+ // proves nothing about the host. A wider ceiling lets the real probe finish on a
387
+ // briefly-saturated box; a genuinely wedged runtime still trips it and degrades.
388
+ const PROC_BIND_PROBE_TIMEOUT_MS = 12_000
381
389
 
382
390
  // Designated probe-script exit codes. ONLY these two are a cacheable verdict;
383
391
  // every other code (a setup failure, bwrap startup failure, a signal, 127, …) is
@@ -18,3 +18,26 @@ export class SandboxPolicyError extends Error {
18
18
  super(`sandbox policy rejected command: ${reason}`)
19
19
  }
20
20
  }
21
+
22
+ // Raised when the /proc strategy degraded to the empty `tmpfs` fallback AND the
23
+ // command needs a real /proc (a bun install / bunx / bun run that reads the
24
+ // kernel-backed /proc/self/{fd,maps}). Without this pre-check Bun aborts deep in
25
+ // its install pipeline with the opaque "NotDir" (ENOTDIR) error, which the model
26
+ // misreads as a bad package or a transient hiccup and retries forever. Surfacing
27
+ // it here, before the command runs, turns the failure into one the operator (or
28
+ // the model) can act on: it is an environment/runtime limitation, not the
29
+ // command's fault, so retrying the same command on the same container is futile.
30
+ export class SandboxDegradedProcError extends Error {
31
+ override readonly name = 'SandboxDegradedProcError'
32
+ constructor() {
33
+ super(
34
+ 'sandbox /proc is in degraded tmpfs mode, so bun package commands ' +
35
+ '(bun install / bun add / bunx / bun run) cannot run: Bun needs a real ' +
36
+ '/proc/self/fd, which this strategy cannot provide, and would otherwise ' +
37
+ 'fail with an opaque "NotDir" error. This is a container/runtime limitation ' +
38
+ '(no usable user namespaces for the cap-free proc-bind strategy), not a ' +
39
+ 'problem with the command or the package. Retrying the same command will ' +
40
+ 'not help; report it as a sandbox/environment issue.',
41
+ )
42
+ }
43
+ }
@@ -24,10 +24,10 @@ export {
24
24
  type WritableZones,
25
25
  } from './writable-zones'
26
26
  export { resolveSandboxSymlinks, type SandboxSymlinkSpec } from './symlinks'
27
- export { isPackageInstallCommand } from './package-install'
27
+ export { commandNeedsRealProc, isPackageInstallCommand } from './package-install'
28
28
  export { ensureSessionTmpDir, isUnderTmp, mapVirtualTmpPath, SESSION_TMP_ROOT, sessionTmpDir } from './session-tmp'
29
29
  export { formatCommand, shellQuote } from './quote'
30
- export { SandboxPolicyError, SandboxUnavailableError } from './errors'
30
+ export { SandboxDegradedProcError, SandboxPolicyError, SandboxUnavailableError } from './errors'
31
31
  export {
32
32
  DEFAULT_SANDBOX_ENV,
33
33
  type SandboxCommandFilter,
@@ -21,3 +21,38 @@ export function isPackageInstallCommand(command: string): boolean {
21
21
 
22
22
  return !words.some((word) => GLOBAL_FLAG.test(word))
23
23
  }
24
+
25
+ // The bun subcommands whose work (or whose spawned child) reads the kernel-backed
26
+ // /proc/self/{fd,maps} magic symlinks: package installs (add/install/i), the
27
+ // package runners (x/create), and `run` (which can exec a package bin). Under the
28
+ // degraded `tmpfs` /proc strategy those reads return ENOTDIR and Bun aborts with
29
+ // its opaque "NotDir". `bunx` is the bare-binary alias for `bun x`.
30
+ const REAL_PROC_BUN_SUBCOMMANDS = new Set(['add', 'install', 'i', 'x', 'create', 'run'])
31
+
32
+ // Splits a command line at the shell operators that begin a NEW simple command —
33
+ // `&&`, `||`, `;`, `|`, `|&`, `&`, newline — and the subshell opener `(`. A bun
34
+ // invocation after a prelude (`cd app && bun install`, `mkdir a; cd a; bunx foo`)
35
+ // starts a fresh segment, so checking each segment's head catches it where a
36
+ // whole-string `words[0]` check would not. Coarse on purpose: it over-splits
37
+ // inside quotes/`$()`, but this only feeds the DIAGNOSTIC below, never a privilege
38
+ // decision, so a spurious extra segment can only make the error message MORE
39
+ // likely, never widen the sandbox.
40
+ const SHELL_COMMAND_SEPARATOR = /(?:&&|\|\||[;|&\n()])+/
41
+
42
+ // Whether a command will exercise the real /proc, so a caller can turn the
43
+ // degraded-mode `tmpfs` fallback into an actionable diagnostic instead of letting
44
+ // Bun surface its opaque "NotDir". UNLIKE isPackageInstallCommand this is a
45
+ // DIAGNOSTIC heuristic, not a privilege gate — it deliberately fires through shell
46
+ // metacharacters and chained preludes (a `cd app && bunx foo` still runs bunx) and
47
+ // never widens the sandbox surface, so erring toward catching more only improves
48
+ // the error message.
49
+ export function commandNeedsRealProc(command: string): boolean {
50
+ return command.split(SHELL_COMMAND_SEPARATOR).some((segment) => segmentInvokesRealProcBun(segment.trim()))
51
+ }
52
+
53
+ function segmentInvokesRealProcBun(segment: string): boolean {
54
+ if (segment === 'bunx' || segment.startsWith('bunx ')) return true
55
+ const words = segment.split(/\s+/)
56
+ if (words[0] !== 'bun') return false
57
+ return words[1] !== undefined && REAL_PROC_BUN_SUBCOMMANDS.has(words[1])
58
+ }