typeclaw 0.34.1 → 0.35.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.
Files changed (39) hide show
  1. package/package.json +3 -1
  2. package/src/agent/plugin-tools.ts +71 -13
  3. package/src/agent/provider-error.ts +10 -0
  4. package/src/agent/session-origin.ts +26 -0
  5. package/src/agent/tools/channel-disengage.ts +13 -9
  6. package/src/bundled-plugins/github-cli-auth/gh-command.ts +124 -6
  7. package/src/bundled-plugins/github-cli-auth/git-command.ts +172 -26
  8. package/src/bundled-plugins/github-cli-auth/index.ts +46 -7
  9. package/src/bundled-plugins/github-cli-auth/token-class.ts +13 -0
  10. package/src/bundled-plugins/security/policies/prompt-injection.ts +33 -2
  11. package/src/channels/adapters/github/inbound.ts +41 -3
  12. package/src/channels/adapters/slack-bot.ts +17 -9
  13. package/src/channels/continuation-willingness.ts +331 -0
  14. package/src/channels/github-review-claim.ts +105 -0
  15. package/src/channels/github-token-bridge.ts +7 -0
  16. package/src/channels/router.ts +103 -24
  17. package/src/cli/channel.ts +102 -11
  18. package/src/cli/qr.ts +130 -0
  19. package/src/config/config.ts +98 -2
  20. package/src/container/start.ts +12 -0
  21. package/src/init/dockerfile.ts +64 -0
  22. package/src/init/line-auth.ts +8 -3
  23. package/src/inspect/live.ts +128 -13
  24. package/src/plugin/context.ts +5 -1
  25. package/src/plugin/manager.ts +2 -0
  26. package/src/plugin/types.ts +1 -0
  27. package/src/run/index.ts +1 -0
  28. package/src/sandbox/availability.ts +87 -19
  29. package/src/sandbox/build.ts +27 -0
  30. package/src/sandbox/index.ts +10 -0
  31. package/src/sandbox/package-install.ts +23 -0
  32. package/src/sandbox/policy.ts +31 -0
  33. package/src/sandbox/symlinks.ts +34 -0
  34. package/src/sandbox/writable-zones.ts +164 -4
  35. package/src/server/index.ts +5 -1
  36. package/src/shared/protocol.ts +22 -11
  37. package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
  38. package/src/skills/typeclaw-github-contributing/SKILL.md +124 -0
  39. package/typeclaw.schema.json +32 -1
