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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agent/index.ts +133 -27
- package/src/agent/llm-replay-sanitizer.ts +120 -0
- package/src/agent/loop-guard.ts +34 -0
- package/src/agent/multimodal/look-at.ts +1 -1
- package/src/agent/plugin-tools.ts +122 -8
- package/src/agent/restart/index.ts +15 -3
- package/src/agent/restart-handoff/index.ts +110 -12
- package/src/agent/session-origin.ts +30 -0
- package/src/agent/subagent-completion-reminder.ts +26 -1
- package/src/agent/subagents.ts +75 -3
- package/src/agent/system-prompt.ts +5 -1
- package/src/agent/todo/continuation-policy.ts +242 -0
- package/src/agent/todo/continuation-state.ts +87 -0
- package/src/agent/todo/continuation-wiring.ts +113 -0
- package/src/agent/todo/continuation.ts +71 -0
- package/src/agent/todo/scope.ts +77 -0
- package/src/agent/todo/store.ts +98 -0
- package/src/agent/tool-not-found-nudge.ts +126 -0
- package/src/agent/tools/channel-reply.ts +51 -0
- package/src/agent/tools/curl-impersonate.ts +2 -2
- package/src/agent/tools/restart.ts +11 -4
- package/src/agent/tools/spawn-subagent.ts +19 -2
- package/src/agent/tools/subagent-access.ts +40 -5
- package/src/agent/tools/subagent-cancel.ts +3 -1
- package/src/agent/tools/subagent-output.ts +6 -2
- package/src/agent/tools/todo/index.ts +119 -0
- package/src/agent/tools/webfetch/fetch.ts +18 -18
- package/src/agent/tools/webfetch/index.ts +1 -1
- package/src/agent/tools/webfetch/tool.ts +13 -13
- package/src/agent/tools/webfetch/types.ts +1 -1
- package/src/agent/tools/websearch.ts +6 -6
- package/src/bundled-plugins/backup/index.ts +40 -37
- package/src/bundled-plugins/backup/runner.ts +23 -2
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
- package/src/bundled-plugins/memory/README.md +11 -11
- package/src/bundled-plugins/memory/dreaming.ts +5 -0
- package/src/bundled-plugins/memory/search-tool.ts +98 -1
- package/src/bundled-plugins/operator/operator.ts +5 -1
- package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
- package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/bundled-plugins/scout/scout.ts +7 -7
- package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
- package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
- package/src/bundled-plugins/tool-result-cap/README.md +1 -1
- package/src/channels/adapters/discord-bot-reference.ts +78 -0
- package/src/channels/adapters/discord-bot.ts +25 -3
- package/src/channels/adapters/github/inbound.ts +172 -10
- package/src/channels/adapters/github/index.ts +10 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
- package/src/channels/adapters/github/webhook-register.ts +32 -27
- package/src/channels/adapters/kakaotalk-classify.ts +67 -6
- package/src/channels/adapters/slack-bot-classify.ts +9 -1
- package/src/channels/adapters/slack-bot-reference.ts +129 -0
- package/src/channels/adapters/slack-bot.ts +67 -8
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +506 -45
- package/src/channels/schema.ts +21 -4
- package/src/channels/subagent-completion-bridge.ts +18 -18
- package/src/channels/types.ts +69 -1
- package/src/cli/inspect-controller.ts +132 -33
- package/src/cli/inspect.ts +2 -1
- package/src/commands/index.ts +9 -0
- package/src/container/start.ts +7 -1
- package/src/git/mutex.ts +22 -0
- package/src/git/reconcile-ignored.ts +214 -0
- package/src/hostd/daemon.ts +26 -1
- package/src/hostd/portbroker-manager.ts +7 -0
- package/src/init/dockerfile.ts +1 -1
- package/src/init/gitignore.ts +28 -16
- package/src/inspect/index.ts +53 -4
- package/src/inspect/loop.ts +16 -12
- package/src/plugin/define.ts +2 -2
- package/src/plugin/index.ts +2 -2
- package/src/portbroker/hostd-client.ts +36 -13
- package/src/run/index.ts +74 -5
- package/src/sandbox/build.ts +20 -0
- package/src/sandbox/index.ts +10 -0
- package/src/sandbox/policy.ts +22 -0
- package/src/sandbox/session-tmp.ts +43 -0
- package/src/sandbox/writable-zones.ts +178 -0
- package/src/server/command-runner.ts +1 -1
- package/src/server/index.ts +126 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/tui/format.ts +11 -11
- 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
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
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.
|
package/src/sandbox/build.ts
CHANGED
|
@@ -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':
|
package/src/sandbox/index.ts
CHANGED
|
@@ -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'
|
package/src/sandbox/policy.ts
CHANGED
|
@@ -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,
|
|
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()
|