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.
- package/package.json +3 -1
- package/src/agent/plugin-tools.ts +53 -5
- package/src/agent/provider-error.ts +10 -0
- package/src/agent/session-origin.ts +26 -0
- package/src/agent/tools/channel-disengage.ts +13 -9
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +124 -6
- package/src/bundled-plugins/github-cli-auth/git-command.ts +172 -26
- package/src/bundled-plugins/github-cli-auth/index.ts +46 -7
- package/src/bundled-plugins/github-cli-auth/token-class.ts +13 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +33 -2
- package/src/channels/adapters/github/inbound.ts +41 -3
- package/src/channels/adapters/slack-bot.ts +17 -9
- package/src/channels/continuation-willingness.ts +331 -0
- package/src/channels/github-review-claim.ts +105 -0
- package/src/channels/github-token-bridge.ts +7 -0
- package/src/channels/router.ts +103 -24
- package/src/cli/channel.ts +102 -11
- package/src/cli/qr.ts +130 -0
- package/src/config/config.ts +98 -2
- package/src/container/start.ts +12 -0
- package/src/init/dockerfile.ts +64 -0
- package/src/init/line-auth.ts +8 -3
- package/src/plugin/context.ts +5 -1
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/types.ts +1 -0
- package/src/run/index.ts +1 -0
- package/src/sandbox/build.ts +27 -0
- package/src/sandbox/index.ts +6 -0
- package/src/sandbox/package-install.ts +23 -0
- package/src/sandbox/policy.ts +31 -0
- package/src/sandbox/symlinks.ts +34 -0
- package/src/sandbox/writable-zones.ts +164 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
- package/src/skills/typeclaw-github-contributing/SKILL.md +124 -0
- 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, '&')
|
|
126
|
+
.replace(/</g, '<')
|
|
127
|
+
.replace(/>/g, '>')
|
|
128
|
+
.replace(/"/g, '"')
|
|
129
|
+
.replace(/'/g, ''')
|
|
130
|
+
}
|
package/src/config/config.ts
CHANGED
|
@@ -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
|
package/src/container/start.ts
CHANGED
|
@@ -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
|
}
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -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
|
`
|
package/src/init/line-auth.ts
CHANGED
|
@@ -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: {
|
|
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) =>
|
|
64
|
+
onQRUrl: async (url) => {
|
|
65
|
+
await input.callbacks.onQRUrl?.(url)
|
|
66
|
+
},
|
|
62
67
|
onPincode: input.callbacks.onPincode,
|
|
63
68
|
})
|
|
64
69
|
: await client.loginWithEmail({
|
package/src/plugin/context.ts
CHANGED
|
@@ -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: {
|
|
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(
|
package/src/plugin/manager.ts
CHANGED
|
@@ -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
|
})
|
package/src/plugin/types.ts
CHANGED
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
|
|
package/src/sandbox/build.ts
CHANGED
|
@@ -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':
|
package/src/sandbox/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/sandbox/policy.ts
CHANGED
|
@@ -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
|
+
}
|