typeclaw 0.29.0 → 0.30.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 (35) hide show
  1. package/package.json +1 -1
  2. package/scripts/verify-realproc-sandbox.sh +58 -0
  3. package/src/agent/index.ts +6 -0
  4. package/src/agent/live-subagents.ts +5 -0
  5. package/src/agent/plugin-tools.ts +79 -10
  6. package/src/agent/subagent-drain.ts +150 -0
  7. package/src/agent/subagents.ts +34 -3
  8. package/src/agent/system-prompt.ts +1 -1
  9. package/src/agent/tools/spawn-subagent.ts +13 -1
  10. package/src/bundled-plugins/bun-hygiene/README.md +12 -11
  11. package/src/bundled-plugins/bun-hygiene/policy.ts +8 -3
  12. package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +116 -35
  13. package/src/bundled-plugins/github-cli-auth/effective-approval.ts +14 -9
  14. package/src/bundled-plugins/github-cli-auth/index.ts +3 -3
  15. package/src/bundled-plugins/planner/planner.ts +2 -1
  16. package/src/bundled-plugins/researcher/researcher.ts +9 -2
  17. package/src/bundled-plugins/reviewer/reviewer.ts +2 -1
  18. package/src/channels/adapters/discord-bot-format.ts +191 -0
  19. package/src/channels/adapters/discord-bot.ts +2 -1
  20. package/src/channels/adapters/github/inbound.ts +88 -30
  21. package/src/channels/adapters/github/review-state.ts +27 -0
  22. package/src/channels/github-review-claim.ts +15 -3
  23. package/src/channels/outbound-flood-filter.ts +70 -3
  24. package/src/channels/router.ts +53 -0
  25. package/src/compose/discover.ts +5 -1
  26. package/src/config/config.ts +38 -0
  27. package/src/container/start.ts +14 -0
  28. package/src/migrations/index.ts +35 -0
  29. package/src/migrations/secrets-v1-to-v2.ts +344 -0
  30. package/src/run/index.ts +13 -0
  31. package/src/sandbox/availability.ts +12 -0
  32. package/src/sandbox/build.ts +53 -9
  33. package/src/sandbox/index.ts +1 -1
  34. package/src/sandbox/policy.ts +17 -1
  35. package/typeclaw.schema.json +24 -0
