typeclaw 0.34.1 → 0.35.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.
Files changed (35) hide show
  1. package/package.json +3 -1
  2. package/src/agent/plugin-tools.ts +53 -5
  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/plugin/context.ts +5 -1
  24. package/src/plugin/manager.ts +2 -0
  25. package/src/plugin/types.ts +1 -0
  26. package/src/run/index.ts +1 -0
  27. package/src/sandbox/build.ts +27 -0
  28. package/src/sandbox/index.ts +6 -0
  29. package/src/sandbox/package-install.ts +23 -0
  30. package/src/sandbox/policy.ts +31 -0
  31. package/src/sandbox/symlinks.ts +34 -0
  32. package/src/sandbox/writable-zones.ts +164 -4
  33. package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
  34. package/src/skills/typeclaw-github-contributing/SKILL.md +124 -0
  35. package/typeclaw.schema.json +32 -1
package/src/cli/qr.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { promisify } from 'node:util'
6
+
7
+ import QRCode from 'qrcode'
8
+
9
+ const execFileAsync = promisify(execFile)
10
+
11
+ // The upstream LINE SDK's QR login hands back a raw auth URL
12
+ // (https://line.me/R/au/q/...), which is not scannable on its own — the LINE
13
+ // mobile app needs an actual QR image. This module renders that URL the way the
14
+ // upstream `agent-line` CLI does: an HTML page opened in the browser plus, on a
15
+ // TTY, an inline ASCII QR. Every external effect is best-effort so a non-TTY or
16
+ // browserless host degrades to a machine-readable scan payload rather than
17
+ // blocking login.
18
+
19
+ export type QRPresentation = {
20
+ qrUrl: string
21
+ htmlPath: string | null
22
+ terminal: string | null
23
+ opened: boolean
24
+ }
25
+
26
+ export type DisplayQROptions = {
27
+ title: string
28
+ scanInstruction: string
29
+ brandColor?: string
30
+ isTty?: boolean
31
+ opener?: (filePath: string) => Promise<void>
32
+ tmpDir?: string
33
+ now?: () => number
34
+ }
35
+
36
+ export async function buildQRHtml(
37
+ url: string,
38
+ options: { title: string; scanInstruction: string; brandColor: string },
39
+ ): Promise<string> {
40
+ const svg = await QRCode.toString(url, { type: 'svg', margin: 2 })
41
+ const title = escapeHtml(options.title)
42
+ const instruction = escapeHtml(options.scanInstruction)
43
+ return `<!DOCTYPE html>
44
+ <html><head><meta charset="utf-8"><title>${title}</title>
45
+ <style>body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;font-family:-apple-system,system-ui,sans-serif;background:${options.brandColor}}
46
+ .card{background:#fff;border-radius:16px;padding:40px;text-align:center;box-shadow:0 4px 24px rgba(0,0,0,.15)}
47
+ h1{margin:0 0 8px;font-size:22px;color:#111}p{margin:0 0 24px;color:#666;font-size:14px}
48
+ svg{width:280px;height:280px}</style></head>
49
+ <body><div class="card"><h1>${title}</h1><p>${instruction}</p>${svg}</div></body></html>`
50
+ }
51
+
52
+ export async function renderTerminalQR(url: string): Promise<string> {
53
+ // 'L' error correction yields the fewest modules, so the QR stays small
54
+ // enough to scan from a terminal over SSH where screen real estate is the
55
+ // binding constraint. A short-lived auth URL doesn't need higher ECC.
56
+ return QRCode.toString(url, { type: 'terminal', small: true, errorCorrectionLevel: 'L' })
57
+ }
58
+
59
+ // Writes the QR HTML to a temp file and tries to open it in the default
60
+ // browser, and (when on a TTY) renders an inline ASCII QR. Every external
61
+ // effect is best-effort: a failure to write, open, or render degrades the
62
+ // result rather than throwing, so login is never blocked by presentation.
63
+ export async function displayQR(url: string, options: DisplayQROptions): Promise<QRPresentation> {
64
+ const brandColor = options.brandColor ?? '#06C755'
65
+ const isTty = options.isTty ?? process.stderr.isTTY === true
66
+ const now = options.now ?? Date.now
67
+ const dir = options.tmpDir ?? tmpdir()
68
+
69
+ const htmlPath = await writeQRHtmlFile(url, {
70
+ title: options.title,
71
+ scanInstruction: options.scanInstruction,
72
+ brandColor,
73
+ dir,
74
+ stamp: now(),
75
+ })
76
+
77
+ let opened = false
78
+ if (htmlPath !== null) {
79
+ const open = options.opener ?? openInBrowser
80
+ opened = await open(htmlPath).then(
81
+ () => true,
82
+ () => false,
83
+ )
84
+ }
85
+
86
+ const terminal = isTty ? await renderTerminalQR(url).catch(() => null) : null
87
+
88
+ return { qrUrl: url, htmlPath, terminal, opened }
89
+ }
90
+
91
+ async function writeQRHtmlFile(
92
+ url: string,
93
+ options: { title: string; scanInstruction: string; brandColor: string; dir: string; stamp: number },
94
+ ): Promise<string | null> {
95
+ try {
96
+ const html = await buildQRHtml(url, options)
97
+ const slug =
98
+ options.title
99
+ .toLowerCase()
100
+ .replace(/[^a-z0-9]+/g, '-')
101
+ .replace(/^-+|-+$/g, '') || 'qr'
102
+ const htmlPath = join(options.dir, `typeclaw-${slug}-${options.stamp}.html`)
103
+ await writeFile(htmlPath, html, { mode: 0o600 })
104
+ return htmlPath
105
+ } catch {
106
+ return null
107
+ }
108
+ }
109
+
110
+ async function openInBrowser(filePath: string): Promise<void> {
111
+ const platform = process.platform
112
+ if (platform === 'darwin') {
113
+ await execFileAsync('open', [filePath])
114
+ return
115
+ }
116
+ if (platform === 'win32') {
117
+ await execFileAsync('cmd', ['/c', 'start', '', filePath])
118
+ return
119
+ }
120
+ await execFileAsync('xdg-open', [filePath])
121
+ }
122
+
123
+ function escapeHtml(value: string): string {
124
+ return value
125
+ .replace(/&/g, '&amp;')
126
+ .replace(/</g, '&lt;')
127
+ .replace(/>/g, '&gt;')
128
+ .replace(/"/g, '&quot;')
129
+ .replace(/'/g, '&#39;')
130
+ }
@@ -1,6 +1,6 @@
1
1
  import { accessSync, constants as fsConstants, readFileSync, statSync, writeFileSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
- import { isAbsolute, join, resolve } from 'node:path'
3
+ import { isAbsolute, join, posix, resolve } from 'node:path'
4
4
 
5
5
  import type { Model } from '@mariozechner/pi-ai'
6
6
  import { z } from 'zod'
@@ -361,11 +361,98 @@ export type NetworkConfig = z.infer<typeof networkSchema>
361
361
  // mount actually works (bare-metal Linux, Docker Desktop — NOT OrbStack, which
362
362
  // rejects the mount even with the cap; there the runtime falls back to
363
363
  // 'proc-bind' regardless). The cost is the CAP_SYS_ADMIN grant on the container.
364
+
365
+ // `sandbox.writablePaths` re-exposes operator-chosen subtrees of the agent
366
+ // folder as WRITABLE inside the per-tool bwrap sandbox, on top of the built-in
367
+ // free-write zones (workspace, public, mounts, .git). It exists for tools that
368
+ // insist on writing a fixed config dir a low-trust role would otherwise hit
369
+ // EROFS on (e.g. a CLI that rewrites `<agentDir>/.foo-cli/config.json`).
370
+ //
371
+ // Each entry is AGENT-ROOT-RELATIVE — it resolves under /agent and may not
372
+ // escape it. Absolute container paths are rejected at parse time: a blanket RW
373
+ // bind outside /agent would punch a hole through the agent trust boundary that
374
+ // the rest of the sandbox model assumes can't happen. `..` segments and
375
+ // null bytes are rejected for the same reason. Targets that don't exist, aren't
376
+ // directories, are symlinks, or land on a security-sensitive path
377
+ // (.git, .env, secrets.json, sessions, memory, .typeclaw, node_modules, the
378
+ // agent root itself) are dropped at resolve time, NOT parse time — existence is
379
+ // a runtime property and the drop keeps a stale config from aborting the
380
+ // sandbox. See resolveWritableZones in src/sandbox/writable-zones.ts.
381
+ export const relativeAgentPathSchema = z
382
+ .string()
383
+ .min(1)
384
+ .refine((value) => !isAbsolute(value), 'must be relative to the agent root, not an absolute path')
385
+ .refine((value) => !value.includes('\0'), 'must not contain a null byte')
386
+ .refine((value) => !value.split(/[/\\]+/).includes('..'), "must not contain a '..' segment")
387
+
388
+ // `sandbox.symlinks` is the one-entry abstraction for the common case of a CLI
389
+ // that reads its config from a fixed path the sandbox can't write: it (1) creates
390
+ // the symlink `from -> /agent/<to>` and (2) makes `<to>` a writable zone (same
391
+ // machinery as `writablePaths` — every `to` is folded into the writable set).
392
+ //
393
+ // `from` is the symlink LOCATION and is fully configurable: an absolute container
394
+ // path (e.g. `/root/.metabase-cli`) or a `~/`-prefixed path expanded against the
395
+ // stage's HOME. Two stages create it: the entrypoint shim creates it at the real
396
+ // container HOME (/root) for trusted/owner roles whose bash runs UNSANDBOXED, and
397
+ // the per-tool bwrap sandbox emits a `--symlink` at the sandbox HOME (/tmp) for
398
+ // low-trust roles — because `$HOME` differs between the two stages, a `~/` from
399
+ // resolves to a different absolute path in each, which is exactly what each
400
+ // consumer needs. The entrypoint refuses to clobber an existing non-symlink.
401
+ //
402
+ // `from` SECURITY: it must not contain a null byte, must not be the root `/`, and
403
+ // must not point INTO /agent (a self-referential loop). Kernel/virtual paths
404
+ // (/proc, /sys, /dev, /run) are rejected — symlinking over them is never a real
405
+ // config need and risks masking the runtime's view of them. `/etc/...` is allowed
406
+ // (a legitimate use case) because the entrypoint's no-clobber guard already stops
407
+ // it from overwriting an existing system file. `to` reuses relativeAgentPathSchema.
408
+ //
409
+ // `..` is rejected OUTRIGHT, before the /agent and kernel-root bans run. Those
410
+ // bans previously inspected the RAW string, which both consumers later normalize
411
+ // against $HOME — so a `~/../agent/workspace/.foo` (→ /agent/...) or
412
+ // `~/../proc/x` (→ /proc/...) slipped past a startsWith('/agent') /
413
+ // startsWith('/proc') check on the un-normalized text. A traversal segment is the
414
+ // ONLY way the post-$HOME effective path can re-enter a banned root, so banning
415
+ // `..` makes the raw-string bans equivalent to checking the effective path —
416
+ // stage-independent, no need to expand $HOME at parse time. The bans then run on
417
+ // the POSIX-normalized form so an absolute `from` like `/var/../proc` is caught
418
+ // even though it has no leading `/proc` literal.
419
+ const FORBIDDEN_SYMLINK_FROM_ROOTS = ['/proc', '/sys', '/dev', '/run'] as const
420
+ function normalizedSymlinkFrom(value: string): string {
421
+ // The `~/` prefix is not a real path component; normalize only the remainder
422
+ // so a `~/a/b` stays `~/a/b` while `/var/../proc` collapses to `/proc`.
423
+ if (value.startsWith('~/')) return `~/${posix.normalize(value.slice(2))}`
424
+ return posix.normalize(value)
425
+ }
426
+ export const symlinkFromSchema = z
427
+ .string()
428
+ .min(1)
429
+ .refine((value) => !value.includes('\0'), 'must not contain a null byte')
430
+ .refine((value) => value.startsWith('~/') || isAbsolute(value), 'must be an absolute path or start with ~/')
431
+ .refine((value) => !value.split(/[/\\]+/).includes('..'), "must not contain a '..' segment")
432
+ .refine((value) => normalizedSymlinkFrom(value) !== '/', 'must not be the filesystem root')
433
+ .refine((value) => {
434
+ const normalized = normalizedSymlinkFrom(value)
435
+ return !normalized.startsWith('/agent/') && normalized !== '/agent'
436
+ }, 'must not point into /agent (the symlink would loop back into the agent folder)')
437
+ .refine((value) => {
438
+ const normalized = normalizedSymlinkFrom(value)
439
+ return !FORBIDDEN_SYMLINK_FROM_ROOTS.some((root) => normalized === root || normalized.startsWith(`${root}/`))
440
+ }, 'must not point at a kernel/virtual path (/proc, /sys, /dev, /run)')
441
+
442
+ export const symlinkSchema = z.object({
443
+ from: symlinkFromSchema,
444
+ to: relativeAgentPathSchema,
445
+ })
446
+
447
+ export type SandboxSymlink = z.infer<typeof symlinkSchema>
448
+
364
449
  export const sandboxSchema = z
365
450
  .object({
366
451
  realProc: z.boolean().default(false),
452
+ writablePaths: z.array(relativeAgentPathSchema).default([]),
453
+ symlinks: z.array(symlinkSchema).default([]),
367
454
  })
368
- .default({ realProc: false })
455
+ .default({ realProc: false, writablePaths: [], symlinks: [] })
369
456
 
370
457
  export type SandboxConfig = z.infer<typeof sandboxSchema>
371
458
 
@@ -592,6 +679,15 @@ export function expandMountPath(input: string, cwd: string): string {
592
679
  return isAbsolute(input) ? input : resolve(cwd, input)
593
680
  }
594
681
 
682
+ // The full set of agent-relative dirs the sandbox should make writable: the
683
+ // explicit `sandbox.writablePaths` plus every `sandbox.symlinks[].to` (so an
684
+ // operator declaring a symlink doesn't also have to list its target). Order is
685
+ // stable (writablePaths first) and duplicates are harmless — resolveWritableZones
686
+ // dedupes after resolving each to an absolute path.
687
+ export function getSandboxWritablePathSpecs(cfg: Pick<Config, 'sandbox'>): string[] {
688
+ return [...cfg.sandbox.writablePaths, ...cfg.sandbox.symlinks.map((link) => link.to)]
689
+ }
690
+
595
691
  // Loaded eagerly from process.cwd()/typeclaw.json at module-import time so
596
692
  // citty arg defaults (e.g. config.port in src/cli/*.ts) see real values, not
597
693
  // hardcoded fallbacks. Missing file → schema defaults; malformed file → ALSO
@@ -533,6 +533,18 @@ export async function planStart({
533
533
  runArgs.push('--cap-add=SYS_ADMIN')
534
534
  }
535
535
 
536
+ // sandbox.symlinks: the entrypoint shim creates `from -> /agent/<to>` at the
537
+ // real container HOME for the UNSANDBOXED (trusted/owner) bash path. The
538
+ // low-trust path is handled separately by the per-tool bwrap --symlink op
539
+ // (src/sandbox/build.ts). Passed as base64-encoded JSON because `from`/`to`
540
+ // are arbitrary operator strings — base64 sidesteps every shell-metachar and
541
+ // env-quoting hazard; the shim decodes + JSON-parses it with bun. Omitted when
542
+ // empty so the common case adds no env clutter and the shim's loop never runs.
543
+ if (cfg.sandbox.symlinks.length > 0) {
544
+ const encoded = Buffer.from(JSON.stringify(cfg.sandbox.symlinks), 'utf8').toString('base64')
545
+ runArgs.push('-e', `TYPECLAW_SANDBOX_SYMLINKS=${encoded}`)
546
+ }
547
+
536
548
  if (hostdControl) {
537
549
  runArgs.push('--add-host', HOST_GATEWAY_ALIAS)
538
550
  }
@@ -385,6 +385,68 @@ link_persistent_home_files() {
385
385
  ln -sfn "$persist_root/${CLAUDE_CREDENTIALS_RELATIVE_PATH}" "$claude_config_dir/${CLAUDE_CREDENTIALS_FILE_NAME}"
386
386
  }
387
387
 
388
+ # link_configured_symlinks creates the operator's \`sandbox.symlinks\` at the real
389
+ # container $HOME for the UNSANDBOXED (trusted/owner) bash path; low-trust bash
390
+ # gets an equivalent in-jail symlink from the per-tool bwrap builder instead
391
+ # (src/sandbox/build.ts). TYPECLAW_SANDBOX_SYMLINKS is base64-encoded JSON of
392
+ # [{from,to}] (set by start.ts only when non-empty). The whole job is done in
393
+ # \`bun -e\` rather than POSIX shell because \`from\`/\`to\` are arbitrary operator
394
+ # strings: a real JSON parser + Node fs API sidesteps every word-splitting,
395
+ # glob, and metachar hazard that shell string-handling of untrusted paths
396
+ # carries. Contract enforced here (belt to the config-parse validation in
397
+ # config.ts): \`from\` is expanded against $HOME for a leading \`~/\`, \`to\` resolves
398
+ # under /agent and may not escape it, and an existing NON-symlink at \`from\` is
399
+ # refused (never clobbered) — a dangling/symlink \`from\` is replaced idempotently
400
+ # with \`ln -sfn\` semantics (force + no-deref). Failures are logged and skipped
401
+ # per-entry so one bad symlink never blocks container boot.
402
+ #
403
+ # TYPECLAW_AGENT_DIR defaults to /agent (the bind-mount path) and is overridable
404
+ # only by the shim's behavioral tests, which point it at a tmpdir — same escape
405
+ # hatch as TYPECLAW_PERSIST_HOME_ROOT in link_persistent_home_files. Production
406
+ # never sets it.
407
+ link_configured_symlinks() {
408
+ [ -n "\${TYPECLAW_SANDBOX_SYMLINKS:-}" ] || return 0
409
+ TYPECLAW_SANDBOX_SYMLINKS="$TYPECLAW_SANDBOX_SYMLINKS" HOME="$HOME" \\
410
+ TYPECLAW_AGENT_DIR="\${TYPECLAW_AGENT_DIR:-/agent}" bun -e '
411
+ import { lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
412
+ import { dirname, join, normalize, resolve } from "node:path";
413
+ const home = process.env.HOME || "/root";
414
+ const agentDir = process.env.TYPECLAW_AGENT_DIR || "/agent";
415
+ let specs;
416
+ try {
417
+ specs = JSON.parse(Buffer.from(process.env.TYPECLAW_SANDBOX_SYMLINKS, "base64").toString("utf8"));
418
+ } catch (e) {
419
+ console.error("typeclaw-entrypoint: could not parse TYPECLAW_SANDBOX_SYMLINKS:", String(e));
420
+ process.exit(0);
421
+ }
422
+ for (const spec of Array.isArray(specs) ? specs : []) {
423
+ const from = String(spec?.from ?? "");
424
+ const to = String(spec?.to ?? "");
425
+ if (from === "" || to === "") continue;
426
+ const fromAbs = from.startsWith("~/") ? join(home, from.slice(2)) : normalize(from);
427
+ const target = resolve(agentDir, to);
428
+ if (target !== agentDir && !target.startsWith(agentDir + "/")) {
429
+ console.error("typeclaw-entrypoint: skip symlink, target escapes /agent:", to);
430
+ continue;
431
+ }
432
+ try {
433
+ mkdirSync(target, { recursive: true });
434
+ mkdirSync(dirname(fromAbs), { recursive: true });
435
+ let existing;
436
+ try { existing = lstatSync(fromAbs); } catch { existing = undefined; }
437
+ if (existing && !existing.isSymbolicLink()) {
438
+ console.error("typeclaw-entrypoint: refusing to clobber existing non-symlink at", fromAbs);
439
+ continue;
440
+ }
441
+ if (existing) rmSync(fromAbs);
442
+ symlinkSync(target, fromAbs);
443
+ } catch (e) {
444
+ console.error("typeclaw-entrypoint: failed to create symlink", fromAbs, "->", target, ":", String(e));
445
+ }
446
+ }
447
+ ' || true
448
+ }
449
+
388
450
  start_xvfb() {
389
451
  if ! command -v Xvfb >/dev/null 2>&1; then
390
452
  return 0
@@ -419,6 +481,7 @@ start_xvfb() {
419
481
 
420
482
  if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
421
483
  link_persistent_home_files
484
+ link_configured_symlinks
422
485
  start_xvfb
423
486
  exec bun run typeclaw "$@"
424
487
  fi
@@ -465,6 +528,7 @@ ip6tables -A OUTPUT -o lo -j ACCEPT
465
528
  ${ipv6Rules.join('\n')}
466
529
 
467
530
  link_persistent_home_files
531
+ link_configured_symlinks
468
532
  start_xvfb
469
533
  exec setpriv --bounding-set -net_admin --inh-caps -net_admin --ambient-caps -net_admin -- bun run typeclaw "$@"
470
534
  `
@@ -7,7 +7,7 @@ import { SecretsLineCredentialStore } from '@/secrets/line-store'
7
7
  export type LineBootstrapStatus = { ok: true } | { ok: false; reason: string }
8
8
 
9
9
  export type LineLoginCallbacks = {
10
- onQRUrl?: (url: string) => void
10
+ onQRUrl?: (url: string) => void | Promise<void>
11
11
  onPincode: (pin: string) => void
12
12
  }
13
13
 
@@ -33,7 +33,10 @@ export type LineLoginInput =
33
33
  // Structural subset of the upstream LineClient the bootstrap drives. Declared
34
34
  // here so tests can inject a fake without standing up the real LOCO client.
35
35
  export type LineLoginClient = {
36
- loginWithQR(options: { onQRUrl: (url: string) => void; onPincode: (pin: string) => void }): Promise<LineLoginResult>
36
+ loginWithQR(options: {
37
+ onQRUrl: (url: string) => void | Promise<void>
38
+ onPincode: (pin: string) => void
39
+ }): Promise<LineLoginResult>
37
40
  loginWithEmail(options: {
38
41
  email: string
39
42
  password: string
@@ -58,7 +61,9 @@ export async function runLineBootstrap(input: LineLoginInput): Promise<LineBoots
58
61
  const result =
59
62
  input.method === 'qr'
60
63
  ? await client.loginWithQR({
61
- onQRUrl: (url) => input.callbacks.onQRUrl?.(url),
64
+ onQRUrl: async (url) => {
65
+ await input.callbacks.onQRUrl?.(url)
66
+ },
62
67
  onPincode: input.callbacks.onPincode,
63
68
  })
64
69
  : await client.loginWithEmail({
@@ -13,6 +13,7 @@ export type CreatePluginContextOptions<TConfig> = {
13
13
  logger: PluginLogger
14
14
  permissions: PermissionService
15
15
  resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
16
+ hasGithubAppTokenResolver?: () => boolean
16
17
  spawnSubagent: SpawnSubagentFn
17
18
  isBooted: () => boolean
18
19
  }
@@ -30,7 +31,10 @@ export function createPluginContext<TConfig>(opts: CreatePluginContextOptions<TC
30
31
  config: opts.config,
31
32
  logger: opts.logger,
32
33
  permissions: opts.permissions,
33
- github: { resolveTokenForRepo: opts.resolveGithubTokenForRepo ?? githubTokenUnavailable },
34
+ github: {
35
+ resolveTokenForRepo: opts.resolveGithubTokenForRepo ?? githubTokenUnavailable,
36
+ hasAppTokenResolver: opts.hasGithubAppTokenResolver ?? (() => false),
37
+ },
34
38
  spawnSubagent: async (name: string, payload?: unknown, options?: SpawnSubagentOptions) => {
35
39
  if (!opts.isBooted()) {
36
40
  throw new Error(
@@ -22,6 +22,7 @@ export type LoadPluginsOptions = {
22
22
  loadEntry?: LoadPluginEntryFn
23
23
  roles?: RolesConfig
24
24
  resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
25
+ hasGithubAppTokenResolver?: () => boolean
25
26
  // Bundled plugins resolved by the runtime (not from typeclaw.json). Loaded
26
27
  // before user-declared `entries` so a config block named after a bundled
27
28
  // plugin (e.g. "memory") is consumed by the bundled plugin, and so plugin-
@@ -104,6 +105,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
104
105
  logger,
105
106
  permissions,
106
107
  resolveGithubTokenForRepo: opts.resolveGithubTokenForRepo,
108
+ hasGithubAppTokenResolver: opts.hasGithubAppTokenResolver,
107
109
  spawnSubagent: (name, payload, options) => spawnSubagentImpl(name, payload, options),
108
110
  isBooted: () => booted,
109
111
  })
@@ -280,6 +280,7 @@ export type PluginContext<TConfig = never> = {
280
280
 
281
281
  export type PluginGithubServices = {
282
282
  resolveTokenForRepo: ResolveGithubTokenForRepo
283
+ hasAppTokenResolver: () => boolean
283
284
  }
284
285
 
285
286
  export type PluginExports = {
package/src/run/index.ts CHANGED
@@ -167,6 +167,7 @@ export async function startAgent({
167
167
  configsByName: pluginConfigsByName,
168
168
  bundled: BUNDLED_PLUGINS,
169
169
  resolveGithubTokenForRepo: githubTokenBridge.resolveTokenForRepo,
170
+ hasGithubAppTokenResolver: githubTokenBridge.hasAppTokenResolver,
170
171
  ...(cwdConfig.roles !== undefined ? { roles: cwdConfig.roles } : {}),
171
172
  })
172
173
 
@@ -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':
@@ -11,12 +11,16 @@ export {
11
11
  } from './availability'
12
12
  export { resolveHiddenPaths, type HiddenPaths } from './hidden-paths'
13
13
  export {
14
+ resolvePackageInstallZones,
14
15
  resolveProtectedZones,
15
16
  resolveWritableZones,
16
17
  subtractMasked,
18
+ type PackageInstallZones,
17
19
  type ProtectedZones,
18
20
  type WritableZones,
19
21
  } from './writable-zones'
22
+ export { resolveSandboxSymlinks, type SandboxSymlinkSpec } from './symlinks'
23
+ export { isPackageInstallCommand } from './package-install'
20
24
  export { ensureSessionTmpDir, isUnderTmp, mapVirtualTmpPath, SESSION_TMP_ROOT, sessionTmpDir } from './session-tmp'
21
25
  export { formatCommand, shellQuote } from './quote'
22
26
  export { SandboxPolicyError, SandboxUnavailableError } from './errors'
@@ -30,5 +34,7 @@ export {
30
34
  type SandboxProcessPolicy,
31
35
  type SandboxProcStrategy,
32
36
  type SandboxProtectedPolicy,
37
+ type SandboxSymlinkOp,
33
38
  type SandboxWritablePolicy,
39
+ type SandboxWritableRootPolicy,
34
40
  } 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
+ }