@@ -138,6 +138,27 @@ export function _resetRealProcProbeCacheForTests(): void {
138
138
  // future bwrap flag change, would turn this strategy into a secret leak. So we
139
139
  // PROBE it directly before ever selecting it — plant a real secret in a sibling
140
140
  // process's env and assert the sandbox cannot read it back.
141
+ // The probe has THREE outcomes, not two — collapsing them to a boolean is what
142
+ // caused the silent-degrade bug this verdict type fixes. 'safe'/'unsafe' are definitive capability
143
+ // facts (the userns block held / a leak was observed); 'inconclusive' is a
144
+ // transient local failure (probe timeout under CPU/IO contention, sentinel dying
145
+ // mid-probe, a bwrap startup hiccup) that proves NOTHING about the host. A caller
146
+ // deciding the /proc strategy must tell these apart: an inconclusive probe must
147
+ // trigger a RETRY, never a fall-through to tmpfs that breaks the whole bash call
148
+ // on a host that is actually capable. 'unsafe' must still fail closed with no
149
+ // retry. canBindProcSafely() keeps the old boolean shape for callers that only
150
+ // need "is proc-bind selectable right now"; getProcBindSafetyVerdict() exposes
151
+ // the third state for the retry-owning strategy resolver.
152
+ export type ProcBindSafetyVerdict = 'safe' | 'unsafe' | 'inconclusive'
153
+
154
+ // Only DEFINITIVE verdicts are process-global facts worth caching. Caching
155
+ // 'inconclusive' (i.e. its boolean `false`) would PERMANENTLY disable proc-bind
156
+ // for the process — a single slow first bash call would silently break every
157
+ // later bunx until container restart (the exact "works after restart" symptom
158
+ // this whole machinery exists to kill). So the cache type structurally excludes
159
+ // it.
160
+ type CacheableProcBindSafetyVerdict = Exclude<ProcBindSafetyVerdict, 'inconclusive'>
161
+
141
162
  // Keyed by resolved bwrapPath, like ensureBwrapAvailable: the safety answer is a
142
163
  // fact about a SPECIFIC bwrap binary, so a caller pinning a non-default path
143
164
  // (tests, or a future deployment) must re-probe rather than inherit the default
@@ -145,19 +166,21 @@ export function _resetRealProcProbeCacheForTests(): void {
145
166
  // concurrent first callers for one path share a single probe. Both cached
146
167
  // process-globally (the answer is a per-container capability fact). Not abortable
147
168
  // (see canMountRealProc).
148
- const procBindProbeCache = new Map<string, boolean>()
149
- const procBindProbeInFlight = new Map<string, Promise<boolean>>()
150
-
151
- // `safe` is the answer; `cacheable` is false for INCONCLUSIVE outcomes (a probe
152
- // timeout under load, or the sentinel dying mid-probe). Those are transient
153
- // failure modes, not capability facts, so caching their `safe=false` would
154
- // PERMANENTLY disable proc-bind for the process — a single slow first bash call
155
- // would silently break every later bunx until container restart (the exact
156
- // "works after restart" symptom this whole fix exists to kill). Only a probe that
157
- // ran to a verdict (definitively safe OR definitively leaking) is cached.
158
- type ProcBindProbe = { safe: boolean; cacheable: boolean }
169
+ const procBindProbeCache = new Map<string, CacheableProcBindSafetyVerdict>()
170
+ const procBindProbeInFlight = new Map<string, Promise<ProcBindSafetyVerdict>>()
159
171
 
160
- export function canBindProcSafely(options?: { bwrapPath?: string }): Promise<boolean> {
172
+ // `verdict` is the answer; only definitive verdicts are `cacheable`. INCONCLUSIVE
173
+ // outcomes (a probe timeout under load, or the sentinel dying mid-probe) are
174
+ // transient failure modes, not capability facts — see the cache rationale above.
175
+ type ProcBindProbe =
176
+ | { verdict: CacheableProcBindSafetyVerdict; cacheable: true }
177
+ | { verdict: 'inconclusive'; cacheable: false }
178
+
179
+ // The three-state probe, deduped + cached like canBindProcSafely. The strategy
180
+ // resolver (resolveProcStrategy in plugin-tools.ts) consumes this so it can RETRY
181
+ // an 'inconclusive' result before degrading the bash call to tmpfs, while still
182
+ // failing closed on 'unsafe'.
183
+ export function getProcBindSafetyVerdict(options?: { bwrapPath?: string }): Promise<ProcBindSafetyVerdict> {
161
184
  const bwrap = options?.bwrapPath ?? 'bwrap'
162
185
  const cached = procBindProbeCache.get(bwrap)
163
186
  if (cached !== undefined) return Promise.resolve(cached)
@@ -165,9 +188,9 @@ export function canBindProcSafely(options?: { bwrapPath?: string }): Promise<boo
165
188
  if (existing !== undefined) return existing
166
189
 
167
190
  const promise = probeProcBind(bwrap)
168
- .then(({ safe, cacheable }) => {
169
- if (cacheable) procBindProbeCache.set(bwrap, safe)
170
- return safe
191
+ .then(({ verdict, cacheable }) => {
192
+ if (cacheable) procBindProbeCache.set(bwrap, verdict)
193
+ return verdict
171
194
  })
172
195
  .finally(() => {
173
196
  procBindProbeInFlight.delete(bwrap)
@@ -176,9 +199,53 @@ export function canBindProcSafely(options?: { bwrapPath?: string }): Promise<boo
176
199
  return promise
177
200
  }
178
201
 
202
+ // Boolean convenience wrapper: 'safe' is the ONLY verdict that makes proc-bind
203
+ // selectable. 'unsafe' AND 'inconclusive' both map to false — callers that only
204
+ // take a boolean (and do not own a retry budget) must fail closed on either.
205
+ // Derives from the deduped verdict probe, so concurrent callers still share one
206
+ // spawn even though this wrapper's own promise identity differs per call.
207
+ export function canBindProcSafely(options?: { bwrapPath?: string }): Promise<boolean> {
208
+ return getProcBindSafetyVerdict(options).then((verdict) => verdict === 'safe')
209
+ }
210
+
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
213
+ // normally sub-ms; it only returns 'inconclusive' under transient CPU/IO
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
218
+
219
+ // proc-bind selection must distinguish "definitely unavailable" from "couldn't
220
+ // verify right now". A DEFINITIVE verdict is final: 'safe'→true; a real userns
221
+ // leak ('unsafe')→false with NO retry. Only an 'inconclusive' verdict (transient
222
+ // probe failure that proves nothing about the host) is retried, because degrading
223
+ // the bash call to tmpfs over a transient hiccup is what silently broke
224
+ // external-package runs on capable hosts. 'inconclusive' is never cached
225
+ // (see the cache type), so each retry re-probes from scratch. After the backoff
226
+ // budget is exhausted we fail CLOSED — an unverified leak-block is never treated
227
+ // as safe. Pure and dependency-injected (probe + sleep) so the retry policy is
228
+ // unit-testable without spawning processes; production passes
229
+ // getProcBindSafetyVerdict and Bun.sleep.
230
+ export async function resolveProcBindSafetyWithRetry(
231
+ probe: () => Promise<ProcBindSafetyVerdict>,
232
+ sleep: (ms: number) => Promise<void>,
233
+ backoffMs: readonly number[] = PROC_BIND_RETRY_BACKOFF_MS,
234
+ ): Promise<boolean> {
235
+ for (let attempt = 0; ; attempt++) {
236
+ const verdict = await probe()
237
+ if (verdict === 'safe') return true
238
+ if (verdict === 'unsafe') return false
239
+
240
+ const backoff = backoffMs[attempt]
241
+ if (backoff === undefined) return false
242
+ await sleep(backoff)
243
+ }
244
+ }
245
+
179
246
  const PROC_BIND_PROBE_SECRET = 'TYPECLAW_PROCBIND_PROBE_SECRET'
180
247
 
181
- const INCONCLUSIVE: ProcBindProbe = { safe: false, cacheable: false }
248
+ const INCONCLUSIVE: ProcBindProbe = { verdict: 'inconclusive', cacheable: false }
182
249
 
183
250
  async function probeProcBind(bwrap: string): Promise<ProcBindProbe> {
184
251
  // The sentinel must model the REAL threat geometry: the agent runtime holds
@@ -277,13 +344,13 @@ async function probeProcBind(bwrap: string): Promise<ProcBindProbe> {
277
344
  // "non-zero" — a non-zero exit also covers script setup failures (a bwrap that
278
345
  // started but couldn't read /proc/self/fd), bwrap startup failures (missing
279
346
  // lib, transient mount EBUSY → bwrap's own exit), and an external SIGKILL.
280
- // Caching any of those transient failures as a definitive safe=false would
347
+ // Caching any of those transient failures as a definitive 'unsafe' would
281
348
  // PERMANENTLY disable proc-bind — the same cache-poisoning class as the
282
349
  // timeout bug. So only the script's two designated codes are cacheable:
283
350
  // PROC_BIND_SAFE (clean run, every open blocked) and PROC_BIND_LEAK (an open
284
351
  // SUCCEEDED — a real leak). Setup failures use PROC_BIND_SETUP_FAILED, and any
285
352
  // other code (bwrap startup, signals, 127) is treated as inconclusive.
286
- if (proc.exitCode === PROC_BIND_LEAK) return { safe: false, cacheable: true }
353
+ if (proc.exitCode === PROC_BIND_LEAK) return { verdict: 'unsafe', cacheable: true }
287
354
  if (proc.exitCode !== PROC_BIND_SAFE) return INCONCLUSIVE
288
355
  // Final liveness: the in-sandbox blocked-open assertions are only meaningful
289
356
  // if the sentinel was alive throughout. Re-read its MARKER from the PARENT —
@@ -293,12 +360,13 @@ async function probeProcBind(bwrap: string): Promise<ProcBindProbe> {
293
360
  // kernel liveness, so this marker re-read is the stronger postcondition. A
294
361
  // failure here means the sentinel vanished mid-probe → inconclusive.
295
362
  if (!(await parentReadsSentinelMarker(sentinelPid))) return INCONCLUSIVE
296
- return { safe: true, cacheable: true }
363
+ return { verdict: 'safe', cacheable: true }
297
364
  } catch {
298
365
  return INCONCLUSIVE
299
366
  } finally {
300
367
  try {
301
368
  sentinel?.kill()
369
+ await sentinel?.exited.catch(() => {})
302
370
  } catch {
303
371
  // killing an already-exited sentinel can throw on some runtimes; cleanup
304
372
  // must never propagate out of the probe.
@@ -1,3 +1,5 @@
1
+ import { posix } from 'node:path'
2
+
1
3
  import { SandboxPolicyError } from './errors'
2
4
  import {
3
5
  DEFAULT_SANDBOX_ENV,
@@ -8,6 +10,8 @@ import {
8
10
  } from './policy'
9
11
  import { formatCommand } from './quote'
10
12
 
13
+ const { dirname } = posix
14
+
11
15
  export type SandboxedCommand = {
12
16
  argv: string[]
13
17
  commandString: string
@@ -163,9 +167,11 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
163
167
  appendMount(argv, mount)
164
168
  }
165
169
 
170
+ appendWritableRoot(argv, policy)
166
171
  appendMasks(argv, policy)
167
172
  appendWritable(argv, policy)
168
173
  appendProtected(argv, policy)
174
+ appendSymlinks(argv, policy)
169
175
 
170
176
  if (policy.cwd !== undefined) {
171
177
  argv.push('--chdir', policy.cwd)
@@ -175,6 +181,15 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
175
181
  return argv
176
182
  }
177
183
 
184
+ // Renders BEFORE appendMasks so the broad RW root is overridden by the secret
185
+ // masks and protected re-binds that follow (last-op-wins). See
186
+ // SandboxWritableRootPolicy for the full ordering contract.
187
+ function appendWritableRoot(argv: string[], policy: SandboxPolicy): void {
188
+ if (policy.writableRoot !== undefined) {
189
+ argv.push('--bind', policy.writableRoot.dir, policy.writableRoot.dir)
190
+ }
191
+ }
192
+
178
193
  function appendMasks(argv: string[], policy: SandboxPolicy): void {
179
194
  for (const dir of policy.masks?.dirs ?? []) {
180
195
  argv.push('--tmpfs', dir)
@@ -202,6 +217,18 @@ function appendProtected(argv: string[], policy: SandboxPolicy): void {
202
217
  }
203
218
  }
204
219
 
220
+ // Rendered after every bind (incl. the /tmp session bind in policy.mounts) so
221
+ // last-op-wins keeps the symlink: a `/tmp/.foo` dest emitted before the /tmp
222
+ // bind would be erased by it. `--dir` ensures the symlink's parent exists inside
223
+ // the jail (the sandbox HOME dir may not be present after --clearenv tmpfs
224
+ // scaffolding); `--symlink TARGET DEST` then creates `dest -> target`.
225
+ function appendSymlinks(argv: string[], policy: SandboxPolicy): void {
226
+ for (const link of policy.symlinks ?? []) {
227
+ argv.push('--dir', dirname(link.dest))
228
+ argv.push('--symlink', link.target, link.dest)
229
+ }
230
+ }
231
+
205
232
  function appendMount(argv: string[], mount: SandboxMount): void {
206
233
  switch (mount.type) {
207
234
  case 'ro-bind':
@@ -4,19 +4,27 @@ export {
4
4
  canBindProcSafely,
5
5
  canMountRealProc,
6
6
  ensureBwrapAvailable,
7
+ getProcBindSafetyVerdict,
8
+ PROC_BIND_RETRY_BACKOFF_MS,
9
+ resolveProcBindSafetyWithRetry,
7
10
  resolveProcSelfExe,
11
+ type ProcBindSafetyVerdict,
8
12
  _resetBwrapAvailabilityCacheForTests,
9
13
  _resetProcBindProbeCacheForTests,
10
14
  _resetRealProcProbeCacheForTests,
11
15
  } from './availability'
12
16
  export { resolveHiddenPaths, type HiddenPaths } from './hidden-paths'
13
17
  export {
18
+ resolvePackageInstallZones,
14
19
  resolveProtectedZones,
15
20
  resolveWritableZones,
16
21
  subtractMasked,
22
+ type PackageInstallZones,
17
23
  type ProtectedZones,
18
24
  type WritableZones,
19
25
  } from './writable-zones'
26
+ export { resolveSandboxSymlinks, type SandboxSymlinkSpec } from './symlinks'
27
+ export { isPackageInstallCommand } from './package-install'
20
28
  export { ensureSessionTmpDir, isUnderTmp, mapVirtualTmpPath, SESSION_TMP_ROOT, sessionTmpDir } from './session-tmp'
21
29
  export { formatCommand, shellQuote } from './quote'
22
30
  export { SandboxPolicyError, SandboxUnavailableError } from './errors'
@@ -30,5 +38,7 @@ export {
30
38
  type SandboxProcessPolicy,
31
39
  type SandboxProcStrategy,
32
40
  type SandboxProtectedPolicy,
41
+ type SandboxSymlinkOp,
33
42
  type SandboxWritablePolicy,
43
+ type SandboxWritableRootPolicy,
34
44
  } from './policy'
@@ -0,0 +1,23 @@
1
+ // Recognizes the narrow command class that earns the package-install sandbox
2
+ // mode (RW project root). Deliberately conservative: a single standalone local
3
+ // `bun add` / `bun install` / `bun i` with NO shell metacharacters, chaining,
4
+ // redirects, or substitution. Anything fancier (`bun add x && rm -rf …`,
5
+ // `bun add x; curl …`, a subshell, a pipe) falls back to the default ro-root
6
+ // jail so the broad RW root can never be piggybacked onto an attacker-controlled
7
+ // second command. Global installs (`-g` / `--global`) are excluded — the
8
+ // bun-hygiene guard already blocks them and they write outside the jail anyway.
9
+ const SHELL_METACHARACTERS = /[;&|`$()<>\\\n\r{}!*?[\]"']/
10
+
11
+ const GLOBAL_FLAG = /^(-g|--global)$/
12
+
13
+ export function isPackageInstallCommand(command: string): boolean {
14
+ if (SHELL_METACHARACTERS.test(command)) return false
15
+
16
+ const words = command.trim().split(/\s+/)
17
+ if (words[0] !== 'bun') return false
18
+
19
+ const subcommand = words[1]
20
+ if (subcommand !== 'add' && subcommand !== 'install' && subcommand !== 'i') return false
21
+
22
+ return !words.some((word) => GLOBAL_FLAG.test(word))
23
+ }
@@ -83,6 +83,35 @@ export type SandboxProtectedPolicy = {
83
83
  files?: string[]
84
84
  }
85
85
 
86
+ // Symlinks recreated INSIDE the jail so a CLI that reads a fixed path (e.g.
87
+ // `$HOME/.metabase-cli`) resolves to a writable target under /agent. `dest` is
88
+ // the symlink location resolved against the SANDBOX HOME (/tmp), `target` is the
89
+ // absolute /agent path it points at. Rendered last (after the /tmp bind and all
90
+ // writable binds) so last-op-wins keeps the symlink — a `/tmp/...` dest emitted
91
+ // before the /tmp bind would be erased by it.
92
+ export type SandboxSymlinkOp = {
93
+ target: string
94
+ dest: string
95
+ }
96
+
97
+ // A single RW bind of the project root, used ONLY by the package-install path
98
+ // (recognized standalone `bun add`/`bun install` commands). `bun add` writes
99
+ // node_modules/ AND a temp lockfile (`bun.lock.NNN.tmp`, atomically renamed)
100
+ // directly under the root, so a file-level RW bind of `bun.lock` alone is
101
+ // insufficient — Bun needs DIRECTORY write to create its temp file. The default
102
+ // ro-root + narrow carve-out model can't express that, so this widens the root
103
+ // to RW for that command class only.
104
+ //
105
+ // CRITICAL ordering: unlike `writable` (rendered AFTER masks), `writableRoot`
106
+ // renders BEFORE masks so the broad RW root does not re-expose secrets. With
107
+ // last-op-wins the chain is: ro-bind root → writableRoot (RW root) → masks
108
+ // (re-hide .env/secrets.json/private dirs) → protected (re-RO node_modules/typeclaw,
109
+ // packages, .agents/skills, .git/hooks, .git/config). Everything stays hidden or
110
+ // EROFS except the dirs a dependency install legitimately needs to write.
111
+ export type SandboxWritableRootPolicy = {
112
+ dir: string
113
+ }
114
+
86
115
  export type SandboxPolicy = {
87
116
  bwrapPath?: string
88
117
  cwd?: string
@@ -95,9 +124,11 @@ export type SandboxPolicy = {
95
124
  // the builder stays pure.
96
125
  procSelfExe?: string
97
126
  mounts?: SandboxMount[]
127
+ writableRoot?: SandboxWritableRootPolicy
98
128
  masks?: SandboxMaskPolicy
99
129
  writable?: SandboxWritablePolicy
100
130
  protected?: SandboxProtectedPolicy
131
+ symlinks?: SandboxSymlinkOp[]
101
132
  network?: SandboxNetwork
102
133
  env?: SandboxEnvPolicy
103
134
  commandFilter?: SandboxCommandFilter
@@ -0,0 +1,34 @@
1
+ import { posix } from 'node:path'
2
+
3
+ import type { SandboxSymlinkOp } from './policy'
4
+
5
+ const { isAbsolute, join, normalize } = posix
6
+
7
+ export type SandboxSymlinkSpec = {
8
+ from: string
9
+ to: string
10
+ }
11
+
12
+ // Resolves config `sandbox.symlinks` into the in-jail `--symlink` ops the bwrap
13
+ // builder consumes. `from` is the symlink LOCATION: a `~/`-prefixed `from` is
14
+ // expanded against the SANDBOX HOME (`/tmp`, where the per-session tmp dir is
15
+ // bound), NOT the container's real `/root` — inside the jail a CLI reading
16
+ // `$HOME/.foo` looks under `/tmp`, so the symlink must live there. An absolute
17
+ // `from` is used verbatim. `to` is resolved to the absolute /agent path the
18
+ // symlink points at. Container paths are always POSIX, so this uses posix path
19
+ // ops regardless of the dev-stage host OS.
20
+ export function resolveSandboxSymlinks(
21
+ agentDir: string,
22
+ specs: readonly SandboxSymlinkSpec[],
23
+ sandboxHome: string,
24
+ ): SandboxSymlinkOp[] {
25
+ return specs.map((spec) => ({
26
+ target: join(agentDir, spec.to),
27
+ dest: resolveSymlinkDest(spec.from, sandboxHome),
28
+ }))
29
+ }
30
+
31
+ function resolveSymlinkDest(from: string, home: string): string {
32
+ if (from.startsWith('~/')) return join(home, from.slice(2))
33
+ return isAbsolute(from) ? normalize(from) : join(home, from)
34
+ }
@@ -1,4 +1,4 @@
1
- import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
1
+ import { lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'
2
2
  import path, { isAbsolute, join, resolve } from 'node:path'
3
3
 
4
4
  export type WritableZones = {
@@ -35,6 +35,25 @@ export type ProtectedZones = {
35
35
  // exactly that escalation.
36
36
  const WRITABLE_DIRS = ['workspace', 'public', 'mounts', '.git'] as const
37
37
 
38
+ // SECURITY: configured writable paths (`sandbox.writablePaths`) may NOT resolve
39
+ // onto these. `.git` carries the hook/config escalation surface; `.env` and
40
+ // `secrets.json` are the credential files; `sessions`/`memory` are the agent's
41
+ // private surface (masked from low-trust roles by hidden-paths); `.typeclaw`
42
+ // holds system-managed home persistence; `node_modules` is executable
43
+ // dependency code. Granting blanket RW to any of these via config would defeat
44
+ // the very guards the narrow built-in set exists to preserve. The agent root
45
+ // itself is also rejected (a writablePaths of '' or '.') — an RW bind of the
46
+ // whole tree erases the read-only confinement wholesale.
47
+ const FORBIDDEN_WRITABLE_ROOTS = [
48
+ '.git',
49
+ '.env',
50
+ 'secrets.json',
51
+ 'sessions',
52
+ 'memory',
53
+ '.typeclaw',
54
+ 'node_modules',
55
+ ] as const
56
+
38
57
  const PROTECTED_GIT_DIRS = ['.git/hooks'] as const
39
58
  const PROTECTED_GIT_FILES = ['.git/config'] as const
40
59
 
@@ -54,16 +73,157 @@ const WRITABLE_ROOT_FILES = [
54
73
  // so a `workspace -> /etc` symlink at a zone root would grant write access to an
55
74
  // outside path. (Symlinks INSIDE a real zone are already safe — the kernel
56
75
  // resolves them to the read-only parent mount.)
57
- export async function resolveWritableZones(agentDir: string): Promise<WritableZones> {
58
- const dirs = await collectExisting(
76
+ //
77
+ // `configuredWritablePaths` are operator-chosen agent-relative dirs from
78
+ // `sandbox.writablePaths`. They join the built-in dirs through the SAME
79
+ // existence + symlink filter, plus the extra guardrails in
80
+ // `resolveConfiguredWritableDirs`: each must resolve inside agentDir and must
81
+ // not land on a forbidden root. A path that fails any check is dropped, never
82
+ // throws — a stale config should degrade the one bad entry, not abort sandboxing.
83
+ export async function resolveWritableZones(
84
+ agentDir: string,
85
+ configuredWritablePaths: readonly string[] = [],
86
+ ): Promise<WritableZones> {
87
+ const builtinDirs = await collectExisting(
59
88
  WRITABLE_DIRS.map((d) => join(agentDir, d)),
60
89
  'dir',
61
90
  )
91
+ const configuredDirs = await resolveConfiguredWritableDirs(agentDir, configuredWritablePaths)
62
92
  const files = await collectExisting(
63
93
  WRITABLE_ROOT_FILES.map((f) => join(agentDir, f)),
64
94
  'file',
65
95
  )
66
- return { dirs, files }
96
+ return { dirs: dedupe([...builtinDirs, ...configuredDirs]), files }
97
+ }
98
+
99
+ // SECURITY: validation is on the REAL path, not the lexical one. A lexical-only
100
+ // check (resolve + isInside) is bypassable by a symlinked INTERMEDIATE component:
101
+ // with `/agent/alias -> /tmp/outside` (or `-> /agent/sessions`) and a config of
102
+ // `alias/sub`, the lexical path `/agent/alias/sub` passes isInside and the
103
+ // forbidden-root check, while the bwrap `--bind` follows the ancestor symlink to
104
+ // write outside /agent (or onto a forbidden root). The zone-root lstat alone
105
+ // can't see it — lstat of the final component follows ancestor symlinks. So we
106
+ // realpath BOTH the candidate and agentDir (+ the forbidden roots) and validate
107
+ // the resolved targets. A path whose real form escapes agentDir or lands on a
108
+ // real forbidden root is dropped. realpath also rejects the final component
109
+ // being a symlink (its real target is re-checked), subsuming the prior lstat.
110
+ async function resolveConfiguredWritableDirs(agentDir: string, configured: readonly string[]): Promise<string[]> {
111
+ const realAgentDir = await realpathOrUndefined(agentDir)
112
+ if (realAgentDir === undefined) return []
113
+ const realForbidden = await resolveRealForbiddenRoots(agentDir)
114
+
115
+ const accepted: string[] = []
116
+ for (const rel of configured) {
117
+ const absolute = resolve(agentDir, rel)
118
+ // Cheap lexical pre-filter: reject obvious escapes before touching the disk.
119
+ if (absolute === agentDir || !isInside(agentDir, absolute)) continue
120
+ const real = await realpathOrUndefined(absolute)
121
+ if (real === undefined) continue
122
+ if (!(await isRealEntry(real, 'dir'))) continue
123
+ if (real === realAgentDir || !isInside(realAgentDir, real)) continue
124
+ if (realForbidden.some((root) => real === root || isInside(root, real))) continue
125
+ // Bind the lexical (caller-facing) path; bwrap resolves it to `real` itself.
126
+ accepted.push(absolute)
127
+ }
128
+ return accepted
129
+ }
130
+
131
+ async function resolveRealForbiddenRoots(agentDir: string): Promise<string[]> {
132
+ const resolved: string[] = []
133
+ for (const root of FORBIDDEN_WRITABLE_ROOTS) {
134
+ const real = await realpathOrUndefined(join(agentDir, root))
135
+ if (real !== undefined) resolved.push(real)
136
+ }
137
+ return resolved
138
+ }
139
+
140
+ async function realpathOrUndefined(target: string): Promise<string | undefined> {
141
+ try {
142
+ return await realpath(target)
143
+ } catch {
144
+ return undefined
145
+ }
146
+ }
147
+
148
+ function dedupe(values: string[]): string[] {
149
+ return [...new Set(values)]
150
+ }
151
+
152
+ export type PackageInstallZones = {
153
+ root: string
154
+ protected: ProtectedZones
155
+ }
156
+
157
+ // SECURITY: the package-install RW root is governed by an ALLOWLIST, not a
158
+ // denylist. `bun add` writes exactly these and nothing else: `node_modules/`
159
+ // (deps), `package.json` + `bun.lock` (manifest + lockfile, plus the temp
160
+ // lockfile created in the root DIR). The scratch zones (`workspace`, `public`,
161
+ // `mounts`) stay writable to match the normal jail. EVERY other existing root
162
+ // entry is RO-bound, so a denylist of "executable/runtime-sensitive" paths is
163
+ // not needed — it would be unbounded (any file the unsandboxed runtime reads or
164
+ // execs, including `src/`/`scripts/` in dev-mode agents where typeclaw is a
165
+ // file:/link: dep, the agent's own lifecycle scripts, and prompt-source files)
166
+ // and fails OPEN for any root entry not yet listed. An allowlist fails CLOSED.
167
+ const PACKAGE_INSTALL_WRITABLE_DIRS = ['node_modules', 'workspace', 'public', 'mounts'] as const
168
+ const PACKAGE_INSTALL_WRITABLE_FILES = ['package.json', 'bun.lock'] as const
169
+
170
+ // Resolves the jail layout for a recognized standalone dependency install
171
+ // (`bun add` / `bun install`). The RW root lets bun create node_modules/ and its
172
+ // temp lockfile (`bun.lock.NNN.tmp`, renamed) — a file-level bind of `bun.lock`
173
+ // alone cannot, since the temp file needs DIRECTORY write. Pre-creates an empty
174
+ // node_modules/ so the dir exists before the RW root bind. Then RO-binds every
175
+ // EXISTING root entry not in the writable allowlist (readdir enumeration, so a
176
+ // new file like `src/` or a planted `cron.json` is covered without a hardcoded
177
+ // list), plus `node_modules/typeclaw` (the live/symlinked runtime, nested under
178
+ // the writable node_modules) and the whole `.git` (a `bun add` never needs git,
179
+ // so RO-binding it wholesale is simpler and safer than the hooks/config carve-out
180
+ // — it closes the hook / core.hooksPath escalation by construction).
181
+ //
182
+ // SECURITY: rejects a symlink at agentDir, at any install-touched path
183
+ // (node_modules, package.json, bun.lock), and at every RO-bind source — an RW
184
+ // root or an RO bind that follows a symlink would write/read outside the jail.
185
+ // The secret/private masks render AFTER this protected set (subtractMasked in
186
+ // applyBashSandbox drops any protected entry a mask already hides), so .env /
187
+ // secrets.json / memory / sessions stay hidden, not merely RO.
188
+ export async function resolvePackageInstallZones(agentDir: string): Promise<PackageInstallZones> {
189
+ await assertNotSymlink(agentDir)
190
+ await mkdir(join(agentDir, 'node_modules'), { recursive: true })
191
+ for (const rel of ['node_modules', ...PACKAGE_INSTALL_WRITABLE_FILES] as const) {
192
+ const target = join(agentDir, rel)
193
+ if (await exists(target)) await assertNotSymlink(target)
194
+ }
195
+
196
+ const writable = new Set<string>([...PACKAGE_INSTALL_WRITABLE_DIRS, ...PACKAGE_INSTALL_WRITABLE_FILES])
197
+ const dirs: string[] = []
198
+ const files: string[] = []
199
+ for (const entry of await readdir(agentDir, { withFileTypes: true })) {
200
+ if (writable.has(entry.name)) continue
201
+ // A symlinked root entry is skipped, not RO-bound: an RO bind follows it to
202
+ // an outside target. Skipping leaves it under the RW root — but it is the
203
+ // agent's OWN symlink under its OWN root, contained by the agent-folder bind
204
+ // and the always-on kernel invariants, the same residual the default jail
205
+ // accepts for symlinks pointing outside /agent.
206
+ if (entry.isSymbolicLink()) continue
207
+ const target = join(agentDir, entry.name)
208
+ if (entry.isDirectory()) dirs.push(target)
209
+ else if (entry.isFile()) files.push(target)
210
+ }
211
+
212
+ // node_modules itself is writable (deps land there), but the runtime under it
213
+ // must not be — RO-bind it nested, last-op-wins over the writable node_modules.
214
+ const runtime = join(agentDir, 'node_modules', 'typeclaw')
215
+ if (await isRealEntry(runtime, 'dir')) dirs.push(runtime)
216
+
217
+ return { root: agentDir, protected: { dirs: dedupe(dirs), files: dedupe(files) } }
218
+ }
219
+
220
+ async function exists(target: string): Promise<boolean> {
221
+ try {
222
+ await lstat(target)
223
+ return true
224
+ } catch {
225
+ return false
226
+ }
67
227
  }
68
228
 
69
229
  // Read-only re-protections rendered on top of the writable .git bind. Unlike
@@ -1265,6 +1265,10 @@ function handleInspectMessage(
1265
1265
  ws.close()
1266
1266
  return
1267
1267
  }
1268
+ if (msg.type === 'ping') {
1269
+ sendInspect(ws, { type: 'pong', id: msg.id })
1270
+ return
1271
+ }
1268
1272
  if (msg.type !== 'subscribe' || typeof msg.sessionId !== 'string' || msg.sessionId === '') {
1269
1273
  sendInspect(ws, { type: 'error', message: 'invalid inspect subscription' })
1270
1274
  ws.close()
@@ -1314,7 +1318,7 @@ function handleInspectMessage(
1314
1318
  })
1315
1319
  }
1316
1320
 
1317
- sendInspect(ws, { type: 'subscribed', sessionId: msg.sessionId, sessionLive: live !== undefined })
1321
+ sendInspect(ws, { type: 'subscribed', sessionId: msg.sessionId, sessionLive: live !== undefined, supportsPing: true })
1318
1322
  }
1319
1323
 
1320
1324
  function extractJobId(target: StreamMessage['target']): string {
@@ -44,16 +44,22 @@ export type TunnelLogsServerMessage =
44
44
  | { type: 'error'; message: string }
45
45
  | { type: 'end' }
46
46
 
47
- export type InspectClientMessage = {
48
- type: 'subscribe'
49
- sessionId: string
50
- // sinceMs is a wall-clock cutoff for backfilling broadcasts from the
51
- // in-process Stream ring buffer. The client uses Date.now() - duration;
52
- // omit to skip broadcast backfill. AgentSession events are NEVER
53
- // backfilled (the session's pi-coding-agent subscribe API delivers
54
- // future events only).
55
- sinceMs?: number
56
- }
47
+ export type InspectClientMessage =
48
+ | {
49
+ type: 'subscribe'
50
+ sessionId: string
51
+ // sinceMs is a wall-clock cutoff for backfilling broadcasts from the
52
+ // in-process Stream ring buffer. The client uses Date.now() - duration;
53
+ // omit to skip broadcast backfill. AgentSession events are NEVER
54
+ // backfilled (the session's pi-coding-agent subscribe API delivers
55
+ // future events only).
56
+ sinceMs?: number
57
+ }
58
+ // Steady-state liveness probe echoed back as a pong. A live tail is
59
+ // legitimately quiet for long stretches, so absence of inbound frames cannot
60
+ // distinguish "idle" from "dead"; a missed pong can. Guards a wedged
61
+ // WebSocket that stays ESTABLISHED yet never fires 'close'/'error'.
62
+ | { type: 'ping'; id: number }
57
63
 
58
64
  export type InspectFramePayload =
59
65
  | { kind: 'text_delta'; sessionId: string; delta: string }
@@ -123,9 +129,14 @@ export type InspectFramePayload =
123
129
  }
124
130
 
125
131
  export type InspectServerMessage =
126
- | { type: 'subscribed'; sessionId: string; sessionLive: boolean }
132
+ // supportsPing is the heartbeat capability flag. A pre-heartbeat server omits
133
+ // it; the client must treat its absence as "no ping support" and never send a
134
+ // ping (an old server answers an unknown ping with an error + close, killing
135
+ // the tail). Strict opt-in: only an explicit true arms round-trip probing.
136
+ | { type: 'subscribed'; sessionId: string; sessionLive: boolean; supportsPing?: true }
127
137
  | { type: 'frame'; ts: number; payload: InspectFramePayload }
128
138
  | { type: 'error'; message: string }
139
+ | { type: 'pong'; id: number }
129
140
 
130
141
  export type ClientMessage =
131
142
  | { type: 'prompt'; text: string; delivery?: PromptDelivery }