@@ -0,0 +1,344 @@
1
+ import { chmodSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import lockfile from 'proper-lockfile'
5
+
6
+ import { parseSecretsFile, SECRETS_FILE_VERSION } from '@/secrets/schema'
7
+
8
+ // PR #638 removed the in-memory v1->v2 upgrade that `parseSecretsFile` used to
9
+ // perform, so a `secrets.json` still in v1 now fails to parse:
10
+ // `hydrateChannelEnvFromSecrets` swallows the failure as `{}`, no token env vars
11
+ // are injected, and channel adapters (Discord, Slack, Telegram) never connect.
12
+ // This is the one-shot on-disk replacement, run once at boot rather than on
13
+ // every parse, so the v2-only runtime keeps working without a read-time shim.
14
+
15
+ const SCHEMA_REL = './node_modules/typeclaw/secrets.schema.json'
16
+ const FILE_MODE = 0o600
17
+
18
+ const LEGACY_FILENAME = 'auth.json'
19
+ const TARGET_FILENAME = 'secrets.json'
20
+
21
+ // Frozen, migration-local reverse map (env-var name -> { adapterId, field }).
22
+ // Intentionally a private copy rather than an inversion of
23
+ // `CHANNEL_FIELD_ENV` in src/secrets/defaults.ts: re-importing live runtime
24
+ // defaults would (a) re-couple current code to deleted legacy surface area,
25
+ // and (b) let a future change to the runtime env-var names silently rewrite
26
+ // the semantics of this historical migration. A v1 file written years ago must
27
+ // migrate the same way regardless of what the live adapters key off today.
28
+ const LEGACY_CHANNEL_ENV_TO_FIELD: Record<string, { adapterId: string; field: string }> = {
29
+ DISCORD_BOT_TOKEN: { adapterId: 'discord-bot', field: 'token' },
30
+ SLACK_BOT_TOKEN: { adapterId: 'slack-bot', field: 'botToken' },
31
+ SLACK_APP_TOKEN: { adapterId: 'slack-bot', field: 'appToken' },
32
+ TELEGRAM_BOT_TOKEN: { adapterId: 'telegram-bot', field: 'token' },
33
+ }
34
+
35
+ export const MIGRATION_ID = '0001-secrets-v1-to-v2'
36
+
37
+ export type SecretsMigrationResult = { changed: boolean; summary: string }
38
+
39
+ // Idempotent: a folder already at v2 (or with no legacy file) returns
40
+ // `changed: false`. Errors that indicate ambiguous/unsafe state throw with an
41
+ // actionable message rather than guessing.
42
+ //
43
+ // Concurrency: secrets.json is the lock resource SecretsBackend (provider add,
44
+ // OAuth refresh, channel add) and credential exporters use, so we hold ITS lock
45
+ // across the entire precedence resolution AND upgrade. The lock requires the
46
+ // file to exist, so when only auth.json is present we first seed secrets.json
47
+ // with exclusive create-if-absent semantics (never overwriting a file a
48
+ // concurrent writer may have just written), then lock, then re-read precedence
49
+ // from fresh on-disk state under the lock.
50
+ export function migrateSecretsV1ToV2(agentDir: string): SecretsMigrationResult {
51
+ const legacyPath = join(agentDir, LEGACY_FILENAME)
52
+ const targetPath = join(agentDir, TARGET_FILENAME)
53
+
54
+ if (!existsSync(legacyPath) && !existsSync(targetPath)) {
55
+ return { changed: false, summary: 'no secrets file to migrate' }
56
+ }
57
+
58
+ seedTargetIfAbsent(targetPath)
59
+
60
+ return withFileLock(targetPath, () => {
61
+ resolvePrecedenceUnderLock(legacyPath, targetPath)
62
+ return upgradeFileInPlace(targetPath)
63
+ })
64
+ }
65
+
66
+ // Creates an empty v2 envelope at secrets.json only if it does not already
67
+ // exist, using exclusive create ('wx') so a concurrent writer that wrote real
68
+ // credentials between our existsSync check and here is never clobbered — the
69
+ // EEXIST is swallowed because the file we need to lock now exists, which is all
70
+ // we required. A freshly-seeded empty envelope is indistinguishable from "no
71
+ // target" to resolvePrecedenceUnderLock (isEmptyEnvelope returns true), so
72
+ // "only auth.json" collapses into the "secrets.json empty -> auth wins" branch.
73
+ function seedTargetIfAbsent(targetPath: string): void {
74
+ if (existsSync(targetPath)) return
75
+ try {
76
+ writeFileSync(targetPath, stringifyEmptyEnvelope(), { encoding: 'utf8', mode: FILE_MODE, flag: 'wx' })
77
+ } catch (err) {
78
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
79
+ }
80
+ }
81
+
82
+ // auth.json precedence, run ENTIRELY under the secrets.json lock so the read,
83
+ // the rename/unlink decision, and the rename itself can't interleave with a
84
+ // concurrent secrets.json writer. Preserves the deleted migrateLegacyAuthJson
85
+ // semantics so no credential is ever silently dropped:
86
+ // - no auth.json -> operate on secrets.json as-is
87
+ // - droppable auth.json -> unlink auth.json, operate on secrets.json
88
+ // - secrets.json empty seed -> auth.json wins (rename over the empty seed)
89
+ // - both non-empty -> hard error (can't pick a source of truth)
90
+ function resolvePrecedenceUnderLock(legacyPath: string, targetPath: string): void {
91
+ if (!existsSync(legacyPath)) return
92
+
93
+ if (isDroppableLegacyFile(legacyPath)) {
94
+ unlinkSync(legacyPath)
95
+ return
96
+ }
97
+
98
+ if (isEmptyEnvelope(targetPath)) {
99
+ renameWithRaceFallback(legacyPath, targetPath)
100
+ chmodSync(targetPath, FILE_MODE)
101
+ return
102
+ }
103
+
104
+ throw new Error(
105
+ `Both ${LEGACY_FILENAME} and a non-empty ${TARGET_FILENAME} exist in the agent folder. ` +
106
+ `Inspect manually and remove the stale file before re-running.`,
107
+ )
108
+ }
109
+
110
+ function upgradeFileInPlace(path: string): SecretsMigrationResult {
111
+ let raw: string
112
+ try {
113
+ raw = readFileSync(path, 'utf8')
114
+ } catch {
115
+ return { changed: false, summary: 'secrets file unreadable; skipped' }
116
+ }
117
+ if (raw.trim() === '') return { changed: false, summary: 'secrets file empty; skipped' }
118
+
119
+ let parsed: unknown
120
+ try {
121
+ parsed = JSON.parse(raw)
122
+ } catch (err) {
123
+ throw new Error(`secrets file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`)
124
+ }
125
+
126
+ // Already current: parseSecretsFile only accepts v2 post-#638, so a successful
127
+ // parse means there is nothing to do.
128
+ if (parseSecretsFile(parsed).ok) return { changed: false, summary: 'already v2; no change' }
129
+
130
+ const upgraded = upgradeToV2(parsed)
131
+ if (upgraded === null) {
132
+ throw new Error(
133
+ 'secrets file is neither a valid v2 envelope nor a recognized legacy (v1 / pre-envelope) shape; ' +
134
+ 'leaving it untouched for manual inspection',
135
+ )
136
+ }
137
+
138
+ // Re-validate the product of our own transform before persisting. A transform
139
+ // that emitted an invalid v2 file would brick the next read; failing here is
140
+ // strictly safer than writing garbage.
141
+ const check = parseSecretsFile(upgraded)
142
+ if (!check.ok) {
143
+ throw new Error(`internal: migrated secrets file failed v2 validation: ${check.reason}`)
144
+ }
145
+
146
+ writeEnvelopeAtomic(path, check.file)
147
+ return { changed: true, summary: `upgraded secrets file to v${SECRETS_FILE_VERSION}` }
148
+ }
149
+
150
+ // Recognizes the two pre-v2 shapes the deleted parseSecretsFile branches used
151
+ // to accept and returns a v2-shaped object. Returns null when the input matches
152
+ // neither (caller turns that into a loud, no-write error).
153
+ //
154
+ // v1 envelope: { version: 1, llm: {...}, channels: { adapter: { ENV: value } } }
155
+ // pre-envelope flat: { providerId: { type, key } } at the top level
156
+ function upgradeToV2(raw: unknown): Record<string, unknown> | null {
157
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
158
+ const obj = raw as Record<string, unknown>
159
+
160
+ if (obj.version === 1) {
161
+ return upgradeV1Envelope(obj)
162
+ }
163
+
164
+ if (looksLikeFlatProviders(obj)) {
165
+ return upgradeV1Envelope({ version: 1, llm: obj, channels: {} })
166
+ }
167
+
168
+ return null
169
+ }
170
+
171
+ function upgradeV1Envelope(obj: Record<string, unknown>): Record<string, unknown> {
172
+ const llm = isPlainObject(obj.llm) ? obj.llm : {}
173
+ const legacyChannels = isPlainObject(obj.channels) ? obj.channels : {}
174
+
175
+ const providers: Record<string, unknown> = {}
176
+ for (const [providerId, cred] of Object.entries(llm)) {
177
+ if (!isPlainObject(cred)) continue
178
+ if (cred.type === 'api_key' && typeof cred.key === 'string') {
179
+ providers[providerId] = { type: 'api_key', key: { value: cred.key } }
180
+ } else {
181
+ // OAuth and any unknown credential type pass through verbatim — they are
182
+ // not env-injectable and the v2 schema accepts them via catchall.
183
+ providers[providerId] = cred
184
+ }
185
+ }
186
+
187
+ const channels: Record<string, Record<string, unknown>> = {}
188
+ for (const [adapterId, slot] of Object.entries(legacyChannels)) {
189
+ if (!isPlainObject(slot)) continue
190
+ const upgradedSlot: Record<string, unknown> = {}
191
+ for (const [key, value] of Object.entries(slot)) {
192
+ if (typeof value !== 'string') {
193
+ // A non-string value means this isn't the flat env-keyed v1 channel
194
+ // shape (e.g. a kakaotalk block, which is structured). Preserve it
195
+ // verbatim so the catchall keeps it valid; do not try to reshape.
196
+ upgradedSlot[key] = value
197
+ continue
198
+ }
199
+ const mapping = LEGACY_CHANNEL_ENV_TO_FIELD[key]
200
+ if (mapping && mapping.adapterId === adapterId) {
201
+ upgradedSlot[mapping.field] = { value }
202
+ } else {
203
+ // Unknown env-var key on a known adapter, or an unknown adapter:
204
+ // preserve under the original key but still wrap as a v2 Secret so the
205
+ // resulting file is valid v2.
206
+ upgradedSlot[key] = { value }
207
+ }
208
+ }
209
+ channels[adapterId] = upgradedSlot
210
+ }
211
+
212
+ const result: Record<string, unknown> = {
213
+ $schema: typeof obj.$schema === 'string' ? obj.$schema : SCHEMA_REL,
214
+ version: SECRETS_FILE_VERSION,
215
+ providers,
216
+ channels,
217
+ }
218
+ return result
219
+ }
220
+
221
+ // A flat pre-envelope file is a top-level record of provider credentials. Every
222
+ // value must be a credential object with a `type` field; anything else means we
223
+ // don't recognize the shape and should not guess.
224
+ function looksLikeFlatProviders(obj: Record<string, unknown>): boolean {
225
+ const entries = Object.entries(obj).filter(([k]) => k !== '$schema')
226
+ if (entries.length === 0) return false
227
+ return entries.every(([, value]) => isPlainObject(value) && typeof value.type === 'string')
228
+ }
229
+
230
+ function isEmptyEnvelope(path: string): boolean {
231
+ const parsed = readJsonOrNull(path)
232
+ if (parsed === undefined) return true
233
+ if (parsed === null) return false
234
+ const result = parseSecretsFile(parsed)
235
+ if (!result.ok) return false
236
+ return Object.keys(result.file.providers).length === 0 && Object.keys(result.file.channels).length === 0
237
+ }
238
+
239
+ // True only when a legacy auth.json carries nothing worth keeping, so dropping
240
+ // it in favor of an existing secrets.json is safe: a missing/blank file, or a
241
+ // valid-but-empty v2 envelope. Anything else parseable — a legacy shape with
242
+ // credentials OR a parseable-but-unrecognized object — returns false so
243
+ // resolveLegacyFilename falls through to the both-non-empty hard error rather
244
+ // than silently deleting a file whose contents we can't account for.
245
+ function isDroppableLegacyFile(path: string): boolean {
246
+ const parsed = readJsonOrNull(path)
247
+ if (parsed === undefined) return true
248
+ if (parsed === null) return false
249
+ const v2 = parseSecretsFile(parsed)
250
+ if (!v2.ok) return false
251
+ return Object.keys(v2.file.providers).length === 0 && Object.keys(v2.file.channels).length === 0
252
+ }
253
+
254
+ // undefined = file missing/blank (treat as empty); null = present but invalid
255
+ // JSON (treat as "has content we can't safely drop").
256
+ function readJsonOrNull(path: string): unknown {
257
+ let raw: string
258
+ try {
259
+ raw = readFileSync(path, 'utf8')
260
+ } catch {
261
+ return undefined
262
+ }
263
+ if (raw.trim() === '') return undefined
264
+ try {
265
+ return JSON.parse(raw)
266
+ } catch {
267
+ return null
268
+ }
269
+ }
270
+
271
+ function stringifyEmptyEnvelope(): string {
272
+ return `${JSON.stringify({ $schema: SCHEMA_REL, version: SECRETS_FILE_VERSION, providers: {}, channels: {} }, null, 2)}\n`
273
+ }
274
+
275
+ function writeEnvelopeAtomic(path: string, envelope: unknown): void {
276
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
277
+ writeFileSync(tmp, `${JSON.stringify(envelope, null, 2)}\n`, { encoding: 'utf8', mode: FILE_MODE })
278
+ try {
279
+ renameSync(tmp, path)
280
+ } catch (err) {
281
+ try {
282
+ unlinkSync(tmp)
283
+ } catch {
284
+ // best-effort cleanup of the temp file when rename fails
285
+ }
286
+ throw err
287
+ }
288
+ chmodSync(path, FILE_MODE)
289
+ }
290
+
291
+ // renameSync is atomic per syscall, but two concurrent migration runs can both
292
+ // observe auth.json exists and secrets.json does not, then race on the rename.
293
+ // One wins; the loser gets ENOENT because the source is already gone — that is
294
+ // a successful migration from its POV, so recheck the target and swallow it.
295
+ function renameWithRaceFallback(from: string, to: string): void {
296
+ try {
297
+ renameSync(from, to)
298
+ } catch (err) {
299
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT' && existsSync(to)) return
300
+ throw err
301
+ }
302
+ }
303
+
304
+ // Mirror SecretsBackend's lock discipline so a concurrent credential write
305
+ // (provider add, OAuth refresh, channel add) can't interleave with the
306
+ // read-transform-write. proper-lockfile needs the target to exist; the target
307
+ // always exists by the time we lock (resolveLegacyFilename guarantees it).
308
+ function withFileLock<T>(path: string, fn: () => T): T {
309
+ let release: (() => void) | undefined
310
+ try {
311
+ release = acquireSyncLockWithRetry(path)
312
+ return fn()
313
+ } finally {
314
+ release?.()
315
+ }
316
+ }
317
+
318
+ const SYNC_LOCK_RETRIES = 10
319
+ const SYNC_LOCK_DELAY_MS = 20
320
+
321
+ function acquireSyncLockWithRetry(path: string): () => void {
322
+ let lastError: unknown
323
+ for (let attempt = 1; attempt <= SYNC_LOCK_RETRIES; attempt++) {
324
+ try {
325
+ return lockfile.lockSync(path, { realpath: false })
326
+ } catch (error) {
327
+ const code =
328
+ typeof error === 'object' && error !== null && 'code' in error
329
+ ? String((error as { code: unknown }).code)
330
+ : undefined
331
+ if (code !== 'ELOCKED' || attempt === SYNC_LOCK_RETRIES) throw error
332
+ lastError = error
333
+ const start = Date.now()
334
+ while (Date.now() - start < SYNC_LOCK_DELAY_MS) {
335
+ // intentionally empty: synchronous busy-wait to match SecretsBackend
336
+ }
337
+ }
338
+ }
339
+ throw (lastError as Error | undefined) ?? new Error('Failed to acquire secrets store lock')
340
+ }
341
+
342
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
343
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
344
+ }
package/src/run/index.ts CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  } from '@/cron'
43
43
  import { CLI_VERSION } from '@/init/cli-version'
