typeclaw 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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'
@@ -0,0 +1,47 @@
1
+ export type SandboxMount =
2
+ | { type: 'ro-bind'; source: string; dest: string }
3
+ | { type: 'bind'; source: string; dest: string }
4
+ | { type: 'tmpfs'; dest: string }
5
+ | { type: 'dev'; dest: string }
6
+
7
+ export type SandboxNetwork = 'none' | 'inherit'
8
+
9
+ export type SandboxProcStrategy = 'tmpfs' | 'none'
10
+
11
+ export type SandboxEnvPolicy = {
12
+ set?: Record<string, string>
13
+ passthrough?: string[]
14
+ }
15
+
16
+ export type SandboxCommandFilter = {
17
+ allowPrefixes?: string[]
18
+ rejectShellMetacharacters?: boolean
19
+ }
20
+
21
+ export type SandboxProcessPolicy = {
22
+ newSession?: boolean
23
+ dieWithParent?: boolean
24
+ }
25
+
26
+ export type SandboxPolicy = {
27
+ bwrapPath?: string
28
+ cwd?: string
29
+ mounts?: SandboxMount[]
30
+ network?: SandboxNetwork
31
+ env?: SandboxEnvPolicy
32
+ commandFilter?: SandboxCommandFilter
33
+ process?: SandboxProcessPolicy
34
+ proc?: SandboxProcStrategy
35
+ }
36
+
37
+ // The env the sandbox always re-introduces after `--clearenv`. Anything not
38
+ // listed here (or explicitly named in `env.set` / `env.passthrough` by the
39
+ // consumer) is invisible inside the sandbox. This is the load-bearing leak
40
+ // guard: the container env holds FIREWORKS_API_KEY and GH_TOKEN, and env
41
+ // inheritance is the single highest-risk exfil path for prompt-injected bash.
42
+ // HOME points at /tmp because the sandbox mounts /tmp as a fresh tmpfs.
43
+ export const DEFAULT_SANDBOX_ENV: Record<string, string> = {
44
+ PATH: '/usr/local/bin:/usr/bin:/bin',
45
+ HOME: '/tmp',
46
+ LANG: 'C.UTF-8',
47
+ }
@@ -0,0 +1,18 @@
1
+ // POSIX shell quoting for rendering a bwrap argv array into a single
2
+ // `bash -c`-safe string. Today's bash tool accepts a string `command` slot
3
+ // (`mutableArgs.command`), so the sandbox primitive renders its canonical
4
+ // argv into a quoted string the agent runtime can drop in unchanged.
5
+ //
6
+ // This is a local copy of the same helper in `src/update/index.ts`. It is
7
+ // deliberately not promoted to a shared module yet: two call sites do not
8
+ // justify the coupling, and this primitive is meant to stand alone with zero
9
+ // imports from the rest of the tree. Promote to `src/shared/shell.ts` only
10
+ // when a third independent consumer appears.
11
+ export function shellQuote(arg: string): string {
12
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(arg)) return arg
13
+ return `'${arg.replaceAll("'", "'\\''")}'`
14
+ }
15
+
16
+ export function formatCommand(argv: readonly string[]): string {
17
+ return argv.map(shellQuote).join(' ')
18
+ }
@@ -1121,7 +1121,9 @@ function handleInspectMessage(
1121
1121
 
1122
1122
  if (stream !== undefined && typeof msg.sinceMs === 'number') {
1123
1123
  for (const event of stream.scan({ sinceTs: msg.sinceMs, target: { kind: 'broadcast' } })) {
1124
- sendInspect(ws, { type: 'frame', ts: event.ts, payload: broadcastEventToFrame(event) })
1124
+ const payload = broadcastEventToFrame(event)
1125
+ if (!isFrameForWatchedSession(payload, msg.sessionId)) continue
1126
+ sendInspect(ws, { type: 'frame', ts: event.ts, payload })
1125
1127
  }
1126
1128
  for (const event of stream.scan({ sinceTs: msg.sinceMs, target: { kind: 'cron' } })) {
1127
1129
  sendInspect(ws, {
@@ -1143,7 +1145,9 @@ function handleInspectMessage(
1143
1145
 
1144
1146
  if (stream !== undefined) {
1145
1147
  ws.data.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (event) => {
1146
- sendInspect(ws, { type: 'frame', ts: event.ts, payload: broadcastEventToFrame(event) })
1148
+ const payload = broadcastEventToFrame(event)
1149
+ if (!isFrameForWatchedSession(payload, msg.sessionId)) return
1150
+ sendInspect(ws, { type: 'frame', ts: event.ts, payload })
1147
1151
  })
1148
1152
  ws.data.unsubCron = stream.subscribe({ target: { kind: 'cron' } }, (event) => {
1149
1153
  sendInspect(ws, {
@@ -1171,6 +1175,15 @@ function broadcastEventToFrame(event: StreamMessage): InspectFramePayload {
1171
1175
  }
1172
1176
  }
1173
1177
 
1178
+ // Channel inbounds are published as global broadcasts, so every inspect client
1179
+ // receives every session's inbounds. Drop the ones that don't belong to the
1180
+ // session being watched. Non-inbound broadcasts (subagent completions, cron,
1181
+ // tunnels) stay global — they carry no session identity here.
1182
+ function isFrameForWatchedSession(payload: InspectFramePayload, watchedSessionId: string): boolean {
1183
+ if (payload.kind !== 'channel_inbound') return true
1184
+ return payload.sessionId === watchedSessionId
1185
+ }
1186
+
1174
1187
  function readChannelInboundBroadcast(payload: unknown): InspectFramePayload | null {
1175
1188
  if (typeof payload !== 'object' || payload === null) return null
1176
1189
  const p = payload as Record<string, unknown>
@@ -1191,6 +1204,7 @@ function readChannelInboundBroadcast(payload: unknown): InspectFramePayload | nu
1191
1204
  if (decision !== 'engage' && decision !== 'observe' && decision !== 'denied' && decision !== 'claim') return null
1192
1205
  return {
1193
1206
  kind: 'channel_inbound',
1207
+ ...(typeof p.sessionId === 'string' ? { sessionId: p.sessionId } : {}),
1194
1208
  adapter: p.adapter,
1195
1209
  workspace: p.workspace,
1196
1210
  chat: p.chat,
@@ -24,10 +24,4 @@ export {
24
24
  type TunnelSnapshot,
25
25
  } from './protocol'
26
26
 
27
- export {
28
- formatLocalDate,
29
- formatLocalDateTime,
30
- formatLocalWeekday,
31
- type LocalWeekday,
32
- resolveLocalTimezoneName,
33
- } from './local-time'
27
+ export { formatLocalDate, formatLocalDateTime, formatLocalWeekday, resolveLocalTimezoneName } from './local-time'
@@ -37,34 +37,26 @@ export function resolveLocalTimezoneName(): string {
37
37
  }
38
38
  }
39
39
 
40
- // English + Korean weekday name pair for a given Date. The per-turn time
41
- // anchor renders both so the model has the answer to "what day is it"
42
- // without computing weekday-from-ISO-date — a step LLMs get wrong often
43
- // enough to matter, especially when answering in a non-English language.
44
- // Pre-computing in both candidate reply languages removes the arithmetic
45
- // step entirely instead of trusting the model to do it correctly each
46
- // turn.
40
+ // English weekday name for a given Date. The per-turn time anchor renders
41
+ // it so the model has the answer to "what day is it" without computing
42
+ // weekday-from-ISO-date — a step LLMs get wrong often enough to matter.
43
+ // Pre-computing the weekday removes the arithmetic step entirely instead
44
+ // of trusting the model to do it correctly each turn. English only:
45
+ // TypeClaw's users are global, so a single canonical language keeps the
46
+ // anchor compact and lets each agent's SOUL.md decide its reply language.
47
47
  //
48
- // Uses Intl.DateTimeFormat with explicit locales. No `timeZone` option:
48
+ // Uses Intl.DateTimeFormat with an explicit locale. No `timeZone` option:
49
49
  // the container's local clock is already host-local (the entrypoint
50
50
  // propagates TZ via `-e TZ=<host-tz>`), so the runtime's default zone is
51
- // the one the user sees. Both locales fall back to the hand-rolled
52
- // 7-entry lookup if Intl throws (no-tzdata, locked-down sandbox) — the
53
- // fallback names stay readable and never make the prefix empty.
51
+ // the one the user sees. Falls back to the hand-rolled 7-entry lookup if
52
+ // Intl throws (no-tzdata, locked-down sandbox) — the fallback names stay
53
+ // readable and never make the prefix empty.
54
54
  const WEEKDAYS_EN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
55
- const WEEKDAYS_KO = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'] as const
56
55
 
57
- export type LocalWeekday = { en: string; ko: string }
58
-
59
- export function formatLocalWeekday(date: Date = new Date()): LocalWeekday {
60
- const dow = date.getDay()
61
- const fallback: LocalWeekday = { en: WEEKDAYS_EN[dow]!, ko: WEEKDAYS_KO[dow]! }
56
+ export function formatLocalWeekday(date: Date = new Date()): string {
62
57
  try {
63
- return {
64
- en: new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(date),
65
- ko: new Intl.DateTimeFormat('ko-KR', { weekday: 'long' }).format(date),
66
- }
58
+ return new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(date)
67
59
  } catch {
68
- return fallback
60
+ return WEEKDAYS_EN[date.getDay()]!
69
61
  }
70
62
  }
@@ -101,6 +101,10 @@ export type InspectFramePayload =
101
101
  // text — no batching, no compose-prompt wrapping.
102
102
  | {
103
103
  kind: 'channel_inbound'
104
+ // Channel session this inbound belongs to. Absent for denied/claim
105
+ // intercepts that fire before a session exists. The inspect server drops
106
+ // frames whose sessionId does not match the watched session.
107
+ sessionId?: string
104
108
  adapter: string
105
109
  workspace: string
106
110
  chat: string
@@ -11,14 +11,16 @@ This means **you are messaging as a person, not as a bot.** Other participants s
11
11
 
12
12
  ## What KakaoTalk does NOT support
13
13
 
14
- If you produce any of the following, KakaoTalk will render it literally and the recipient will see the raw markup:
15
-
16
- - **Bold / italic / strikethrough** `**bold**` shows as `**bold**`. Drop the asterisks; emphasize with word choice or capitalization (sparingly).
17
- - **Headings** — `# H1`, `## H2`, `### H3` all render as raw `#` characters.
18
- - **Tables** pipe-delimited tables become a wall of `|` characters. Use bullet lists or short prose paragraphs instead.
19
- - **Code fences** — ``` blocks render as raw backticks. For short snippets, paste the code inline. For long snippets, summarize and offer to send it via another channel.
20
- - **Inline code** — `` `foo` `` renders as `` `foo` ``. Just write `foo`.
21
- - **Links with display text** — `[label](url)` becomes the literal string. Send the bare URL on its own; the KakaoTalk client will auto-link it.
14
+ KakaoTalk renders messages as plain text — it has no rich-text formatting. **Write plain text from the start.** The adapter strips common markdown as a safety net before sending (so an accidental `**bold**` won't leak literal asterisks), but treat that as a last-resort guard, not a license to write markdown: the strip removes _markers_, it cannot make formatting-dependent layouts like tables readable. Compose for a plain-text surface and you control the result; lean on the stripper and you get whatever falls out.
15
+
16
+ Specifically, do not rely on any of the following write the plain-text equivalent yourself:
17
+
18
+ - **Bold / italic / strikethrough** emphasize with word choice or capitalization (sparingly), not `**asterisks**`.
19
+ - **Headings** — `# H1`, `## H2`, `### H3` carry no visual weight here. Use a short label line or just lead with the point.
20
+ - **Tables** — the stripper cannot rescue a pipe-delimited table; it would collapse into an unreadable line. Use bullet lists or short prose paragraphs instead.
21
+ - **Code fences** — for short snippets, paste the code inline as plain text. For long snippets, summarize and offer to send it via another channel.
22
+ - **Inline code** — just write `foo`, no backticks.
23
+ - **Links with display text** — send the bare URL on its own line; the KakaoTalk client auto-links it. (A `[label](url)` that slips through is reduced to `label (url)`, but a bare URL reads cleaner.)
22
24
  - **Mentions** — there is no `@user` syntax that the protocol surfaces. Address people by name in the message body.
23
25
  - **Threads / replies-with-quote** — every message is a top-level chat post. There is no per-message reply UI.
24
26
  - **Outbound stickers / emoticons** — the KakaoTalk sticker store requires desktop-app purchase flows that the SDK does not replicate. Inbound stickers ARE surfaced (see below), but you cannot send one. If the user asks for a sticker, acknowledge the limit and offer text.
@@ -106,4 +108,4 @@ The adapter drops every inbound where `event.author_id` equals the logged-in acc
106
108
 
107
109
  ## When you cannot answer in KakaoTalk
108
110
 
109
- If the user asks you to do something the adapter cannot do (render markdown, post in a thread, send a sticker), say so plainly. Files are fine — those go through `attachments[]` as described above — but markdown rendering, threading, and stickers are real limits. Acknowledge the limit instead of silently dropping the request.
111
+ If the user asks you to do something the adapter cannot do (post in a thread, send a sticker, render a real table), say so plainly. Files are fine — those go through `attachments[]` as described above — but threading, stickers, and rich formatting are real limits. Markdown markers you emit get stripped to plain text automatically, so a stray `**` won't leak; the limit is that nothing renders as formatting, not that it crashes. Acknowledge the limit instead of silently dropping the request.
@@ -32,6 +32,7 @@
32
32
  "anthropic/claude-haiku-4-5",
33
33
  "anthropic/claude-sonnet-4-6",
34
34
  "anthropic/claude-opus-4-7",
35
+ "anthropic/claude-opus-4-8",
35
36
  "fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
36
37
  "zai/glm-4.5-air",
37
38
  "zai/glm-4.6",
@@ -59,6 +60,7 @@
59
60
  "anthropic/claude-haiku-4-5",
60
61
  "anthropic/claude-sonnet-4-6",
61
62
  "anthropic/claude-opus-4-7",
63
+ "anthropic/claude-opus-4-8",
62
64
  "fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
63
65
  "zai/glm-4.5-air",
64
66
  "zai/glm-4.6",