typeclaw 0.23.0 → 0.25.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 (90) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +133 -27
  4. package/src/agent/llm-replay-sanitizer.ts +120 -0
  5. package/src/agent/loop-guard.ts +34 -0
  6. package/src/agent/multimodal/look-at.ts +1 -1
  7. package/src/agent/plugin-tools.ts +122 -8
  8. package/src/agent/restart/index.ts +15 -3
  9. package/src/agent/restart-handoff/index.ts +110 -12
  10. package/src/agent/session-origin.ts +30 -0
  11. package/src/agent/subagent-completion-reminder.ts +26 -1
  12. package/src/agent/subagents.ts +75 -3
  13. package/src/agent/system-prompt.ts +5 -1
  14. package/src/agent/todo/continuation-policy.ts +242 -0
  15. package/src/agent/todo/continuation-state.ts +87 -0
  16. package/src/agent/todo/continuation-wiring.ts +113 -0
  17. package/src/agent/todo/continuation.ts +71 -0
  18. package/src/agent/todo/scope.ts +77 -0
  19. package/src/agent/todo/store.ts +98 -0
  20. package/src/agent/tool-not-found-nudge.ts +126 -0
  21. package/src/agent/tools/channel-reply.ts +51 -0
  22. package/src/agent/tools/curl-impersonate.ts +2 -2
  23. package/src/agent/tools/restart.ts +11 -4
  24. package/src/agent/tools/spawn-subagent.ts +19 -2
  25. package/src/agent/tools/subagent-access.ts +40 -5
  26. package/src/agent/tools/subagent-cancel.ts +3 -1
  27. package/src/agent/tools/subagent-output.ts +6 -2
  28. package/src/agent/tools/todo/index.ts +119 -0
  29. package/src/agent/tools/webfetch/fetch.ts +18 -18
  30. package/src/agent/tools/webfetch/index.ts +1 -1
  31. package/src/agent/tools/webfetch/tool.ts +13 -13
  32. package/src/agent/tools/webfetch/types.ts +1 -1
  33. package/src/agent/tools/websearch.ts +6 -6
  34. package/src/bundled-plugins/backup/index.ts +40 -37
  35. package/src/bundled-plugins/backup/runner.ts +23 -2
  36. package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
  37. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
  38. package/src/bundled-plugins/memory/README.md +11 -11
  39. package/src/bundled-plugins/memory/dreaming.ts +5 -0
  40. package/src/bundled-plugins/memory/search-tool.ts +98 -1
  41. package/src/bundled-plugins/operator/operator.ts +5 -1
  42. package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
  43. package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
  44. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  45. package/src/bundled-plugins/scout/scout.ts +7 -7
  46. package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
  47. package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
  48. package/src/bundled-plugins/tool-result-cap/README.md +1 -1
  49. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  50. package/src/channels/adapters/discord-bot.ts +25 -3
  51. package/src/channels/adapters/github/inbound.ts +172 -10
  52. package/src/channels/adapters/github/index.ts +10 -0
  53. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  54. package/src/channels/adapters/github/webhook-register.ts +32 -27
  55. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  56. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  57. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  58. package/src/channels/adapters/slack-bot.ts +67 -8
  59. package/src/channels/manager.ts +8 -2
  60. package/src/channels/router.ts +506 -45
  61. package/src/channels/schema.ts +21 -4
  62. package/src/channels/subagent-completion-bridge.ts +18 -18
  63. package/src/channels/types.ts +69 -1
  64. package/src/cli/inspect-controller.ts +132 -33
  65. package/src/cli/inspect.ts +2 -1
  66. package/src/commands/index.ts +9 -0
  67. package/src/container/start.ts +7 -1
  68. package/src/git/mutex.ts +22 -0
  69. package/src/git/reconcile-ignored.ts +214 -0
  70. package/src/hostd/daemon.ts +26 -1
  71. package/src/hostd/portbroker-manager.ts +7 -0
  72. package/src/init/dockerfile.ts +1 -1
  73. package/src/init/gitignore.ts +28 -16
  74. package/src/inspect/index.ts +53 -4
  75. package/src/inspect/loop.ts +16 -12
  76. package/src/plugin/define.ts +2 -2
  77. package/src/plugin/index.ts +2 -2
  78. package/src/portbroker/hostd-client.ts +36 -13
  79. package/src/run/index.ts +74 -5
  80. package/src/sandbox/build.ts +20 -0
  81. package/src/sandbox/index.ts +10 -0
  82. package/src/sandbox/policy.ts +22 -0
  83. package/src/sandbox/session-tmp.ts +43 -0
  84. package/src/sandbox/writable-zones.ts +178 -0
  85. package/src/server/command-runner.ts +1 -1
  86. package/src/server/index.ts +126 -4
  87. package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
  88. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  89. package/src/tui/format.ts +11 -11
  90. package/typeclaw.schema.json +10 -0
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
  })