44
44
  import { createMcpManager } from '@/mcp'
45
+ import { runStartupMigrations } from '@/migrations'
45
46
  import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
46
47
  import { createPluginLogger } from '@/plugin/context'
47
48
  import type { CronHandlerContext } from '@/plugin/types'
@@ -194,6 +195,12 @@ export async function startAgent({
194
195
  materializedSkills: null,
195
196
  })
196
197
 
198
+ // Graduate any pre-0.20.0 on-disk shapes (v1 secrets.json, legacy auth.json)
199
+ // to the current v2 envelope before anything reads secrets — otherwise the
200
+ // v2-only parser rejects the file and hydrate below sees no channels. Runs
201
+ // exactly once per folder; a folder already at v2 is a no-op.
202
+ runStartupMigrations(cwd)
203
+
197
204
  // Channel adapters read `process.env[TOKEN_ENV]` (see channels/manager.ts).
198
205
  // Hydrate fills any unset env var from secrets.json#channels via env-wins:
199
206
  // values already in process.env (from `docker --env-file .env`) are kept
@@ -329,6 +336,8 @@ export async function startAgent({
329
336
  ...(subagentOptions?.spawnedByRole !== undefined ? { spawnedByRole: subagentOptions.spawnedByRole } : {}),
330
337
  ...(subagentOptions?.spawnedByOrigin !== undefined ? { spawnedByOrigin: subagentOptions.spawnedByOrigin } : {}),
331
338
  }
339
+ const allowBackgroundFromSubagent =
340
+ entry.pluginSubagent.canBackgroundSpawnSubagents === true && entry.pluginSubagent.canSpawnSubagents === true
332
341
  const created = await createSessionWithDispose({
333
342
  systemPromptOverride: entry.pluginSubagent.systemPrompt,
334
343
  sessionManager,
@@ -359,6 +368,7 @@ export async function startAgent({
359
368
  liveSubagentRegistry,
360
369
  subagentRegistry: snap.subagents,
361
370
  createSessionForSubagent,
371
+ allowBackgroundFromSubagent,
362
372
  }
363
373
  : {}),
364
374
  ...(entry.pluginSubagent.profile !== undefined ? { profile: entry.pluginSubagent.profile } : {}),
@@ -380,6 +390,9 @@ export async function startAgent({
380
390
  agentDir: cwd,
381
391
  origin,
382
392
  getTranscriptPath: () => sessionManager.getSessionFile(),
393
+ ...(allowBackgroundFromSubagent
394
+ ? { backgroundDrain: { stream, sessionId, liveRegistry: liveSubagentRegistry } }
395
+ : {}),
383
396
  }
384
397
  }
385
398
  return defaultCreateSessionForSubagent(subagent, subagentOptions)
@@ -33,3 +33,15 @@ async function probe(bwrap: string): Promise<boolean> {
33
33
  export function _resetBwrapAvailabilityCacheForTests(): void {
34
34
  availabilityCache.clear()
35
35
  }
36
+
37
+ // The bun binary this process runs as (process.execPath). build.ts re-exposes
38
+ // it at /proc/self/exe over the masked /proc so sandboxed package runners can
39
+ // self-locate. This is correct ONLY in the bun-centric container: the base
40
+ // image (oven/bun:1-slim) ships no real node — `node` is a bun symlink and
41
+ // bunx/npx/pnpx all resolve to bun (Bun's fake-node model), so every runtime
42
+ // reading /proc/self/exe IS bun. A real node binary would self-locate to the
43
+ // wrong ELF here; if node is ever added to the image this must resolve the
44
+ // actual interpreter instead.
45
+ export function resolveProcSelfExe(): string {
46
+ return process.execPath
47
+ }
@@ -36,14 +36,35 @@ export function buildSandboxedCommand(command: string, policy: SandboxPolicy = {
36
36
 
37
37
  function buildArgv(command: string, policy: SandboxPolicy): string[] {
38
38
  const bwrap = policy.bwrapPath ?? 'bwrap'
39
- const argv: string[] = [bwrap, '--unshare-all']
40
-
41
- if (policy.network === 'inherit') {
42
- // --unshare-all already unshared the net namespace; --share-net rejoins
43
- // the outer container's network. Other namespaces (user/pid/mount/ipc/
44
- // uts/cgroup) stay unshared. Default ('none' / undefined) leaves the net
45
- // namespace isolated prompt-injected bash cannot exfiltrate over the
46
- // network without the consumer explicitly opting in.
39
+ const procStrategy = policy.proc ?? 'tmpfs'
40
+ const realProc = procStrategy === 'real-proc'
41
+
42
+ // 'real-proc' splits PID-namespace ownership from bwrap. `unshare --pid
43
+ // --fork --mount --mount-proc` (util-linux, baseline) creates the new PID +
44
+ // mount namespaces as REAL root and mounts a fresh procfs scoped to that PID
45
+ // namespace — which OrbStack permits only with CAP_SYS_ADMIN and NOT from
46
+ // bwrap's user namespace (bwrap's --proc is blocked there). bwrap then runs
47
+ // INSIDE that namespace and must NOT re-unshare pid (it would create a second
48
+ // PID ns with no matching procfs and reintroduce the ENOTDIR crash), so we
49
+ // unshare each namespace EXCEPT pid explicitly instead of --unshare-all. The
50
+ // freshly mounted /proc contains only the sandbox subtree, so --ro-bind /proc
51
+ // (below) binds that scoped procfs, never the agent runtime's /proc/N/environ.
52
+ const argv: string[] = realProc
53
+ ? ['unshare', '--pid', '--fork', '--mount', '--mount-proc', '--', bwrap]
54
+ : [bwrap, '--unshare-all']
55
+ if (realProc) {
56
+ argv.push('--unshare-user', '--unshare-ipc', '--unshare-uts', '--unshare-cgroup')
57
+ }
58
+
59
+ if (policy.network !== 'inherit') {
60
+ // Default ('none' / undefined) isolates the net namespace — prompt-injected
61
+ // bash cannot exfiltrate over the network unless the consumer opts in.
62
+ // --unshare-all already covers this in the non-real-proc path; under
63
+ // real-proc the explicit unshares above omit net, so add it here.
64
+ if (realProc) argv.push('--unshare-net')
65
+ } else if (!realProc) {
66
+ // --unshare-all unshared the net namespace; --share-net rejoins the outer
67
+ // container's network. Under real-proc we simply never add --unshare-net.
47
68
  argv.push('--share-net')
48
69
  }
49
70
 
@@ -97,12 +118,35 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
97
118
  '/lib64',
98
119
  )
99
120
 
100
- if ((policy.proc ?? 'tmpfs') === 'tmpfs') {
121
+ if (realProc) {
122
+ // The outer `unshare --mount-proc` already mounted a fresh procfs scoped to
123
+ // the new PID namespace. --ro-bind /proc /proc binds THAT procfs (not the
124
+ // outer container's), so the child gets real /proc/self/{fd,maps} and the
125
+ // agent runtime's pids — and their /proc/N/environ secrets — are simply
126
+ // absent from this namespace. No /proc/self/exe symlink is needed: a real
127
+ // /proc/self/exe already resolves correctly.
128
+ argv.push('--ro-bind', '/proc', '/proc')
129
+ } else if (procStrategy === 'tmpfs') {
101
130
  // --tmpfs /proc, never --proc /proc (OrbStack's kernel blocks
102
131
  // mount("proc",...) from user namespaces) and never --dev-bind /proc /proc
103
132
  // (leaks the outer container's /proc/N/environ — including
104
133
  // FIREWORKS_API_KEY — into the sandbox). See sandbox.mdx.
105
134
  argv.push('--tmpfs', '/proc')
135
+
136
+ // Re-expose ONLY the bun ELF at /proc/self/exe so sandboxed package runners
137
+ // can self-locate; /proc/N/environ stays masked by the tmpfs above. The
138
+ // caller passes bun's path (see resolveProcSelfExe): in this bun-centric
139
+ // container bunx/npx/pnpx all resolve to bun, so bun IS the runtime reading
140
+ // /proc/self/exe. --symlink (not --ro-bind /proc/self/exe): /proc/self at
141
+ // setup time is bwrap's pid, so a bind would capture bwrap's own binary.
142
+ // Must come AFTER --tmpfs /proc (last-op-wins) or the tmpfs erases it.
143
+ // This restores only the runner's SELF-location; a spawned child still
144
+ // reads /proc/self/fd + /proc/self/maps, which the empty tmpfs lacks, so
145
+ // external-package execution requires the 'real-proc' strategy above.
146
+ if (policy.procSelfExe !== undefined) {
147
+ argv.push('--ro-bind', policy.procSelfExe, policy.procSelfExe)
148
+ argv.push('--symlink', policy.procSelfExe, '/proc/self/exe')
149
+ }
106
150
  }
107
151
 
108
152
  for (const mount of policy.mounts ?? []) {
@@ -1,5 +1,5 @@
1
1
  export { buildSandboxedCommand, type SandboxedCommand } from './build'
2
- export { ensureBwrapAvailable, _resetBwrapAvailabilityCacheForTests } from './availability'
2
+ export { ensureBwrapAvailable, resolveProcSelfExe, _resetBwrapAvailabilityCacheForTests } from './availability'
3
3
  export { resolveHiddenPaths, type HiddenPaths } from './hidden-paths'
4
4
  export {
5
5
  resolveProtectedZones,
@@ -6,7 +6,15 @@ export type SandboxMount =
6
6
 
7
7
  export type SandboxNetwork = 'none' | 'inherit'
8
8
 
9
- export type SandboxProcStrategy = 'tmpfs' | 'none'
9
+ // 'tmpfs' (default): empty /proc + a single /proc/self/exe symlink. Works on
10
+ // every host but gives no /proc/self/{fd,maps}, so a JS package runner's CHILD
11
+ // (the spawned bin) crashes with ENOTDIR reading /proc/self/fd. 'none': no
12
+ // /proc at all. 'real-proc': mount a fresh procfs scoped to a NEW pid namespace
13
+ // so the child gets a real /proc/self/{fd,maps} WITHOUT seeing the agent
14
+ // runtime's pids (no /proc/<agent>/environ leak). 'real-proc' requires the
15
+ // outer container to hold CAP_SYS_ADMIN (mount(2) of proc); start.ts only grants
16
+ // it when the operator opts in via typeclaw.json#sandbox.realProc.
17
+ export type SandboxProcStrategy = 'tmpfs' | 'none' | 'real-proc'
10
18
 
11
19
  export type SandboxEnvPolicy = {
12
20
  set?: Record<string, string>
@@ -60,6 +68,14 @@ export type SandboxProtectedPolicy = {
60
68
  export type SandboxPolicy = {
61
69
  bwrapPath?: string
62
70
  cwd?: string
71
+ // Concrete host interpreter ELF (the running bun binary) re-exposed at
72
+ // /proc/self/exe over the --tmpfs /proc mask. JS runtimes self-locate via
73
+ // /proc/self/exe; under the empty tmpfs /proc that read fails and bunx panics
74
+ // in createFakeTemporaryNodeExecutable. A direct --ro-bind of /proc/self/exe
75
+ // is wrong: at bwrap setup time /proc/self is bwrap's pid, so it captures the
76
+ // bwrap binary, not the child runtime. The caller resolves this path (I/O);
77
+ // the builder stays pure.
78
+ procSelfExe?: string
63
79
  mounts?: SandboxMount[]
64
80
  masks?: SandboxMaskPolicy
65
81
  writable?: SandboxWritablePolicy
@@ -190,6 +190,18 @@
190
190
  "minLength": 1
191
191
  }
192
192
  },
193
+ "compose": {
194
+ "default": {
195
+ "exclude": false
196
+ },
197
+ "type": "object",
198
+ "properties": {
199
+ "exclude": {
200
+ "default": false,
201
+ "type": "boolean"
202
+ }
203
+ }
204
+ },
193
205
  "channels": {
194
206
  "default": {},
195
207
  "type": "object",
@@ -1114,6 +1126,18 @@
1114
1126
  }
1115
1127
  }
1116
1128
  },
1129
+ "sandbox": {
1130
+ "default": {
1131
+ "realProc": false
1132
+ },
1133
+ "type": "object",
1134
+ "properties": {
1135
+ "realProc": {
1136
+ "default": false,
1137
+ "type": "boolean"
1138
+ }
1139
+ }
1140
+ },
1117
1141
  "docker": {
1118
1142
  "default": {
1119
1143
  "file": {