typeclaw 0.23.0 → 0.24.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +91 -22
  3. package/src/agent/plugin-tools.ts +38 -2
  4. package/src/agent/restart/index.ts +15 -3
  5. package/src/agent/restart-handoff/index.ts +110 -12
  6. package/src/agent/subagent-completion-reminder.ts +3 -1
  7. package/src/agent/subagents.ts +44 -1
  8. package/src/agent/system-prompt.ts +4 -0
  9. package/src/agent/todo/continuation-policy.ts +242 -0
  10. package/src/agent/todo/continuation-state.ts +87 -0
  11. package/src/agent/todo/continuation-wiring.ts +113 -0
  12. package/src/agent/todo/continuation.ts +71 -0
  13. package/src/agent/todo/scope.ts +77 -0
  14. package/src/agent/todo/store.ts +98 -0
  15. package/src/agent/tool-not-found-nudge.ts +119 -0
  16. package/src/agent/tools/channel-reply.ts +51 -0
  17. package/src/agent/tools/restart.ts +11 -4
  18. package/src/agent/tools/todo/index.ts +119 -0
  19. package/src/bundled-plugins/backup/runner.ts +1 -1
  20. package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
  21. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  22. package/src/channels/adapters/discord-bot.ts +25 -3
  23. package/src/channels/adapters/github/inbound.ts +161 -10
  24. package/src/channels/adapters/github/index.ts +10 -0
  25. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  26. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  27. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  28. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  29. package/src/channels/adapters/slack-bot.ts +67 -8
  30. package/src/channels/manager.ts +8 -2
  31. package/src/channels/router.ts +445 -22
  32. package/src/channels/schema.ts +20 -4
  33. package/src/channels/types.ts +68 -0
  34. package/src/cli/inspect-controller.ts +7 -0
  35. package/src/cli/inspect.ts +2 -1
  36. package/src/commands/index.ts +9 -0
  37. package/src/init/gitignore.ts +5 -2
  38. package/src/inspect/index.ts +22 -0
  39. package/src/run/index.ts +60 -5
  40. package/src/sandbox/build.ts +10 -0
  41. package/src/sandbox/index.ts +2 -0
  42. package/src/sandbox/policy.ts +10 -0
  43. package/src/sandbox/writable-zones.ts +78 -0
  44. package/src/server/index.ts +118 -4
  45. package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
  46. package/typeclaw.schema.json +10 -0
@@ -131,11 +131,26 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
131
131
  'pull_request_review.submitted',
132
132
  ] as const
133
133
 
134
+ // Which pull_request webhook action triggers an agent code review. The two
135
+ // event values are GitHub's bare PR action names (the `pull_request.` event
136
+ // prefix is implied by this field living under the review config); `off` is the
137
+ // disable sentinel, matching the `engagement.stickiness: 'off'` convention:
138
+ // - 'review_requested' — review only when the bot is requested (default)
139
+ // - 'opened' — review every PR as soon as it opens
140
+ // - 'off' — disable code review entirely
141
+ export const GITHUB_REVIEW_ON_VALUES = ['review_requested', 'opened', 'off'] as const
142
+
143
+ export type GithubReviewOn = (typeof GITHUB_REVIEW_ON_VALUES)[number]
144
+
145
+ export const DEFAULT_GITHUB_REVIEW_ON: GithubReviewOn = 'review_requested'
146
+
134
147
  // PR-review policy knobs. Grouped under `review` so future toggles
135
- // (`requestChanges`, auto-review-on-request, severity thresholds) cluster
136
- // here instead of flattening onto the channel root.
148
+ // (`requestChanges`, severity thresholds) cluster here instead of flattening
149
+ // onto the channel root.
150
+ //
151
+ // `on` gates which pull_request action triggers a code review (see values above).
137
152
  //