@@ -328,6 +347,20 @@ export async function startAgent({
328
347
  ...(entry.pluginSubagent.customTools ? { customTools: entry.pluginSubagent.customTools } : {}),
329
348
  toolNamePrefix: `__plugin_${entry.pluginName}_${entry.subagentName}`,
330
349
  },
350
+ // Orchestration wiring is opt-in per subagent (canSpawnSubagents) so
351
+ // only operator/reviewer can delegate; explorer/scout/etc. stay leaves.
352
+ // The same liveSubagentRegistry instance is shared, but
353
+ // subagent_output/subagent_cancel scope by the caller's session (see
354
+ // authorizeLiveSubagentAccess) and spawn_subagent caps the chain at
355
+ // MAX_SUBAGENT_DEPTH. createSessionForSubagent self-references so a
356
+ // nested spawn re-enters this same factory.
357
+ ...(entry.pluginSubagent.canSpawnSubagents === true
358
+ ? {
359
+ liveSubagentRegistry,
360
+ subagentRegistry: snap.subagents,
361
+ createSessionForSubagent,
362
+ }
363
+ : {}),
331
364
  ...(entry.pluginSubagent.profile !== undefined ? { profile: entry.pluginSubagent.profile } : {}),
332
365
  ...(entry.pluginSubagent.toolResultBudget !== undefined
333
366
  ? { toolResultBudget: entry.pluginSubagent.toolResultBudget }
@@ -434,6 +467,13 @@ export async function startAgent({
434
467
  // marker so the audit trail records "user edited cron.json".
435
468
  scheduledByOrigin: (job.scheduledByOrigin as SessionOrigin | undefined) ?? { kind: 'config-file' },
436
469
  }
470
+ // Cron todos are per-fire ephemeral by default: each scheduled run starts
471
+ // with a clean list so an incomplete item from a prior fire cannot
472
+ // resurrect indefinitely on every tick. (A future opt-in could carry them
473
+ // forward; until then, clearing is the safe default.)
474
+ await clearTodosForOrigin(cwd, cronOrigin).catch((err) =>
475
+ console.error(`[cron] ${job.id}: clear todos failed: ${err instanceof Error ? err.message : String(err)}`),
476
+ )
437
477
  const session = await createSession({
438
478
  reloadRegistry,
439
479
  sessionManager,
@@ -507,8 +547,37 @@ export async function startAgent({
507
547
  })
508
548
 
509
549
  reloadRegistry.register(createChannelsReloadable({ manager: channelManager }))
550
+
551
+ // Two-phase channel restart-resume around adapter startup, to close the race
552
+ // where an adapter starts receiving before the resume claims the handoff:
553
+ // 1. Claim the channel handoff and RESERVE the originating key BEFORE
554
+ // channelManager.start(). The reservation installs a per-key gate, so an
555
+ // inbound that arrives the instant an adapter connects coalesces onto the
556
+ // resume instead of stale-rolling the mapping or creating a rival session.
557
+ // 2. start() the adapters (registers outbound callbacks the wake reply needs).
558
+ // 3. resume() the reservation: reopen the exact session and enqueue the wake
559
+ // — skipped automatically if a real inbound already coalesced in (2)→(3).
560
+ // Claims ONLY channel handoffs; tui handoffs are left on disk (peek-then-delete
561
+ // never removes an unclaimed handoff) for the websocket open handler to claim.
562
+ // Best-effort throughout: any failure leaves the todo to resume on the next inbound.
563
+ let restartReservation: ReturnType<typeof channelManager.router.reserveRestartHandoff> = null
564
+ try {
565
+ const handoff = await consumeRestartHandoff(cwd, { accept: (h) => h.origin.kind === 'channel' })
566
+ if (handoff !== null) restartReservation = channelManager.router.reserveRestartHandoff(handoff)
567
+ } catch (err) {
568
+ console.warn(`[run] channel restart-resume reserve failed: ${err instanceof Error ? err.message : err}`)
569
+ }
570
+
510
571
  await channelManager.start()
511
572
 
573
+ if (restartReservation !== null) {
574
+ try {
575
+ await restartReservation.resume()
576
+ } catch (err) {
577
+ console.warn(`[run] channel restart-resume failed: ${err instanceof Error ? err.message : err}`)
578
+ }
579
+ }
580
+
512
581
  // Captured separately from setSpawnSubagent so both the plugin context and
513
582
  // the plugin-command runner can dispatch through the same path. The setter
514
583
  // returns void, so without this local binding we couldn't reuse the fn.
@@ -110,6 +110,8 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
110
110
  }
111
111
 
112
112
  appendMasks(argv, policy)
113
+ appendWritable(argv, policy)
114
+ appendProtected(argv, policy)
113
115
 
114
116
  if (policy.cwd !== undefined) {
115
117
  argv.push('--chdir', policy.cwd)
@@ -128,6 +130,24 @@ function appendMasks(argv: string[], policy: SandboxPolicy): void {
128
130
  }
129
131
  }
130
132
 
133
+ function appendWritable(argv: string[], policy: SandboxPolicy): void {
134
+ for (const dir of policy.writable?.dirs ?? []) {
135
+ argv.push('--bind', dir, dir)
136
+ }
137
+ for (const file of policy.writable?.files ?? []) {
138
+ argv.push('--bind', file, file)
139
+ }
140
+ }
141
+
142
+ function appendProtected(argv: string[], policy: SandboxPolicy): void {
143
+ for (const dir of policy.protected?.dirs ?? []) {
144
+ argv.push('--ro-bind', dir, dir)
145
+ }
146
+ for (const file of policy.protected?.files ?? []) {
147
+ argv.push('--ro-bind', file, file)
148
+ }
149
+ }
150
+
131
151
  function appendMount(argv: string[], mount: SandboxMount): void {
132
152
  switch (mount.type) {
133
153
  case 'ro-bind':
@@ -1,6 +1,14 @@
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 {
5
+ resolveProtectedZones,
6
+ resolveWritableZones,
7
+ subtractMasked,
8
+ type ProtectedZones,
9
+ type WritableZones,
10
+ } from './writable-zones'
11
+ export { ensureSessionTmpDir, isUnderTmp, mapVirtualTmpPath, SESSION_TMP_ROOT, sessionTmpDir } from './session-tmp'
4
12
  export { formatCommand, shellQuote } from './quote'
5
13
  export { SandboxPolicyError, SandboxUnavailableError } from './errors'
6
14
  export {
@@ -12,4 +20,6 @@ export {
12
20
  type SandboxPolicy,
13
21
  type SandboxProcessPolicy,
14
22
  type SandboxProcStrategy,
23
+ type SandboxProtectedPolicy,
24
+ type SandboxWritablePolicy,
15
25
  } from './policy'
@@ -37,11 +37,33 @@ 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
+
49
+ // Read-only re-protections carved back out of a writable parent. MUST render
50
+ // after `writable` so bwrap's last-op-wins keeps these EROFS despite the parent
51
+ // RW bind. Load-bearing: `.git` is writable so members can commit, but
52
+ // `.git/hooks` and `.git/config` stay RO here — otherwise a low-trust role
53
+ // plants a hook or sets core.hooksPath and gets code execution in the
54
+ // unsandboxed runtime git ops (backup/dreaming) that share the same .git.
55
+ export type SandboxProtectedPolicy = {
56
+ dirs?: string[]
57
+ files?: string[]
58
+ }
59
+
40
60
  export type SandboxPolicy = {
41
61
  bwrapPath?: string
42
62
  cwd?: string
43
63
  mounts?: SandboxMount[]
44
64
  masks?: SandboxMaskPolicy
65
+ writable?: SandboxWritablePolicy
66
+ protected?: SandboxProtectedPolicy
45
67
  network?: SandboxNetwork
46
68
  env?: SandboxEnvPolicy
47
69
  commandFilter?: SandboxCommandFilter
@@ -0,0 +1,43 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { isAbsolute, join, relative, resolve } from 'node:path'
3
+
4
+ // Per-session scratch lives on the REAL container /tmp, namespaced by session id.
5
+ // It sits OUTSIDE the agent folder on purpose: the agent folder's `sessions/` is
6
+ // force-committed by typeclaw, and scratch must never be committed. The real
7
+ // /tmp is ephemeral (dies with the container) and already the natural home for
8
+ // throwaway files, so a per-session subdir of it gives `/tmp` semantics without
9
+ // either sharing the whole container /tmp into a sandboxed role or persisting
10
+ // anything into the project surface.
11
+ export const SESSION_TMP_ROOT = '/tmp/typeclaw-session'
12
+
13
+ export function sessionTmpDir(sessionId: string): string {
14
+ return join(SESSION_TMP_ROOT, sessionId)
15
+ }
16
+
17
+ export async function ensureSessionTmpDir(sessionId: string): Promise<string> {
18
+ const dir = sessionTmpDir(sessionId)
19
+ await mkdir(dir, { recursive: true, mode: 0o700 })
20
+ return dir
21
+ }
22
+
23
+ export function isUnderTmp(agentDir: string, rawPath: string): boolean {
24
+ const resolved = resolve(agentDir, rawPath)
25
+ return resolved === '/tmp' || isInside('/tmp', resolved)
26
+ }
27
+
28
+ // Maps a model-facing /tmp path to its per-session backing path. Returns
29
+ // undefined when the path is not under /tmp (caller leaves it untouched). The
30
+ // model keeps writing/reading `/tmp/foo`; only the on-disk target moves to
31
+ // `<SESSION_TMP_ROOT>/<sid>/foo`, which is the same dir bwrap binds over `/tmp`
32
+ // for the sandboxed bash that reads it back.
33
+ export function mapVirtualTmpPath(agentDir: string, sessionId: string, rawPath: string): string | undefined {
34
+ const resolved = resolve(agentDir, rawPath)
35
+ if (resolved !== '/tmp' && !isInside('/tmp', resolved)) return undefined
36
+ const rel = relative('/tmp', resolved)
37
+ return rel === '' ? sessionTmpDir(sessionId) : join(sessionTmpDir(sessionId), rel)
38
+ }
39
+
40
+ function isInside(parent: string, child: string): boolean {
41
+ const rel = relative(parent, child)
42
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
43
+ }
@@ -0,0 +1,178 @@
1
+ import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import path, { isAbsolute, join, resolve } from 'node:path'
3
+
4
+ export type WritableZones = {
5
+ dirs: string[]
6
+ files: string[]
7
+ }
8
+
9
+ export type ProtectedZones = {
10
+ dirs: string[]
11
+ files: string[]
12
+ }
13
+
14
+ // SECURITY: a blanket RW bind is coarser than the write/edit guards, so this set
15
+ // is deliberately NARROWER than the write/edit allowlist — only genuinely
16
+ // free-write scratch zones. `.agents/skills` and `packages` are excluded: the
17
+ // former is validated (SKILL.md shape, name, frontmatter) by the skillAuthoring
18
+ // guard and the latter holds executable plugin code; bash must not get blanket
19
+ // RW to either. Skill authoring and package writes go through the guarded
20
+ // write/edit tool only.
21
+ // `.git` is writable so a member can `git add`/`git commit` their own edits.
22
+ // This is the AGENT'S OWN repo, not a shared/upstream one, so writing history
23
+ // is not a privilege boundary: a low-trust role staging a tracked path it
24
+ // cannot edit in the worktree (e.g. via `git update-index --cacheinfo` plumbing)
25
+ // only writes the agent's own history — content the backup runner already
26
+ // force-commits on idle regardless. So we deliberately do NOT try to confine
27
+ // commit *content* to the worktree write-allowlist; that boundary governs the
28
+ // working tree, not the object database.
29
+ //
30
+ // The one thing writable `.git` must NOT grant is code execution in the
31
+ // UNSANDBOXED runtime (backup/dreaming commit the same .git out of band): a
32
+ // planted `.git/hooks/*` or a `core.hooksPath` in `.git/config` would fire there
33
+ // as a higher-privilege process. resolveProtectedZones re-binds `.git/hooks` and
34
+ // `.git/config` read-only (after the writable .git bind, last-op-wins) to close
35
+ // exactly that escalation.
36
+ const WRITABLE_DIRS = ['workspace', 'public', 'mounts', '.git'] as const
37
+
38
+ const PROTECTED_GIT_DIRS = ['.git/hooks'] as const
39
+ const PROTECTED_GIT_FILES = ['.git/config'] as const
40
+
41
+ // Bash may EDIT these when present; creating a MISSING root file goes through
42
+ // write/edit (bwrap cannot RW-bind a non-existent source without pre-creating it).
43
+ const WRITABLE_ROOT_FILES = [
44
+ 'AGENTS.md',
45
+ 'IDENTITY.md',
46
+ 'SOUL.md',
47
+ 'USER.md',
48
+ 'cron.json',
49
+ 'package.json',
50
+ 'typeclaw.json',
51
+ ] as const
52
+
53
+ // SECURITY: the symlink rejection is load-bearing. An RW bind follows symlinks,
54
+ // so a `workspace -> /etc` symlink at a zone root would grant write access to an
55
+ // outside path. (Symlinks INSIDE a real zone are already safe — the kernel
56
+ // resolves them to the read-only parent mount.)
57
+ export async function resolveWritableZones(agentDir: string): Promise<WritableZones> {
58
+ const dirs = await collectExisting(
59
+ WRITABLE_DIRS.map((d) => join(agentDir, d)),
60
+ 'dir',
61
+ )
62
+ const files = await collectExisting(
63
+ WRITABLE_ROOT_FILES.map((f) => join(agentDir, f)),
64
+ 'file',
65
+ )
66
+ return { dirs, files }
67
+ }
68
+
69
+ // Read-only re-protections rendered on top of the writable .git bind. Unlike
70
+ // the writable resolvers, this MUST NOT drop absent entries: .git is writable,
71
+ // so a path absent at jail-build time would otherwise be CREATED by sandboxed
72
+ // bash (e.g. a planted .git/hooks/pre-commit) and then executed by the
73
+ // unsandboxed runtime git ops. So we ensure each protected path exists first,
74
+ // then always RO-bind it — a read-only bind of a real dir blocks creating
75
+ // children inside it (EROFS), and a read-only bind of config keeps its real
76
+ // content readable (commits need user.name/email) while blocking mutation.
77
+ //
78
+ // We also resolve the effective core.hooksPath from the real (about-to-be-RO)
79
+ // config: if it already points at a writable location (e.g. workspace/hooks),
80
+ // the .git/hooks RO-bind alone would not cover it, so that dir is protected too.
81
+ export async function resolveProtectedZones(agentDir: string): Promise<ProtectedZones> {
82
+ const dirs: string[] = []
83
+ for (const rel of PROTECTED_GIT_DIRS) {
84
+ dirs.push(await ensureProtectedDir(join(agentDir, rel)))
85
+ }
86
+ const files: string[] = []
87
+ for (const rel of PROTECTED_GIT_FILES) {
88
+ files.push(await ensureProtectedFile(join(agentDir, rel)))
89
+ }
90
+
91
+ const hooksPathDir = await resolveEffectiveHooksPath(agentDir)
92
+ if (hooksPathDir !== undefined && !dirs.includes(hooksPathDir)) {
93
+ dirs.push(await ensureProtectedDir(hooksPathDir))
94
+ }
95
+
96
+ return { dirs, files }
97
+ }
98
+
99
+ // Fail closed: a symlink at a protected path would make the RO bind follow it
100
+ // elsewhere, so reject it rather than silently protect the wrong target.
101
+ async function ensureProtectedDir(target: string): Promise<string> {
102
+ await mkdir(target, { recursive: true })
103
+ await assertNotSymlink(target)
104
+ return target
105
+ }
106
+
107
+ async function ensureProtectedFile(target: string): Promise<string> {
108
+ if (!(await isRealEntry(target, 'file'))) {
109
+ try {
110
+ await writeFile(target, '', { flag: 'wx' })
111
+ } catch {
112
+ // Lost a race (or it appeared); the symlink check below still guards it.
113
+ }
114
+ }
115
+ await assertNotSymlink(target)
116
+ return target
117
+ }
118
+
119
+ async function assertNotSymlink(target: string): Promise<void> {
120
+ const stats = await lstat(target)
121
+ if (stats.isSymbolicLink()) {
122
+ throw new Error(`sandbox: refusing to protect symlinked path ${target}`)
123
+ }
124
+ }
125
+
126
+ // Reads core.hooksPath straight from .git/config text (the file is about to be
127
+ // RO-bound, so its content is the trusted baseline). Returns the resolved
128
+ // absolute dir only when it lands inside agentDir — an outside path is not
129
+ // writable by the jail and a relative path resolves against the repo root, per
130
+ // gitconfig semantics.
131
+ async function resolveEffectiveHooksPath(agentDir: string): Promise<string | undefined> {
132
+ let text: string
133
+ try {
134
+ text = await readFile(join(agentDir, '.git', 'config'), 'utf8')
135
+ } catch {
136
+ return undefined
137
+ }
138
+ const match = text.match(/^\s*hooksPath\s*=\s*(.+?)\s*$/m)
139
+ if (match === null) return undefined
140
+ const raw = match[1]?.trim()
141
+ if (raw === undefined || raw.length === 0) return undefined
142
+ const resolved = isAbsolute(raw) ? resolve(raw) : resolve(agentDir, raw)
143
+ return isInside(agentDir, resolved) ? resolved : undefined
144
+ }
145
+
146
+ // SECURITY: a writable RW bind renders AFTER the masks and last-op-wins, so an
147
+ // RW bind on a masked path would re-expose the real (hidden) directory. Drop any
148
+ // writable zone that is, or is nested under, a masked path so the confidentiality
149
+ // boundary survives — e.g. a guest's masked `workspace/` is never re-exposed RW.
150
+ export function subtractMasked(writable: WritableZones, masked: { dirs: string[]; files: string[] }): WritableZones {
151
+ const maskedDirs = masked.dirs
152
+ const isMasked = (target: string): boolean =>
153
+ masked.files.includes(target) || maskedDirs.some((dir) => target === dir || isInside(dir, target))
154
+ return {
155
+ dirs: writable.dirs.filter((dir) => !isMasked(dir)),
156
+ files: writable.files.filter((file) => !isMasked(file)),
157
+ }
158
+ }
159
+
160
+ function isInside(parent: string, child: string): boolean {
161
+ const relative = path.relative(parent, child)
162
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
163
+ }
164
+
165
+ async function collectExisting(paths: string[], kind: 'dir' | 'file'): Promise<string[]> {
166
+ const checks = await Promise.all(paths.map((p) => isRealEntry(p, kind)))
167
+ return paths.filter((_, i) => checks[i])
168
+ }
169
+
170
+ async function isRealEntry(path: string, kind: 'dir' | 'file'): Promise<boolean> {
171
+ try {
172
+ const stats = await lstat(path)
173
+ if (stats.isSymbolicLink()) return false
174
+ return kind === 'dir' ? stats.isDirectory() : stats.isFile()
175
+ } catch {
176
+ return false
177
+ }
178
+ }
@@ -389,7 +389,7 @@ export async function runPromptForCommand(args: {
389
389
  // Mirrors src/agent/multimodal/look-at.ts: spawn a session, prompt, capture
390
390
  // the final assistant text, dispose. Unlike look-at we want the FULL agent
391
391
  // toolset (no `tools: []` / `customTools: []` overrides) so the model can
392
- // call channel_send, websearch, etc. The system prompt is composed from
392
+ // call channel_send, web_search, etc. The system prompt is composed from
393
393
  // the agent folder's IDENTITY/SOUL/MEMORY files via the default resource
394
394
  // loader (no `systemPromptOverride`).
395
395
  const snapshot = args.runtime.get()