typeclaw 0.13.0 → 0.15.0

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.
@@ -0,0 +1,328 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { type AdapterId, type ChannelsConfig } from '@/channels'
4
+ import { loadConfigSync, validateConfig } from '@/config'
5
+ import { readEnvFile } from '@/init'
6
+ import { SecretsBackend } from '@/secrets'
7
+ import { channelFieldDefaultEnv } from '@/secrets/defaults'
8
+
9
+ import type { CheckContext, CheckResult, DoctorCheck } from './types'
10
+
11
+ // Host-stage channel adapter health checks. These cannot talk to Slack /
12
+ // Discord / Telegram / KakaoTalk / GitHub APIs — that work belongs to the
13
+ // container-stage `start()` preflight on each adapter (see
14
+ // `src/channels/manager.ts` and individual adapters). What doctor CAN do
15
+ // from the host is verify that the credentials the container will look for
16
+ // are actually present and resolvable, so the operator gets a clear,
17
+ // before-`typeclaw start` signal instead of a silent skip in the runtime
18
+ // logs.
19
+ //
20
+ // Every check is gated on `ctx.hasAgentFolder` (typeclaw.json is required to
21
+ // read the channels config) and additionally on the adapter being declared
22
+ // AND enabled in typeclaw.json. A missing or `enabled: false` adapter
23
+ // reports `skipped` so the operator can see the check ran without it
24
+ // turning into noise on minimal setups.
25
+
26
+ export function buildChannelChecks(): DoctorCheck[] {
27
+ return [
28
+ slackBotCredentials(),
29
+ discordBotCredentials(),
30
+ telegramBotCredentials(),
31
+ kakaotalkCredentials(),
32
+ githubCredentials(),
33
+ githubWebhookDelivery(),
34
+ ]
35
+ }
36
+
37
+ function slackBotCredentials(): DoctorCheck {
38
+ return {
39
+ name: 'channel.slack-bot.credentials',
40
+ category: 'channels',
41
+ description: 'slack-bot adapter has SLACK_BOT_TOKEN and SLACK_APP_TOKEN',
42
+ applies: (ctx) => ctx.hasAgentFolder,
43
+ run: (ctx) => runTokenAdapterCheck(ctx, 'slack-bot', ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']),
44
+ }
45
+ }
46
+
47
+ function discordBotCredentials(): DoctorCheck {
48
+ return {
49
+ name: 'channel.discord-bot.credentials',
50
+ category: 'channels',
51
+ description: 'discord-bot adapter has DISCORD_BOT_TOKEN',
52
+ applies: (ctx) => ctx.hasAgentFolder,
53
+ run: (ctx) => runTokenAdapterCheck(ctx, 'discord-bot', ['DISCORD_BOT_TOKEN']),
54
+ }
55
+ }
56
+
57
+ function telegramBotCredentials(): DoctorCheck {
58
+ return {
59
+ name: 'channel.telegram-bot.credentials',
60
+ category: 'channels',
61
+ description: 'telegram-bot adapter has TELEGRAM_BOT_TOKEN',
62
+ applies: (ctx) => ctx.hasAgentFolder,
63
+ run: (ctx) => runTokenAdapterCheck(ctx, 'telegram-bot', ['TELEGRAM_BOT_TOKEN']),
64
+ }
65
+ }
66
+
67
+ function kakaotalkCredentials(): DoctorCheck {
68
+ return {
69
+ name: 'channel.kakaotalk.credentials',
70
+ category: 'channels',
71
+ description: 'kakaotalk adapter has at least one account in secrets.json',
72
+ applies: (ctx) => ctx.hasAgentFolder,
73
+ async run(ctx) {
74
+ const channels = readDeclaredChannels(ctx)
75
+ if (channels === null) return configInvalidResult()
76
+ if (!isAdapterActive(channels, 'kakaotalk')) {
77
+ return { status: 'skipped', message: 'kakaotalk not configured' }
78
+ }
79
+ const block = readChannelsSecrets(ctx)?.kakaotalk
80
+ const accountCount = block?.accounts ? Object.keys(block.accounts).length : 0
81
+ if (accountCount === 0) {
82
+ return {
83
+ status: 'warning',
84
+ message: 'kakaotalk has no accounts in secrets.json',
85
+ details: ['Adapter will start but fail authentication and stay disconnected.'],
86
+ fix: { description: 'Run `typeclaw channel add kakaotalk` to log in an account.' },
87
+ }
88
+ }
89
+ return { status: 'ok', message: `kakaotalk has ${accountCount} account(s)` }
90
+ },
91
+ }
92
+ }
93
+
94
+ function githubCredentials(): DoctorCheck {
95
+ return {
96
+ name: 'channel.github.credentials',
97
+ category: 'channels',
98
+ description: 'github adapter has auth and webhookSecret in secrets.json',
99
+ applies: (ctx) => ctx.hasAgentFolder,
100
+ async run(ctx) {
101
+ const channels = readDeclaredChannels(ctx)
102
+ if (channels === null) return configInvalidResult()
103
+ if (!isAdapterActive(channels, 'github')) {
104
+ return { status: 'skipped', message: 'github not configured' }
105
+ }
106
+ const block = readChannelsSecrets(ctx)?.github
107
+ if (!block) {
108
+ return {
109
+ status: 'error',
110
+ message: 'github secrets missing from secrets.json',
111
+ details: ['Adapter requires both `auth` and `webhookSecret`.'],
112
+ fix: { description: 'Run `typeclaw channel set github` to configure GitHub auth.' },
113
+ }
114
+ }
115
+ const dotEnv = safeReadEnvFile(ctx.cwd)
116
+ const details: string[] = []
117
+ const webhookSecret = resolveSecretHostStage(block.webhookSecret, dotEnv)
118
+ if (webhookSecret === undefined || webhookSecret === '') {
119
+ details.push('webhookSecret is unset (resolves to empty string)')
120
+ }
121
+ if (block.auth.type === 'pat') {
122
+ const token = resolveSecretHostStage(block.auth.token, dotEnv)
123
+ if (token === undefined || token === '') {
124
+ details.push('auth.token (PAT) is unset (resolves to empty string)')
125
+ }
126
+ } else {
127
+ const key = resolveSecretHostStage(block.auth.privateKey, dotEnv)
128
+ if (key === undefined || key === '') {
129
+ details.push('auth.privateKey (App) is unset (resolves to empty string)')
130
+ }
131
+ }
132
+ if (details.length > 0) {
133
+ return {
134
+ status: 'error',
135
+ message: 'github credentials present but some fields resolve to empty',
136
+ details,
137
+ fix: { description: 'Run `typeclaw channel set github` to repopulate the missing fields.' },
138
+ }
139
+ }
140
+ return {
141
+ status: 'ok',
142
+ message: `github ${block.auth.type === 'pat' ? 'PAT' : 'App'} auth + webhookSecret resolved`,
143
+ }
144
+ },
145
+ }
146
+ }
147
+
148
+ function githubWebhookDelivery(): DoctorCheck {
149
+ return {
150
+ name: 'channel.github.webhook-delivery',
151
+ category: 'channels',
152
+ description: 'github webhook delivery has a public URL (webhookUrl or tunnel)',
153
+ applies: (ctx) => ctx.hasAgentFolder,
154
+ async run(ctx) {
155
+ const cfg = safeLoadConfig(ctx)
156
+ if (cfg === null) return configInvalidResult()
157
+ const github = cfg.channels.github
158
+ if (github === undefined || github.enabled === false) {
159
+ return { status: 'skipped', message: 'github not configured' }
160
+ }
161
+ const hasWebhookUrl = typeof github.webhookUrl === 'string' && github.webhookUrl.length > 0
162
+ const hasTunnel = cfg.tunnels.some((t) => t.for.kind === 'channel' && t.for.name === 'github')
163
+ if (hasWebhookUrl || hasTunnel) {
164
+ const source = hasWebhookUrl ? 'webhookUrl' : 'tunnel'
165
+ return { status: 'ok', message: `github webhook delivery configured via ${source}` }
166
+ }
167
+ if (github.repos.length === 0) {
168
+ return {
169
+ status: 'info',
170
+ message: 'github has no webhookUrl or tunnel, and no repos to register',
171
+ details: ['Webhooks will not be auto-registered until either webhookUrl or a tunnel binding is set.'],
172
+ }
173
+ }
174
+ return {
175
+ status: 'warning',
176
+ message: `github lists ${github.repos.length} repo(s) but has no public URL to deliver webhooks to`,
177
+ details: [
178
+ 'Either set `channels.github.webhookUrl` in typeclaw.json,',
179
+ 'or add a `tunnels[]` entry with `for: { kind: "channel", name: "github" }`.',
180
+ ],
181
+ fix: {
182
+ description: 'Configure webhookUrl or a github tunnel; see `typeclaw tunnel add` for managed tunnels.',
183
+ },
184
+ }
185
+ },
186
+ }
187
+ }
188
+
189
+ async function runTokenAdapterCheck(
190
+ ctx: CheckContext,
191
+ adapter: Extract<AdapterId, 'slack-bot' | 'discord-bot' | 'telegram-bot'>,
192
+ envNames: readonly string[],
193
+ ): Promise<CheckResult> {
194
+ const channels = readDeclaredChannels(ctx)
195
+ if (channels === null) return configInvalidResult()
196
+ if (!isAdapterActive(channels, adapter)) {
197
+ return { status: 'skipped', message: `${adapter} not configured` }
198
+ }
199
+ const dotEnv = safeReadEnvFile(ctx.cwd)
200
+ const channelSecrets = readChannelsSecrets(ctx)
201
+ const adapterSecrets = (channelSecrets?.[adapter] ?? {}) as Record<string, unknown>
202
+ const missing: string[] = []
203
+ for (const envName of envNames) {
204
+ if (hasTokenForEnv(adapter, envName, dotEnv, adapterSecrets)) continue
205
+ missing.push(envName)
206
+ }
207
+ if (missing.length > 0) {
208
+ return {
209
+ status: 'warning',
210
+ message: `${adapter} missing credentials: ${missing.join(', ')}`,
211
+ details: [
212
+ 'Adapter will be skipped at start until credentials are present.',
213
+ 'Resolution order: process.env wins over .env file value over secrets.json value.',
214
+ ],
215
+ fix: { description: 'Run `typeclaw channel set ' + adapter + '`, or add the env vars to .env.' },
216
+ }
217
+ }
218
+ return { status: 'ok', message: `${adapter} credentials present` }
219
+ }
220
+
221
+ // hasTokenForEnv resolves a single env-var-style credential the same way the
222
+ // runtime does, plus one host-stage-specific source: process.env > .env file >
223
+ // secrets.json. Empty strings count as unset, matching `src/secrets/resolve.ts`.
224
+ function hasTokenForEnv(
225
+ adapter: AdapterId,
226
+ envName: string,
227
+ dotEnv: Map<string, string>,
228
+ adapterSecrets: Record<string, unknown>,
229
+ ): boolean {
230
+ const fromProcess = process.env[envName]
231
+ if (fromProcess !== undefined && fromProcess !== '') return true
232
+ const fromDotEnv = dotEnv.get(envName)
233
+ if (fromDotEnv !== undefined && fromDotEnv !== '') return true
234
+ const fieldName = fieldNameForEnv(adapter, envName)
235
+ if (fieldName === undefined) return false
236
+ const secret = adapterSecrets[fieldName]
237
+ if (!isSecretShape(secret)) return false
238
+ const resolved = resolveSecretHostStage(secret, dotEnv, envName)
239
+ return resolved !== undefined && resolved !== ''
240
+ }
241
+
242
+ // resolveSecretHostStage mirrors `resolveSecret` precedence but adds a .env
243
+ // lookup before falling through to process.env. Doctor runs on the host and
244
+ // never executes the container, so .env values are not in process.env. For
245
+ // Secrets bound to a custom env var (e.g. `{ env: 'MY_TOKEN' }`), the runtime
246
+ // would resolve via process.env.MY_TOKEN inside the container — on the host
247
+ // that yields undefined even when the value is sitting in .env. So look up
248
+ // the custom env name in the parsed .env map first.
249
+ function resolveSecretHostStage(
250
+ secret: { value?: string; env?: string },
251
+ dotEnv: Map<string, string>,
252
+ defaultEnv?: string,
253
+ ): string | undefined {
254
+ const envName = secret.env ?? defaultEnv
255
+ if (envName !== undefined) {
256
+ const fromProcess = process.env[envName]
257
+ if (fromProcess !== undefined && fromProcess !== '') return fromProcess
258
+ const fromDotEnv = dotEnv.get(envName)
259
+ if (fromDotEnv !== undefined && fromDotEnv !== '') return fromDotEnv
260
+ }
261
+ return secret.value
262
+ }
263
+
264
+ function fieldNameForEnv(adapter: AdapterId, envName: string): string | undefined {
265
+ // Reverse-lookup using channelFieldDefaultEnv: scan the small set of known
266
+ // fields per adapter for the one whose default env matches. The set is
267
+ // tiny (1-2 entries) so the linear scan is fine.
268
+ const candidates: Record<string, readonly string[]> = {
269
+ 'slack-bot': ['botToken', 'appToken'],
270
+ 'discord-bot': ['token'],
271
+ 'telegram-bot': ['token'],
272
+ }
273
+ const fields = candidates[adapter]
274
+ if (!fields) return undefined
275
+ for (const field of fields) {
276
+ if (channelFieldDefaultEnv(adapter, field) === envName) return field
277
+ }
278
+ return undefined
279
+ }
280
+
281
+ function isSecretShape(value: unknown): value is { value?: string; env?: string } {
282
+ if (typeof value !== 'object' || value === null) return false
283
+ const obj = value as Record<string, unknown>
284
+ const hasValue = typeof obj['value'] === 'string'
285
+ const hasEnv = typeof obj['env'] === 'string'
286
+ return hasValue || hasEnv
287
+ }
288
+
289
+ function readDeclaredChannels(ctx: CheckContext): ChannelsConfig | null {
290
+ const cfg = safeLoadConfig(ctx)
291
+ return cfg?.channels ?? null
292
+ }
293
+
294
+ function safeLoadConfig(ctx: CheckContext): ReturnType<typeof loadConfigSync> | null {
295
+ const result = validateConfig(ctx.cwd)
296
+ if (!result.ok) return null
297
+ try {
298
+ return loadConfigSync(ctx.cwd)
299
+ } catch {
300
+ return null
301
+ }
302
+ }
303
+
304
+ function safeReadEnvFile(cwd: string): Map<string, string> {
305
+ try {
306
+ return readEnvFile(cwd)
307
+ } catch {
308
+ return new Map()
309
+ }
310
+ }
311
+
312
+ function readChannelsSecrets(ctx: CheckContext): ReturnType<SecretsBackend['tryReadChannelsSync']> {
313
+ try {
314
+ return new SecretsBackend(join(ctx.cwd, 'secrets.json')).tryReadChannelsSync()
315
+ } catch {
316
+ return null
317
+ }
318
+ }
319
+
320
+ function isAdapterActive(channels: ChannelsConfig, adapter: AdapterId): boolean {
321
+ const slot = channels[adapter]
322
+ if (slot === undefined) return false
323
+ return slot.enabled !== false
324
+ }
325
+
326
+ function configInvalidResult(): CheckResult {
327
+ return { status: 'skipped', message: 'config invalid (covered by config.valid)' }
328
+ }
@@ -21,6 +21,7 @@ import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
21
21
  import { detectMissingDeps } from '@/init/ensure-deps'
22
22
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
23
23
 
24
+ import { buildChannelChecks } from './channel-checks'
24
25
  import type { DoctorCheck } from './types'
25
26
 
26
27
  export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): DoctorCheck[] {
@@ -40,6 +41,7 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
40
41
  hostdRegistration(),
41
42
  containerState(dockerExec),
42
43
  containerHostPort(),
44
+ ...buildChannelChecks(),
43
45
  ]