138
- // `approve` gates whether the agent may submit a formal review with
153
+ // `approve` gates *whether* the agent may submit a formal review with
139
154
  // `event: APPROVE`. When `false`, the adapter appends an operator-policy note
140
155
  // to inbounds and the `typeclaw-channel-github` skill downgrades an `approve`
141
156
  // verdict to a `COMMENT` review (findings still posted, no formal approval).
@@ -144,9 +159,10 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
144
159
  // temp file the command interceptor never sees.
145
160
  const githubReviewSchema = z
146
161
  .object({
162
+ on: z.enum(GITHUB_REVIEW_ON_VALUES).default(DEFAULT_GITHUB_REVIEW_ON),
147
163
  approve: z.boolean().default(true),
148
164
  })
149
- .default({ approve: true })
165
+ .default({ on: DEFAULT_GITHUB_REVIEW_ON, approve: true })
150
166
 
151
167
  const githubChannelSchema = adapterSchema.extend({
152
168
  // Optional now (PR 2): when omitted and a `tunnels[]` entry with
@@ -52,6 +52,9 @@ export type InboundMessage = {
52
52
  chat: string
53
53
  thread: string | null
54
54
  text: string
55
+ // Prompt-only context for replied-to / quoted / linked messages. Kept out
56
+ // of `text` so the engagement gate sees only the human-authored body.
57
+ referenceContext?: InboundReferenceContext
55
58
  // Non-text attachments the user sent on this inbound. Empty / omitted
56
59
  // when the message is text-only. The router carries these through to
57
60
  // the live session's promptQueue/contextBuffer so channel tools can
@@ -94,6 +97,15 @@ export type InboundMessage = {
94
97
  // means "unknown" — the formatter renders such lines without a
95
98
  // timestamp prefix instead of stamping them with the wrong clock.
96
99
  ts: number
100
+ // Platform-native anchor for showing a typing/status indicator, kept
101
+ // SEPARATE from `thread` on purpose: `thread` drives reply threading,
102
+ // this drives ONLY the typing surface. Slack's `assistant.threads.
103
+ // setStatus` (the bot's only typing signal) requires a real message ts
104
+ // even in a flat DM, where `thread` is null because replies stay top-
105
+ // level. The classifier sets this to the inbound message ts for DMs so
106
+ // the status can render without forcing the reply into a thread. Omitted
107
+ // for non-DM inbounds, where the typing path falls back to `thread`.
108
+ typingThread?: string
97
109
  // Opaque, adapter-owned handle for the entity an emoji reaction would
98
110
  // attach to. The classifier stamps it because only there is the platform-
99
111
  // side target type still known (GitHub: issue body vs issue-comment vs
@@ -183,6 +195,10 @@ export type OutboundMessage = {
183
195
  // `uploadFile` does not accept a content body or a thread id, see the
184
196
  // adapter for the workaround details.
185
197
  attachments?: OutboundAttachment[]
198
+ // Typing-only anchor (see InboundMessage.typingThread), stamped by the
199
+ // router from the live session so the adapter can CLEAR the status after a
200
+ // flat DM send — where `thread` is null and would otherwise no-op the clear.
201
+ typingThread?: string
186
202
  // Set by the router (native render mode + anchor fired) so an adapter can
187
203
  // reply to the inbound it answers. Telegram/Discord consume `externalMessageId`;
188
204
  // `quote`-mode adapters never see this (the router prepends the blockquote into
@@ -207,6 +223,11 @@ export type QuoteAnchorSource = {
207
223
  text: string
208
224
  }
209
225
 
226
+ export type InboundReferenceContext = {
227
+ kind: 'reply' | 'quote' | 'link'
228
+ sources: readonly QuoteAnchorSource[]
229
+ }
230
+
210
231
  export type SendErrorCode =
211
232
  | 'duplicate'
212
233
  | 'turn-cap'
@@ -224,6 +245,10 @@ export type TypingTarget = {
224
245
  workspace: string
225
246
  chat: string
226
247
  thread?: string | null
248
+ // Typing-only anchor (see InboundMessage.typingThread). An adapter whose
249
+ // typing surface needs a message ts even when `thread` is null (Slack DMs)
250
+ // reads this first and falls back to `thread`. Never used for reply routing.
251
+ typingThread?: string
227
252
  // 'tick' is the heartbeat fired during debouncing/generation; adapters
228
253
  // should set the indicator visible. 'stop' is fired exactly once when the
229
254
  // router decides the turn is over (drain finally, /stop command, or
@@ -280,6 +305,7 @@ export type ChannelHistoryMessage = {
280
305
  authorId: string
281
306
  authorName: string
282
307
  text: string
308
+ referenceContext?: InboundReferenceContext
283
309
  attachments?: readonly InboundAttachment[]
284
310
  ts: number
285
311
  isBot: boolean
@@ -314,6 +340,48 @@ export type FetchAttachmentResult =
314
340
 
315
341
  export type FetchAttachmentCallback = (args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
316
342
 
343
+ // A request to resolve (close out) a review-comment thread the bot itself
344
+ // opened, after the author addressed it. Adapter-specific: only the github
345
+ // adapter registers a resolver today. The router carries the request through
346
+ // to that resolver, which is responsible for the platform-side authorship
347
+ // check — `resolveReviewThread` MUST only close a thread whose root comment
348
+ // the bot authored, never a human reviewer's thread. The address fields below
349
+ // are the same ones a `channel_reply` origin carries: `workspace` is the repo
350
+ // slug `owner/name`, `chat` is `pr:<N>`, and `rootCommentId` is the numeric id
351
+ // of the thread's root comment (the `thread` value the inbound carried).
352
+ export type ReviewThreadResolveRequest = {
353
+ adapter: AdapterId
354
+ workspace: string
355
+ chat: string
356
+ rootCommentId: string
357
+ }
358
+
359
+ // `already-resolved` is a success-shaped no-op: the thread was closed before we
360
+ // got here (a duplicate turn, a manual resolve), so the desired end state holds
361
+ // and the caller should treat it like `ok: true`. `not-author` is a hard
362
+ // refusal: the root comment is not the bot's, so resolving would erase a
363
+ // human's open question — the caller must NOT proceed as if it closed the loop.
364
+ //
365
+ // `no-match` is the ONLY non-blocking failure: the PR's threads listed cleanly
366
+ // but none is rooted at this comment (already deleted, or the wrong target),
367
+ // so there is genuinely nothing to close and an acknowledgement may still post.
368
+ // Every other code is a hard failure — `not-found` here means an HTTP 404 from
369
+ // the API (a real problem, e.g. wrong repo/PR), NOT "no such thread"; a caller
370
+ // must treat it as blocking so it never claims a thread is settled on a failed
371
+ // or misdirected lookup.
372
+ export type ReviewThreadResolveResult =
373
+ | { ok: true; alreadyResolved?: boolean }
374
+ | {
375
+ ok: false
376
+ error: string
377
+ code?: 'not-author' | 'no-match' | 'not-found' | 'unsupported' | 'permission-denied' | 'transient'
378
+ }
379
+
380
+ // Registered per-adapter on the ChannelRouter, last-write-wins like the
381
+ // self-identity resolver (one bot account per adapter). Adapters that do not
382
+ // support review threads never register one; the router answers `unsupported`.
383
+ export type ReviewThreadResolver = (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
384
+
317
385
  export function channelKeyId(key: ChannelKey): string {
318
386
  return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
319
387
  }
@@ -14,6 +14,8 @@ export type EscController = {
14
14
  dispose: () => void
15
15
  }
16
16
 
17
+ const QUIT_KEY = 0x71
18
+
17
19
  export function createEscController({ debounceMs }: { debounceMs: number }): EscController {
18
20
  let currentCtrl: AbortController | null = null
19
21
  let pendingEsc: ReturnType<typeof setTimeout> | null = null
@@ -111,6 +113,11 @@ export function createTailScope(opts: { debounceMs: number; input?: RawInput; pr
111
113
 
112
114
  const onData = (chunk: Buffer): void => {
113
115
  if (esc === null) return
116
+ if (chunk[0] === QUIT_KEY) {
117
+ // q mirrors dreams' quit key and is symmetric with Ctrl-C in live tail.
118
+ settle('exit')
119
+ return
120
+ }
114
121
  const { sigint } = esc.onChunk(chunk)
115
122
  if (sigint) settle('exit')
116
123
  }
@@ -58,6 +58,7 @@ export const inspectCommand = defineCommand({
58
58
  selectSession: (sessions, selectOpts) => clackSelect(sessions, selectOpts?.initialSessionId),
59
59
  ...(liveSource !== undefined ? { liveSource } : {}),
60
60
  createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
61
+ ...(interactive ? { interactive: true } : {}),
61
62
  ...(liveHint !== undefined ? { liveHint } : {}),
62
63
  stdout: (line) => process.stdout.write(`${line}\n`),
63
64
  stderr: (line) => process.stderr.write(`${line}\n`),
@@ -93,7 +94,7 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
93
94
  }
94
95
 
95
96
  function escHintLine(color: boolean): string {
96
- const text = '(press esc to return to session list)'
97
+ const text = '(esc to return to session list · q to quit)'
97
98
  return color ? `\u001b[2m${text}\u001b[0m` : text
98
99
  }
99
100
 
@@ -25,6 +25,13 @@ export type Command<Context> = {
25
25
  description: string
26
26
  permission?: CommandPermission
27
27
  requiresLiveSession?: boolean
28
+ // Resolve an existing live session into the handler context WITHOUT failing
29
+ // when none exists (distinct from `requiresLiveSession`, which aborts the
30
+ // command if no session is live). Used by /restart: it must bounce the
31
+ // container even from a cold channel, but when a session IS live it needs
32
+ // that session's identity to write a resume handoff. Ignored when
33
+ // `requiresLiveSession` is true (that path already resolves the session).
34
+ wantsLiveSession?: boolean
28
35
  handler: CommandHandler<Context>
29
36
  }
30
37
 
@@ -42,6 +49,7 @@ export type CommandInfo = {
42
49
  description: string
43
50
  permission: CommandPermission
44
51
  requiresLiveSession: boolean
52
+ wantsLiveSession: boolean
45
53
  }
46
54
 
47
55
  export type CommandResult =
@@ -74,6 +82,7 @@ export function createCommandRegistry<Context>(commands: readonly Command<Contex
74
82
  description: command.description,
75
83
  permission: command.permission ?? 'session.control',
76
84
  requiresLiveSession: command.requiresLiveSession ?? true,
85
+ wantsLiveSession: command.wantsLiveSession ?? false,
77
86
  })
78
87
 
79
88
  return {
@@ -32,16 +32,19 @@ auth.json
32
32
  node_modules/
33
33
  packages/*/node_modules/
34
34
  workspace/
35
+ public/
35
36
  mounts/
36
37
  Dockerfile
37
38
  .DS_Store
38
39
 
39
40
  # System-managed: gitignored by default so the agent never stages them by hand,
40
- # but TypeClaw force-commits them on its own schedule (sessions/ via auto-backup,
41
- # memory/ via the dreaming subagent). Treat them as runtime-owned, not agent-owned.
41
+ # but TypeClaw force-commits them on its own schedule (sessions/ + todo/ via
42
+ # auto-backup, memory/ via the dreaming subagent). Treat them as runtime-owned,
43
+ # not agent-owned.
42
44
  sessions/
43
45
  memory/
44
46
  channels/
47
+ todo/
45
48
  `
46
49
  }
47
50
 
@@ -34,6 +34,7 @@ export type RunInspectOptions = {
34
34
  // caller's loop inspects its own scope intent to tell back from exit.
35
35
  signal?: AbortSignal
36
36
  liveHint?: string
37
+ interactive?: boolean
37
38
  }
38
39
 
39
40
  export type SelectSessionOptions = {
@@ -81,6 +82,7 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
81
82
  ...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
82
83
  ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
83
84
  ...(opts.liveHint !== undefined ? { liveHint: opts.liveHint } : {}),
85
+ ...(opts.interactive === true ? { interactive: true } : {}),
84
86
  })
85
87
  if (streamResult.escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
86
88
  return { ok: true, exitCode: 0 }
@@ -146,6 +148,7 @@ async function streamSession(opts: {
146
148
  liveSource?: LiveSourceFactory
147
149
  signal?: AbortSignal
148
150
  liveHint?: string
151
+ interactive?: boolean
149
152
  }): Promise<{ escToPicker: boolean }> {
150
153
  if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
151
154
  const emit = (event: InspectEvent): void => {
@@ -167,6 +170,18 @@ async function streamSession(opts: {
167
170
 
168
171
  if (opts.liveSource === undefined) {
169
172
  if (!opts.json) opts.stdout('─── end of transcript ───')
173
+ // Already aborted during replay (user pressed esc/q): honor it, don't lose the keystroke.
174
+ if (aborted()) return { escToPicker: true }
175
+ // Interactive replay-only: hold a stable viewer like `dreams` instead of
176
+ // bouncing straight back to the picker. Block until the tail scope aborts
177
+ // (esc → back, q/ctrl-c → exit). Never block without a signal (non-TTY has
178
+ // no listener and would hang) or in json/non-interactive mode (scriptability).
179
+ if (opts.interactive === true && !opts.json && opts.signal !== undefined) {
180
+ if (opts.liveHint !== undefined && opts.liveHint !== '') {
181
+ opts.stdout(divider(opts.color, opts.liveHint))
182
+ }
183
+ await waitForAbort(opts.signal)
184
+ }
170
185
  return { escToPicker: aborted() }
171
186
  }
172
187
 
@@ -208,6 +223,13 @@ function divider(color: boolean, text: string): string {
208
223
  return text
209
224
  }
210
225
 
226
+ export async function waitForAbort(signal: AbortSignal): Promise<void> {
227
+ if (signal.aborted) return
228
+ await new Promise<void>((resolve) => {
229
+ signal.addEventListener('abort', () => resolve(), { once: true })
230
+ })
231
+ }
232
+
211
233
  function writeHeader(summary: SessionSummary, color: boolean, stdout: (line: string) => void): void {
212
234
  const id = shortSessionId(summary.sessionId)
213
235
  const label = summary.origin === null ? '(unknown origin)' : originLabel(summary.origin)
package/src/run/index.ts CHANGED
@@ -4,6 +4,7 @@ import { createSession, createSessionWithDispose } from '@/agent'
4
4
  import { LiveSessionRegistry } from '@/agent/live-sessions'
5
5
  import { LiveSubagentRegistry } from '@/agent/live-subagents'
6
6
  import { requestContainerRestart } from '@/agent/restart'
7
+ import { consumeRestartHandoff } from '@/agent/restart-handoff'
7
8
  import type { SessionOrigin } from '@/agent/session-origin'
8
9
  import {
9
10
  awaitWithSubagentTimeout,
@@ -16,6 +17,7 @@ import {
16
17
  type SubagentRegistry,
17
18
  type SubagentShared,
18
19
  } from '@/agent/subagents'
20
+ import { clearTodosForOrigin } from '@/agent/todo/continuation-wiring'
19
21
  import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
20
22
  import {
21
23
  createChannelManager,
@@ -282,14 +284,31 @@ export async function startAgent({
282
284
  // `typeclaw run` outside Docker), the handler reports that instead of the
283
285
  // command resolving as unknown, which would make the advertised contract
284
286
  // depend on the runtime environment.
285
- onRestart: async (): Promise<string> => {
287
+ onRestart: async (ctx): Promise<string> => {
286
288
  if (containerName === undefined) {
287
289
  return 'Restart is unavailable: this agent is not running inside a typeclaw container.'
288
290
  }
289
- // No originatingSessionId/stream/handoff: a channel-invoked restart must
290
- // not write a resume hint or fire the "I'm back" broadcast that a TUI
291
- // restart does (issue #291 scoping only TUI origins resume).
292
- const result = await requestContainerRestart({ containerName })
291
+ // When the /restart command resolved a live channel session, ctx carries
292
+ // its identity: pass stream + session id/file + channel handoffOrigin so
293
+ // the dying container appends the `typeclaw.restart-self` entry (via the
294
+ // broadcast) and writes a channel-origin handoff. On the next boot the
295
+ // channel resume path reopens that exact conversation. With no live
296
+ // session (cold channel / native slash), ctx is undefined and the
297
+ // container just bounces — the next inbound resumes pending todos.
298
+ const result = await requestContainerRestart({
299
+ containerName,
300
+ ...(ctx !== undefined
301
+ ? {
302
+ stream,
303
+ agentDir: cwd,
304
+ originatingSessionId: ctx.originatingSessionId,
305
+ ...(ctx.originatingSessionFile !== undefined
306
+ ? { originatingSessionFile: ctx.originatingSessionFile }
307
+ : {}),
308
+ handoffOrigin: ctx.handoffOrigin,
309
+ }
310
+ : {}),
311
+ })
293
312
  return result.ok ? 'Restart scheduled; the container will bounce shortly.' : `Restart denied: ${result.reason}`
294
313
  },
295
314
  })
@@ -434,6 +453,13 @@ export async function startAgent({
434
453
  // marker so the audit trail records "user edited cron.json".
435
454
  scheduledByOrigin: (job.scheduledByOrigin as SessionOrigin | undefined) ?? { kind: 'config-file' },
436
455
  }
456
+ // Cron todos are per-fire ephemeral by default: each scheduled run starts
457
+ // with a clean list so an incomplete item from a prior fire cannot
458
+ // resurrect indefinitely on every tick. (A future opt-in could carry them
459
+ // forward; until then, clearing is the safe default.)
460
+ await clearTodosForOrigin(cwd, cronOrigin).catch((err) =>
461
+ console.error(`[cron] ${job.id}: clear todos failed: ${err instanceof Error ? err.message : String(err)}`),
462
+ )
437
463
  const session = await createSession({
438
464
  reloadRegistry,
439
465
  sessionManager,
@@ -507,8 +533,37 @@ export async function startAgent({
507
533
  })
508
534
 
509
535
  reloadRegistry.register(createChannelsReloadable({ manager: channelManager }))
536
+
537
+ // Two-phase channel restart-resume around adapter startup, to close the race
538
+ // where an adapter starts receiving before the resume claims the handoff:
539
+ // 1. Claim the channel handoff and RESERVE the originating key BEFORE
540
+ // channelManager.start(). The reservation installs a per-key gate, so an
541
+ // inbound that arrives the instant an adapter connects coalesces onto the
542
+ // resume instead of stale-rolling the mapping or creating a rival session.
543
+ // 2. start() the adapters (registers outbound callbacks the wake reply needs).
544
+ // 3. resume() the reservation: reopen the exact session and enqueue the wake
545
+ // — skipped automatically if a real inbound already coalesced in (2)→(3).
546
+ // Claims ONLY channel handoffs; tui handoffs are left on disk (peek-then-delete
547
+ // never removes an unclaimed handoff) for the websocket open handler to claim.
548
+ // Best-effort throughout: any failure leaves the todo to resume on the next inbound.
549
+ let restartReservation: ReturnType<typeof channelManager.router.reserveRestartHandoff> = null
550
+ try {
551
+ const handoff = await consumeRestartHandoff(cwd, { accept: (h) => h.origin.kind === 'channel' })
552
+ if (handoff !== null) restartReservation = channelManager.router.reserveRestartHandoff(handoff)
553
+ } catch (err) {
554
+ console.warn(`[run] channel restart-resume reserve failed: ${err instanceof Error ? err.message : err}`)
555
+ }
556
+
510
557
  await channelManager.start()
511
558
 
559
+ if (restartReservation !== null) {
560
+ try {
561
+ await restartReservation.resume()
562
+ } catch (err) {
563
+ console.warn(`[run] channel restart-resume failed: ${err instanceof Error ? err.message : err}`)
564
+ }
565
+ }
566
+
512
567
  // Captured separately from setSpawnSubagent so both the plugin context and
513
568
  // the plugin-command runner can dispatch through the same path. The setter
514
569
  // returns void, so without this local binding we couldn't reuse the fn.
@@ -110,6 +110,7 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
110
110
  }
111
111
 
112
112
  appendMasks(argv, policy)
113
+ appendWritable(argv, policy)
113
114
 
114
115
  if (policy.cwd !== undefined) {
115
116
  argv.push('--chdir', policy.cwd)
@@ -128,6 +129,15 @@ function appendMasks(argv: string[], policy: SandboxPolicy): void {
128
129
  }
129
130
  }
130
131
 
132
+ function appendWritable(argv: string[], policy: SandboxPolicy): void {
133
+ for (const dir of policy.writable?.dirs ?? []) {
134
+ argv.push('--bind', dir, dir)
135
+ }
136
+ for (const file of policy.writable?.files ?? []) {
137
+ argv.push('--bind', file, file)
138
+ }
139
+ }
140
+
131
141
  function appendMount(argv: string[], mount: SandboxMount): void {
132
142
  switch (mount.type) {
133
143
  case 'ro-bind':
@@ -1,6 +1,7 @@
1
1
  export { buildSandboxedCommand, type SandboxedCommand } from './build'
2
2
  export { ensureBwrapAvailable, _resetBwrapAvailabilityCacheForTests } from './availability'
3
3
  export { resolveHiddenPaths, type HiddenPaths } from './hidden-paths'
4
+ export { resolveWritableZones, subtractMasked, type WritableZones } from './writable-zones'
4
5
  export { formatCommand, shellQuote } from './quote'
5
6
  export { SandboxPolicyError, SandboxUnavailableError } from './errors'
6
7
  export {
@@ -12,4 +13,5 @@ export {
12
13
  type SandboxPolicy,
13
14
  type SandboxProcessPolicy,
14
15
  type SandboxProcStrategy,
16
+ type SandboxWritablePolicy,
15
17
  } from './policy'
@@ -37,11 +37,21 @@ export type SandboxMaskPolicy = {
37
37
  files?: string[]
38
38
  }
39
39
 
40
+ // Writable carve-outs re-exposed on top of a read-only project root AND its
41
+ // masks. Rendered last so "last op wins" makes these the only RW paths: an RW
42
+ // bind here overrides the broad --ro-bind parent, while anything not listed
43
+ // stays read-only (EROFS) or masked.
44
+ export type SandboxWritablePolicy = {
45
+ dirs?: string[]
46
+ files?: string[]
47
+ }
48
+
40
49
  export type SandboxPolicy = {
41
50
  bwrapPath?: string
42
51
  cwd?: string
43
52
  mounts?: SandboxMount[]
44
53
  masks?: SandboxMaskPolicy
54
+ writable?: SandboxWritablePolicy
45
55
  network?: SandboxNetwork
46
56
  env?: SandboxEnvPolicy
47
57
  commandFilter?: SandboxCommandFilter
@@ -0,0 +1,78 @@
1
+ import { lstat } from 'node:fs/promises'
2
+ import path, { join } from 'node:path'
3
+
4
+ export type WritableZones = {
5
+ dirs: string[]
6
+ files: string[]
7
+ }
8
+
9
+ // SECURITY: a blanket RW bind is coarser than the write/edit guards, so this set
10
+ // is deliberately NARROWER than the write/edit allowlist — only genuinely
11
+ // free-write scratch zones. `.agents/skills` and `packages` are excluded: the
12
+ // former is validated (SKILL.md shape, name, frontmatter) by the skillAuthoring
13
+ // guard and the latter holds executable plugin code; bash must not get blanket
14
+ // RW to either. Skill authoring and package writes go through the guarded
15
+ // write/edit tool only.
16
+ const WRITABLE_DIRS = ['workspace', 'public', 'mounts'] as const
17
+
18
+ // Bash may EDIT these when present; creating a MISSING root file goes through
19
+ // write/edit (bwrap cannot RW-bind a non-existent source without pre-creating it).
20
+ const WRITABLE_ROOT_FILES = [
21
+ 'AGENTS.md',
22
+ 'IDENTITY.md',
23
+ 'SOUL.md',
24
+ 'USER.md',
25
+ 'cron.json',
26
+ 'package.json',
27
+ 'typeclaw.json',
28
+ ] as const
29
+
30
+ // SECURITY: the symlink rejection is load-bearing. An RW bind follows symlinks,
31
+ // so a `workspace -> /etc` symlink at a zone root would grant write access to an
32
+ // outside path. (Symlinks INSIDE a real zone are already safe — the kernel
33
+ // resolves them to the read-only parent mount.)
34
+ export async function resolveWritableZones(agentDir: string): Promise<WritableZones> {
35
+ const dirs = await collectExisting(
36
+ WRITABLE_DIRS.map((d) => join(agentDir, d)),
37
+ 'dir',
38
+ )
39
+ const files = await collectExisting(
40
+ WRITABLE_ROOT_FILES.map((f) => join(agentDir, f)),
41
+ 'file',
42
+ )
43
+ return { dirs, files }
44
+ }
45
+
46
+ // SECURITY: a writable RW bind renders AFTER the masks and last-op-wins, so an
47
+ // RW bind on a masked path would re-expose the real (hidden) directory. Drop any
48
+ // writable zone that is, or is nested under, a masked path so the confidentiality
49
+ // boundary survives — e.g. a guest's masked `workspace/` is never re-exposed RW.
50
+ export function subtractMasked(writable: WritableZones, masked: { dirs: string[]; files: string[] }): WritableZones {
51
+ const maskedDirs = masked.dirs
52
+ const isMasked = (target: string): boolean =>
53
+ masked.files.includes(target) || maskedDirs.some((dir) => target === dir || isInside(dir, target))
54
+ return {
55
+ dirs: writable.dirs.filter((dir) => !isMasked(dir)),
56
+ files: writable.files.filter((file) => !isMasked(file)),
57
+ }
58
+ }
59
+
60
+ function isInside(parent: string, child: string): boolean {
61
+ const relative = path.relative(parent, child)
62
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
63
+ }
64
+
65
+ async function collectExisting(paths: string[], kind: 'dir' | 'file'): Promise<string[]> {
66
+ const checks = await Promise.all(paths.map((p) => isRealEntry(p, kind)))
67
+ return paths.filter((_, i) => checks[i])
68
+ }
69
+
70
+ async function isRealEntry(path: string, kind: 'dir' | 'file'): Promise<boolean> {
71
+ try {
72
+ const stats = await lstat(path)
73
+ if (stats.isSymbolicLink()) return false
74
+ return kind === 'dir' ? stats.isDirectory() : stats.isFile()
75
+ } catch {
76
+ return false
77
+ }
78
+ }