44
46
  }
45
47
 
@@ -1,4 +1,9 @@
1
1
  import type { DockerfileConfig, DockerfileFeatureToggle } from '@/config/config'
2
+ import {
3
+ CLAUDE_CREDENTIALS_FILE_NAME,
4
+ CLAUDE_CREDENTIALS_RELATIVE_PATH,
5
+ CLAUDE_DEFAULT_CONFIG_DIR_NAME,
6
+ } from '@/secrets/export-claude-credentials-file'
2
7
 
3
8
  import { GHCR_BASE_IMAGE_REPO } from './cli-version'
4
9
 
@@ -33,7 +38,27 @@ export type BuildDockerfileOptions = {
33
38
  // self-heals: it spawns Xvfb (and exports DISPLAY) if the binary is on
34
39
  // PATH, and execs the agent directly otherwise. See APT_FEATURES.xvfb
35
40
  // below and `buildEntrypointShim`.
36
- const BASELINE_APT_PACKAGES = ['git', 'ca-certificates', 'curl', 'gnupg', 'iptables', 'util-linux'] as const
41
+ // `bubblewrap` ships the `bwrap(1)` setuid-less namespace sandboxer. It is
42
+ // included in baseline (not behind a toggle) because per-tool sandboxing of
43
+ // agent bash calls is a runtime concern resolved by the agent, not by the
44
+ // agent author. See `src/sandbox/` for the bwrap command builder, and
45
+ // `docs/internals/sandbox.mdx` for why bwrap is the right
46
+ // shape for per-call isolation inside an already-containerized agent. The
47
+ // outer container's `--security-opt seccomp=unconfined` (added in the same
48
+ // commit as this line; see `src/container/start.ts:planStart`) is what lets
49
+ // bwrap create user/pid/mount namespaces from inside Docker. Without that
50
+ // flag the seccomp default profile blocks `unshare(CLONE_NEWUSER)` and bwrap
51
+ // fails at startup. The two changes are load-bearing together — do not drop
52
+ // one without the other.
53
+ const BASELINE_APT_PACKAGES = [
54
+ 'git',
55
+ 'ca-certificates',
56
+ 'curl',
57
+ 'gnupg',
58
+ 'iptables',
59
+ 'util-linux',
60
+ 'bubblewrap',
61
+ ] as const
37
62
 
38
63
  // curl-impersonate is the only currently-working way to query DuckDuckGo from
39
64
  // a non-browser client on residential IPs in 2026. DDG fingerprints incoming
@@ -283,13 +308,19 @@ set -eu
283
308
  # no inbound exposure anyway.
284
309
  # link_persistent_home_files symlinks credential files that tools write
285
310
  # to $HOME into a bind-mounted location so they survive container
286
- # restarts. The canonical case is Codex CLI's ~/.codex/auth.json: codex
287
- # rewrites the file in place to rotate OAuth tokens, and the official
288
- # CI/CD guidance is to persist auth.json so refresh-token state
289
- # compounds across runs. The container's $HOME (/root by default) lives
290
- # on Docker's writable overlay and is wiped on every \`stop\`+\`start\`
291
- # cycle, so without this symlink the operator would have to re-paste
292
- # auth.json after every restart.
311
+ # restarts. The container's $HOME (/root by default) lives on Docker's
312
+ # writable overlay and is wiped on every \`stop\`+\`start\` cycle, so
313
+ # without this symlink the operator would have to re-paste credentials
314
+ # after every restart.
315
+ #
316
+ # Two files are linked today, both following the same contract:
317
+ # - ~/.codex/auth.json Codex CLI rotates OAuth tokens in place by
318
+ # rewriting auth.json with refreshed credentials.
319
+ # - $CLAUDE_CONFIG_DIR/.credentials.json, or ~/.claude/.credentials.json
320
+ # by default — Claude Code rotates OAuth tokens in place by rewriting
321
+ # .credentials.json on every successful refresh (anthropics/claude-code
322
+ # #53063). Linux/Windows path; macOS uses the Keychain entry "Claude
323
+ # Code-credentials" with the same JSON shape.
293
324
  #
294
325
  # The persist root lives under /agent/.typeclaw/home/ (bind-mounted
295
326
  # from the agent folder via the -v <cwd>:/agent flag in start.ts).
@@ -329,6 +360,12 @@ link_persistent_home_files() {
329
360
  persist_root="\${TYPECLAW_PERSIST_HOME_ROOT:-/agent/.typeclaw/home}"
330
361
  mkdir -p "$persist_root/.codex" "$HOME/.codex"
331
362
  ln -sfn "$persist_root/.codex/auth.json" "$HOME/.codex/auth.json"
363
+ claude_config_dir="\${CLAUDE_CONFIG_DIR:-}"
364
+ if [ -z "$claude_config_dir" ]; then
365
+ claude_config_dir="$HOME/${CLAUDE_DEFAULT_CONFIG_DIR_NAME}"
366
+ fi
367
+ mkdir -p "$persist_root/${CLAUDE_DEFAULT_CONFIG_DIR_NAME}" "$claude_config_dir"
368
+ ln -sfn "$persist_root/${CLAUDE_CREDENTIALS_RELATIVE_PATH}" "$claude_config_dir/${CLAUDE_CREDENTIALS_FILE_NAME}"
332
369
  }
333
370
 
334
371
  start_xvfb() {
package/src/run/index.ts CHANGED
@@ -43,7 +43,11 @@ import type { CronHandlerContext } from '@/plugin/types'
43
43
  import { createContainerBroker, publishForwardResult } from '@/portbroker'
44
44
  import { ReloadRegistry } from '@/reload'
45
45
  import { createClaimController } from '@/role-claim'
46
- import { exportCodexAuthFileForAgent, hydrateChannelEnvFromSecrets } from '@/secrets'
46
+ import {
47
+ exportClaudeCredentialsFileForAgent,
48
+ exportCodexAuthFileForAgent,
49
+ hydrateChannelEnvFromSecrets,
50
+ } from '@/secrets'
47
51
  import { createServer, type Server } from '@/server'
48
52
  import {
49
53
  createCommandRunner,
@@ -194,6 +198,19 @@ export async function startAgent({
194
198
  log: (message) => console.warn(message),
195
199
  })
196
200
 
201
+ // Same shape as the codex exporter above, gated on `docker.file.claudeCode`
202
+ // and `secrets.json#providers.anthropic`. Writes ~/.claude/.credentials.json
203
+ // so the Claude Code CLI in the container can run without the user pasting
204
+ // a CLAUDE_CODE_OAUTH_TOKEN. See src/secrets/export-claude-credentials-
205
+ // file.ts for the newer-wins compare that prevents clobbering Claude
206
+ // Code's in-place token refreshes, and the read-merge-write that preserves
207
+ // any mcpOAuth state in the file.
208
+ exportClaudeCredentialsFileForAgent({
209
+ agentDir: cwd,
210
+ claudeCodeEnabled: cwdConfig.docker.file.claudeCode,
211
+ log: (message) => console.warn(message),
212
+ })
213
+
197
214
  const claimController = createClaimController({
198
215
  cwd,
199
216
  permissions: pluginsLoaded.permissions,
@@ -0,0 +1,35 @@
1
+ import { SandboxUnavailableError } from './errors'
2
+
3
+ // Cached because the binary cannot appear or disappear during a single
4
+ // process lifetime, and a probe per bash call is wasted work. Keyed by the
5
+ // resolved bwrap path so a test (or a consumer pinning a non-default path)
6
+ // re-probes instead of reading another path's cached result.
7
+ const availabilityCache = new Map<string, boolean>()
8
+
9
+ export async function ensureBwrapAvailable(options?: { bwrapPath?: string }): Promise<void> {
10
+ const bwrap = options?.bwrapPath ?? 'bwrap'
11
+ const cached = availabilityCache.get(bwrap)
12
+ if (cached === true) return
13
+ if (cached === false) throw new SandboxUnavailableError()
14
+
15
+ const available = await probe(bwrap)
16
+ availabilityCache.set(bwrap, available)
17
+ if (!available) throw new SandboxUnavailableError()
18
+ }
19
+
20
+ async function probe(bwrap: string): Promise<boolean> {
21
+ // Bun.spawn throws synchronously with ENOENT when the binary is not on
22
+ // PATH, rather than resolving with a non-zero exit code — so the
23
+ // "not installed" case lands in the catch, not in proc.exitCode.
24
+ try {
25
+ const proc = Bun.spawn([bwrap, '--version'], { stdout: 'ignore', stderr: 'ignore' })
26
+ await proc.exited
27
+ return proc.exitCode === 0
28
+ } catch {
29
+ return false
30
+ }
31
+ }
32
+
33
+ export function _resetBwrapAvailabilityCacheForTests(): void {
34
+ availabilityCache.clear()
35
+ }
@@ -0,0 +1,128 @@
1
+ import { SandboxPolicyError } from './errors'
2
+ import {
3
+ DEFAULT_SANDBOX_ENV,
4
+ type SandboxCommandFilter,
5
+ type SandboxEnvPolicy,
6
+ type SandboxMount,
7
+ type SandboxPolicy,
8
+ } from './policy'
9
+ import { formatCommand } from './quote'
10
+
11
+ export type SandboxedCommand = {
12
+ argv: string[]
13
+ commandString: string
14
+ }
15
+
16
+ // Pure: no I/O, no bwrap availability probe (that is `ensureBwrapAvailable`'s
17
+ // job). Given a bash command and a policy, returns the bwrap-wrapped argv plus
18
+ // a shell-quoted rendering of it. Knows nothing about subagents, origins, or
19
+ // the agent runtime — a consumer resolves a policy from whatever context it
20
+ // has and calls this. Throws SandboxPolicyError only when the consumer opted
21
+ // into the command-filter knobs and the command violates them.
22
+ export function buildSandboxedCommand(command: string, policy: SandboxPolicy = {}): SandboxedCommand {
23
+ if (policy.commandFilter !== undefined) {
24
+ applyCommandFilter(command, policy.commandFilter)
25
+ }
26
+ const argv = buildArgv(command, policy)
27
+ return { argv, commandString: formatCommand(argv) }
28
+ }
29
+
30
+ function buildArgv(command: string, policy: SandboxPolicy): string[] {
31
+ const bwrap = policy.bwrapPath ?? 'bwrap'
32
+ const argv: string[] = [bwrap, '--unshare-all']
33
+
34
+ if (policy.network === 'inherit') {
35
+ // --unshare-all already unshared the net namespace; --share-net rejoins
36
+ // the outer container's network. Other namespaces (user/pid/mount/ipc/
37
+ // uts/cgroup) stay unshared. Default ('none' / undefined) leaves the net
38
+ // namespace isolated — prompt-injected bash cannot exfiltrate over the
39
+ // network without the consumer explicitly opting in.
40
+ argv.push('--share-net')
41
+ }
42
+
43
+ const proc = policy.process ?? {}
44
+ if (proc.newSession !== false) {
45
+ // Drops the controlling terminal so the contained process cannot push
46
+ // input back into the agent's tty via TIOCSTI. Mandated by
47
+ // docs/internals/sandbox.mdx. Harmless for a one-shot `bash -c`.
48
+ argv.push('--new-session')
49
+ }
50
+ if (proc.dieWithParent !== false) {
51
+ argv.push('--die-with-parent')
52
+ }
53
+
54
+ argv.push('--clearenv')
55
+ for (const [key, value] of Object.entries(resolveEnv(policy.env))) {
56
+ argv.push('--setenv', key, value)
57
+ }
58
+
59
+ argv.push('--ro-bind', '/usr', '/usr', '--ro-bind', '/etc', '/etc', '--dev', '/dev', '--tmpfs', '/tmp')
60
+
61
+ if ((policy.proc ?? 'tmpfs') === 'tmpfs') {
62
+ // --tmpfs /proc, never --proc /proc (OrbStack's kernel blocks
63
+ // mount("proc",...) from user namespaces) and never --dev-bind /proc /proc
64
+ // (leaks the outer container's /proc/N/environ — including
65
+ // FIREWORKS_API_KEY — into the sandbox). See sandbox.mdx.
66
+ argv.push('--tmpfs', '/proc')
67
+ }
68
+
69
+ for (const mount of policy.mounts ?? []) {
70
+ appendMount(argv, mount)
71
+ }
72
+
73
+ if (policy.cwd !== undefined) {
74
+ argv.push('--chdir', policy.cwd)
75
+ }
76
+
77
+ argv.push('bash', '-c', command)
78
+ return argv
79
+ }
80
+
81
+ function appendMount(argv: string[], mount: SandboxMount): void {
82
+ switch (mount.type) {
83
+ case 'ro-bind':
84
+ argv.push('--ro-bind', mount.source, mount.dest)
85
+ return
86
+ case 'bind':
87
+ argv.push('--bind', mount.source, mount.dest)
88
+ return
89
+ case 'tmpfs':
90
+ argv.push('--tmpfs', mount.dest)
91
+ return
92
+ case 'dev':
93
+ argv.push('--dev', mount.dest)
94
+ return
95
+ }
96
+ }
97
+
98
+ function resolveEnv(env: SandboxEnvPolicy | undefined): Record<string, string> {
99
+ const resolved: Record<string, string> = { ...DEFAULT_SANDBOX_ENV, ...env?.set }
100
+ for (const key of env?.passthrough ?? []) {
101
+ const value = process.env[key]
102
+ if (value !== undefined) resolved[key] = value
103
+ }
104
+ return resolved
105
+ }
106
+
107
+ // Token-boundary match: the normalized command must equal a prefix exactly or
108
+ // start with `prefix + ' '`. Substring matching would let `git-evil ...` slip
109
+ // past a `git` prefix; this does not.
110
+ const ALLOWLIST_WHITESPACE = /\s+/g
111
+ const FORBIDDEN_METACHARS = /[;&|`$()<>\\\n]/
112
+
113
+ function applyCommandFilter(command: string, filter: SandboxCommandFilter): void {
114
+ if (filter.rejectShellMetacharacters === true && FORBIDDEN_METACHARS.test(command)) {
115
+ throw new SandboxPolicyError(
116
+ 'command contains a forbidden shell metacharacter. This policy only permits simple commands without ; & | ` $ ( ) < > \\ or newlines.',
117
+ )
118
+ }
119
+ if (filter.allowPrefixes !== undefined) {
120
+ const normalized = command.trim().replace(ALLOWLIST_WHITESPACE, ' ')
121
+ const matched = filter.allowPrefixes.some((p) => normalized === p || normalized.startsWith(`${p} `))
122
+ if (!matched) {
123
+ throw new SandboxPolicyError(
124
+ `command does not match any allowed prefix. Allowed: ${filter.allowPrefixes.join(', ')}`,
125
+ )
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,20 @@
1
+ export class SandboxUnavailableError extends Error {
2
+ override readonly name = 'SandboxUnavailableError'
3
+ constructor() {
4
+ super(
5
+ 'sandbox unavailable: bwrap binary not found on PATH. Refusing to run a command that requires sandboxing without the kernel boundary in place.',
6
+ )
7
+ }
8
+ }
9
+
10
+ // Raised by the optional command-filter knobs (allowPrefixes,
11
+ // rejectShellMetacharacters). These are consumer-opt-in restrictions layered
12
+ // ABOVE the always-on kernel containment, so a rejection here is a policy
13
+ // decision the consumer asked for — not a failure of the sandbox itself. The
14
+ // message is phrased for the model to read and self-correct from.
15
+ export class SandboxPolicyError extends Error {
16
+ override readonly name = 'SandboxPolicyError'
17
+ constructor(reason: string) {
18
+ super(`sandbox policy rejected command: ${reason}`)
19
+ }
20
+ }
@@ -0,0 +1,14 @@
1
+ export { buildSandboxedCommand, type SandboxedCommand } from './build'
2
+ export { ensureBwrapAvailable } from './availability'
3
+ export { formatCommand, shellQuote } from './quote'
4
+ export { SandboxPolicyError, SandboxUnavailableError } from './errors'
5
+ export {
6
+ DEFAULT_SANDBOX_ENV,
7
+ type SandboxCommandFilter,
8
+ type SandboxEnvPolicy,
9
+ type SandboxMount,
10
+ type SandboxNetwork,
11
+ type SandboxPolicy,
12
+ type SandboxProcessPolicy,
13
+ type SandboxProcStrategy,
14
+ } from './policy'