@twinsearth/dsh-master 0.0.0-stage → 3.2.8

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 (95) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +89 -2
  3. package/client.js +263 -0
  4. package/cordis.patch.yml +71 -0
  5. package/dist-host/contract/client.js +82 -0
  6. package/dist-host/contract/constants.js +70 -0
  7. package/dist-host/contract/envelope.js +84 -0
  8. package/dist-host/contract/index.js +17 -0
  9. package/dist-host/contract/negotiate.js +87 -0
  10. package/dist-host/contract/server.js +71 -0
  11. package/dist-host/contract/transports.js +360 -0
  12. package/dist-host/index.js +260 -0
  13. package/dist-host/kernel.js +186 -0
  14. package/dist-host/plugins/cost-optimizer.js +147 -0
  15. package/dist-host/plugins/dual-manager.js +251 -0
  16. package/dist-host/plugins/superagent-router.js +499 -0
  17. package/dist-host/plugins/superagent-worker.js +73 -0
  18. package/dist-host/superagent/bridge-tokens.js +236 -0
  19. package/dist-host/superagent/dispatch.js +198 -0
  20. package/dist-host/superagent/feishu.js +115 -0
  21. package/dist-host/superagent/github.js +119 -0
  22. package/dist-host/superagent/pr-delivery.js +351 -0
  23. package/dist-host/superagent/router.js +219 -0
  24. package/dist-host/superagent/stream-adapter.js +238 -0
  25. package/dist-host/superagent/stream-tokens.js +141 -0
  26. package/dist-host/superagent/ticket-store.js +108 -0
  27. package/dist-host/superagent/trust.js +145 -0
  28. package/dist-host/superagent/types.js +144 -0
  29. package/dist-host/superagent/worker.js +95 -0
  30. package/dist-host/superagent/writeback.js +162 -0
  31. package/dist-worker/agent-loop.js +493 -0
  32. package/dist-worker/errors.js +41 -0
  33. package/dist-worker/github-pr.js +44 -0
  34. package/dist-worker/index.js +323 -0
  35. package/dist-worker/llm-http.js +351 -0
  36. package/dist-worker/main.js +45 -0
  37. package/dist-worker/prompt.js +35 -0
  38. package/dist-worker/sink-http.js +135 -0
  39. package/dist-worker/tools.js +542 -0
  40. package/dist-worker/types.js +14 -0
  41. package/docs/ARCHITECTURE.md +114 -0
  42. package/docs/CHANGELOG.md +377 -0
  43. package/docs/INTEROP.md +60 -0
  44. package/docs/REVIEW-v3.2.7.md +121 -0
  45. package/docs/ROADMAP.md +106 -0
  46. package/docs/SECURITY.md +153 -0
  47. package/docs/SMOKE-3.2.5.md +77 -0
  48. package/docs/SMOKE-3.2.7.md +73 -0
  49. package/docs/SUPERAGENT.md +141 -0
  50. package/docs/WORKER-AGENT-LOOP.md +139 -0
  51. package/icon.svg +9 -0
  52. package/locale/en.json +6 -0
  53. package/locale/zh.json +6 -0
  54. package/package.json +79 -4
  55. package/src/contract/client.js +86 -0
  56. package/src/contract/constants.js +79 -0
  57. package/src/contract/envelope.js +83 -0
  58. package/src/contract/index.js +19 -0
  59. package/src/contract/negotiate.js +106 -0
  60. package/src/contract/server.js +77 -0
  61. package/src/contract/transports.js +319 -0
  62. package/src/index.ts +265 -0
  63. package/src/kernel.ts +190 -0
  64. package/src/plugins/cost-optimizer.ts +155 -0
  65. package/src/plugins/dual-manager.ts +252 -0
  66. package/src/plugins/superagent-router.ts +465 -0
  67. package/src/plugins/superagent-worker.ts +85 -0
  68. package/src/superagent/bridge-tokens.ts +236 -0
  69. package/src/superagent/dispatch.ts +197 -0
  70. package/src/superagent/feishu.ts +104 -0
  71. package/src/superagent/github.ts +111 -0
  72. package/src/superagent/pr-delivery.ts +308 -0
  73. package/src/superagent/router.ts +201 -0
  74. package/src/superagent/stream-adapter.ts +184 -0
  75. package/src/superagent/stream-tokens.ts +142 -0
  76. package/src/superagent/ticket-store.ts +104 -0
  77. package/src/superagent/trust.ts +139 -0
  78. package/src/superagent/types.ts +135 -0
  79. package/src/superagent/worker.ts +100 -0
  80. package/src/superagent/writeback.ts +154 -0
  81. package/tsconfig.host.build.json +12 -0
  82. package/tsconfig.host.json +21 -0
  83. package/tsconfig.worker.build.json +13 -0
  84. package/tsconfig.worker.json +17 -0
  85. package/types/superagent.d.ts +99 -0
  86. package/worker/agent-loop.ts +524 -0
  87. package/worker/errors.ts +47 -0
  88. package/worker/github-pr.ts +65 -0
  89. package/worker/index.ts +334 -0
  90. package/worker/llm-http.ts +359 -0
  91. package/worker/main.ts +45 -0
  92. package/worker/prompt.ts +39 -0
  93. package/worker/sink-http.ts +159 -0
  94. package/worker/tools.ts +531 -0
  95. package/worker/types.ts +247 -0
@@ -0,0 +1,531 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+
4
+ /**
5
+ * Worker 的安全工具集(v3.2.3)。
6
+ *
7
+ * v3.2.2 安全审计后的加固:
8
+ * * Shell 使用白名单环境变量(剥离 KEY/TOKEN/SECRET/DSH_*),防密钥回灌模型;
9
+ * * Shell 超时/中止时 kill 整个进程树(Windows taskkill /T /F;POSIX 进程组),
10
+ * 不再出现“工具已返回但后台进程继续跑”;
11
+ * * fs_write_file 的 Mandate 来自每次调用的 context.mandate(而非构造闭包),
12
+ * 并默认拒绝 .git/.dsh/.env 等敏感路径;
13
+ * * 路径 jail + realpath 防 symlink 逃逸保持不变。
14
+ *
15
+ * 仍然坚持:Shell 的高危语义无法用黑名单彻底解决,**VM/容器隔离是主防线**;
16
+ * allowShell 应与“已证明隔离的 VM”绑定(见 docs/SECURITY.md)。
17
+ */
18
+
19
+ import fs from 'node:fs/promises'
20
+ import path from 'node:path'
21
+ import { spawn } from 'node:child_process'
22
+
23
+ import type {
24
+ TicketMandate,
25
+ ToolExecutionContext,
26
+ ToolSchema,
27
+ WorkerTool,
28
+ } from './types.ts'
29
+
30
+ export const LONG_SYNC_COMMAND_THRESHOLD_MS = 5000
31
+ const MAX_READ_BYTES = 256 * 1024
32
+ const MAX_WRITE_BYTES = 1024 * 1024
33
+ const MAX_SHELL_OUTPUT_CHARS = 16_000
34
+ const SHELL_ENV_ALLOWLIST = new Set([
35
+ 'PATH', 'Path', 'PATHEXT', 'SYSTEMROOT', 'SystemRoot', 'WINDIR', 'COMSPEC', 'ComSpec',
36
+ 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'LANG', 'LC_ALL', 'TZ', 'SHELL',
37
+ ])
38
+
39
+ /** 默认拒绝写入的敏感路径(相对 workspace;可在 config 覆盖)。 */
40
+ export const DEFAULT_DENY_WRITE_PATHS = ['.git', '.dsh', '.env', '.env.local', '.ssh', 'node_modules/.cache']
41
+
42
+ /**
43
+ * 命令级禁锢(v3.2.5 P0-2 纵深防御):
44
+ *
45
+ * 黑名单**不是**安全边界—— type ../x / echo > ../x / sh -c ... / powershell -Command ...
46
+ * 都能绕过字面黑名单。这里在命令串层面硬拒绝能逃出 jail 的构造:
47
+ * * 重定向与管道(> >> < | && || ;):可把输出写到任意路径、可串联任意解释器;
48
+ * * 目录切换(cd / chdir / pushd / popd):一旦 cd 出 jail,后续相对路径全部逃离;
49
+ * * 父目录穿越(..):相对逃逸的主要形式;
50
+ * * 绝对路径(盘符 / 前导斜杠 / ~):jail 外的绝对路径;
51
+ * * 环境变量展开(%VAR% / $VAR / ${VAR}):可拼出密钥名或路径;
52
+ * * 解释器(sh/bash/cmd/powershell/node/python/...):等价任意代码执行。
53
+ *
54
+ * 真正的边界仍是 VM/容器(见 docs/SECURITY.md):本函数只保证「未证明隔离时不可用」。
55
+ */
56
+ const SHELL_INTERPRETERS = new Set([
57
+ 'sh', 'bash', 'zsh', 'dash', 'ksh', 'fish',
58
+ 'cmd', 'cmd.exe', 'powershell', 'powershell.exe', 'pwsh', 'pwsh.exe',
59
+ 'node', 'nodejs', 'python', 'python3', 'perl', 'ruby', 'php', 'deno', 'bun',
60
+ 'wscript', 'cscript', 'mshta', 'rundll32', 'regsvr32', 'certutil', 'bitsadmin',
61
+ ])
62
+
63
+ /** 返回拒绝原因;null 表示通过命令级禁锢检查。 */
64
+ export function shellConfinementViolation(command: string): string | null {
65
+ const raw = String(command ?? '')
66
+ if (!raw.trim()) return 'empty-command'
67
+ // 1) 重定向 / 管道 / 串联 / 命令替换
68
+ if (/[<>|&;`]/.test(raw)) return 'shell-redirection-or-chaining'
69
+ if (/\$\(/.test(raw)) return 'shell-substitution'
70
+ // 2) 目录切换
71
+ if (/\b(cd|chdir|pushd|popd)\b/i.test(raw)) return 'shell-cd'
72
+ // 3) 父目录穿越(路径段形式,避免误伤 a..b 文件名)
73
+ if (/(^|[\s"'])\.\.([\\/]|$)/.test(raw)) return 'shell-parent-traversal'
74
+ // 4) 绝对路径(盘符 / 前导斜杠 / UNC / ~)
75
+ if (/[A-Za-z]:[\\/]/.test(raw)) return 'shell-absolute-path'
76
+ if (/(^|[\s"'])[\\/]{1,2}[^\s"']/.test(raw)) return 'shell-absolute-path'
77
+ if (/(^|[\s"'])~[\\/]/.test(raw)) return 'shell-home-path'
78
+ // 5) 环境变量展开(可能拼出密钥名或 jail 外路径)
79
+ if (/%[A-Za-z_][A-Za-z0-9_]*%/.test(raw)) return 'shell-env-expansion'
80
+ if (/\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/.test(raw)) return 'shell-env-expansion'
81
+ // 6) 解释器作为命令词(任意代码执行)
82
+ const firstWord = raw.trim().split(/\s+/)[0].replace(/^["']|["']$/g, '').toLowerCase()
83
+ const base = firstWord.split(/[\\/]/).pop() ?? firstWord
84
+ if (SHELL_INTERPRETERS.has(base)) return 'shell-interpreter:' + base
85
+ // 7) git 危险子命令(含 -C / --git-dir / -c 变体)
86
+ if (/\bgit\b[^;&|]*\b(push|remote|config|credential|submodule)\b/i.test(raw)) return 'shell-git-write'
87
+ return null
88
+ }
89
+
90
+ /** 危险 Shell 模式(最小黑名单;VM 网络/资源隔离仍是主防线)。 */
91
+ export const DEFAULT_DENY_PATTERNS: RegExp[] = [
92
+ /\bsudo\b/i,
93
+ /\bmkfs\b/i,
94
+ /\bdd\s+if=/i,
95
+ /\bshutdown\b/i,
96
+ /\breboot\b/i,
97
+ /\b(curl|wget)\b/i,
98
+ /\b(ssh|scp|nc|ncat|telnet)\b/i,
99
+ /\bchmod\s+777\b/i,
100
+ /\brm\s+-[a-z]*r[a-z]*f[a-z]*\s+\/(\s|$)/i,
101
+ /\bgit\s+push\b/i,
102
+ /\bgh\s+(auth|token)\b/i,
103
+ /\bpowershell(\.exe)?\b.*\s-(enc|e)\b/i,
104
+ /\b(base64\s+-d|frombase64string)\b.*\|\s*(sh|bash|powershell)/i,
105
+ // 解释器 -e/-c 任意代码执行:黑名单挡不住,直接拒绝(VM 内也应尽量少用)。
106
+ /\b(node|python|python3|perl|ruby|php)\b.*\s-(e|c)\b/i,
107
+ ]
108
+
109
+ /** 只保留安全环境变量;任何 KEY/TOKEN/SECRET/PASSWORD/AUTH/DSH_* 一律剔除。 */
110
+ export function sanitizeShellEnv(env: Record<string, string | undefined> = process.env): Record<string, string> {
111
+ const safe: Record<string, string> = {}
112
+ for (const [key, value] of Object.entries(env)) {
113
+ if (value === undefined) continue
114
+ if (!SHELL_ENV_ALLOWLIST.has(key)) continue
115
+ safe[key] = value
116
+ }
117
+ // 最小可运行集:没有 PATH 时用系统默认(exec 会在 shell 内置 PATH,但显式更稳)。
118
+ if (!safe.PATH && !safe.Path) safe.PATH = process.platform === 'win32' ? 'C:\\Windows\\System32;C:\\Windows' : '/usr/local/bin:/usr/bin:/bin'
119
+ return safe
120
+ }
121
+
122
+ /** 杀掉整棵进程树(Windows 用 taskkill /T;POSIX 用负 pid 信号)。 */
123
+ export function killProcessTree(child: { pid?: number } | null | undefined, platform = process.platform): Promise<void> {
124
+ const pid = Number(child?.pid)
125
+ if (!Number.isInteger(pid) || pid <= 0) return Promise.resolve()
126
+ return new Promise((resolve) => {
127
+ try {
128
+ if (platform === 'win32') {
129
+ const killer = spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true })
130
+ killer.on('close', () => resolve())
131
+ killer.on('error', () => resolve())
132
+ } else {
133
+ try { process.kill(-pid, 'SIGKILL') } catch { /* already gone */ }
134
+ try { process.kill(pid, 'SIGKILL') } catch { /* already gone */ }
135
+ resolve()
136
+ }
137
+ } catch {
138
+ resolve()
139
+ }
140
+ })
141
+ }
142
+
143
+ export interface ShellResult {
144
+ stdout: string
145
+ stderr: string
146
+ code: number | null
147
+ timedOut: boolean
148
+ aborted: boolean
149
+ }
150
+
151
+ /**
152
+ * 在 workspace 内执行 Shell;超时/中止时杀死进程树并等待退出。
153
+ * 输出上限 2MB(超出 kill),返回值再截断到 16k 字符。
154
+ */
155
+ export function runShell(command: string, options: {
156
+ cwd: string
157
+ timeoutMs: number
158
+ signal: AbortSignal
159
+ env?: Record<string, string | undefined>
160
+ }): Promise<ShellResult> {
161
+ return new Promise((resolve) => {
162
+ const child = spawn(command, {
163
+ cwd: options.cwd,
164
+ env: sanitizeShellEnv(options.env ?? process.env),
165
+ shell: true,
166
+ windowsHide: true,
167
+ detached: process.platform !== 'win32',
168
+ })
169
+ let stdout = ''
170
+ let stderr = ''
171
+ let settled = false
172
+ let timedOut = false
173
+ let aborted = false
174
+ let size = 0
175
+ let killTimer: ReturnType<typeof setTimeout> | undefined
176
+ const finish = (code: number | null) => {
177
+ if (settled) return
178
+ settled = true
179
+ clearTimeout(timer)
180
+ if (killTimer) clearTimeout(killTimer)
181
+ options.signal.removeEventListener('abort', onAbort)
182
+ // close 表示 stdio 已关闭;只有超时/中止/输出爆量时才需补杀进程树。
183
+ // 不再对每次成功命令都调用 taskkill,也不等待一个可能永远不来的第二次 close。
184
+ resolve({ stdout, stderr, code, timedOut, aborted })
185
+ }
186
+ const killAndSettle = () => {
187
+ void killProcessTree(child)
188
+ // 某些孙进程持有管道时 close 可能迟到;给正常关闭一个短窗口后硬结算。
189
+ if (killTimer) clearTimeout(killTimer)
190
+ killTimer = setTimeout(() => finish(null), 500)
191
+ killTimer.unref?.()
192
+ }
193
+ const timer = setTimeout(() => {
194
+ timedOut = true
195
+ killAndSettle()
196
+ }, Math.max(1, options.timeoutMs))
197
+ const onAbort = () => {
198
+ aborted = true
199
+ killAndSettle()
200
+ }
201
+ options.signal.addEventListener('abort', onAbort, { once: true })
202
+ child.stdout?.on('data', (chunk: Buffer) => {
203
+ size += chunk.length
204
+ if (size > 2 * 1024 * 1024) { killAndSettle(); return }
205
+ if (stdout.length < MAX_SHELL_OUTPUT_CHARS * 2) stdout += chunk.toString('utf8')
206
+ })
207
+ child.stderr?.on('data', (chunk: Buffer) => {
208
+ size += chunk.length
209
+ if (size > 2 * 1024 * 1024) { killAndSettle(); return }
210
+ if (stderr.length < MAX_SHELL_OUTPUT_CHARS * 2) stderr += chunk.toString('utf8')
211
+ })
212
+ child.on('error', (error) => { stderr += `\n${error.message}`; void finish(null) })
213
+ child.on('close', (code) => { void finish(code) })
214
+ })
215
+ }
216
+
217
+ export interface PullRequestInput {
218
+ title: string
219
+ body: string
220
+ branch?: string
221
+ base?: string
222
+ }
223
+
224
+ export interface PullRequestClient {
225
+ createPullRequest(input: PullRequestInput): Promise<string>
226
+ }
227
+
228
+ export interface SandboxOptions {
229
+ workspace: string
230
+ mandate: TicketMandate
231
+ /**
232
+ * VM/容器隔离是否已被证明(v3.2.5 P0-2 fail-closed):
233
+ * 未证明时 exec_shell_in_vm 一律拒绝——黑名单/路径字符串检查都不是安全边界。
234
+ */
235
+ vmIsolation?: boolean
236
+ prClient?: PullRequestClient
237
+ denyPatterns?: RegExp[]
238
+ /** 额外拒绝写入的相对路径。 */
239
+ denyWritePaths?: string[]
240
+ /** Shell 超过该时长后转为后台作业(默认 5000ms;测试可缩小)。 */
241
+ backgroundAfterMs?: number
242
+ }
243
+
244
+ /** 解析 Mandate.scope 为写 jail 根;scope 是 threadId/非路径时退回 workspace。 */
245
+ export function resolveMandateRoot(workspace: string, scope: string): string {
246
+ const root = path.resolve(workspace)
247
+ const raw = String(scope ?? '').trim()
248
+ if (!raw) return root
249
+ // scope 首先是一张 Ticket 的逻辑边界(GitHub issue/飞书 thread),不是路径。
250
+ // 仅当配置明确给出已存在的仓库子目录时才收窄写 jail,绝不把飞书 ID 当成路径。
251
+ if (raw.includes('#') || raw.includes(':')) return root
252
+ const candidate = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(root, raw)
253
+ const rel = path.relative(root, candidate)
254
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) return candidate
255
+ return root
256
+ }
257
+
258
+ function isInside(root: string, candidate: string): boolean {
259
+ const rel = path.relative(root, candidate)
260
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
261
+ }
262
+
263
+ async function realpathNearest(target: string): Promise<string> {
264
+ let current = target
265
+ for (let i = 0; i < 32; i += 1) {
266
+ try {
267
+ return await fs.realpath(current)
268
+ } catch {
269
+ const parent = path.dirname(current)
270
+ if (parent === current) return current
271
+ current = parent
272
+ }
273
+ }
274
+ return target
275
+ }
276
+
277
+ export async function resolveInside(root: string, target: unknown): Promise<string> {
278
+ const text = String(target ?? '').trim()
279
+ if (!text || text.includes('\u0000')) throw new Error('invalid-path')
280
+ const base = path.resolve(root)
281
+ const resolved = path.isAbsolute(text) ? path.resolve(text) : path.resolve(base, text)
282
+ if (!isInside(base, resolved)) throw new Error('path-outside-workspace')
283
+ const realBase = await realpathNearest(base)
284
+ const realTarget = await realpathNearest(resolved)
285
+ if (!isInside(realBase, realTarget)) throw new Error('path-outside-workspace')
286
+ return resolved
287
+ }
288
+
289
+ function requireString(args: Record<string, unknown>, key: string): string {
290
+ const value = args[key]
291
+ if (typeof value !== 'string') throw new Error(`argument ${key} must be a string`)
292
+ return value
293
+ }
294
+
295
+ function truncateOutput(text: string): string {
296
+ if (text.length <= MAX_SHELL_OUTPUT_CHARS) return text
297
+ return text.slice(0, MAX_SHELL_OUTPUT_CHARS) + `\n…[输出截断,共 ${text.length} 字符]`
298
+ }
299
+
300
+ function commandDenied(command: string, patterns: RegExp[]): string | null {
301
+ for (const pattern of patterns) if (pattern.test(command)) return String(pattern)
302
+ return null
303
+ }
304
+
305
+ /** 写入前检查敏感相对路径;在 workspace 与 mandateRoot 之间都检查。 */
306
+ function deniedWritePath(workspace: string, target: string, denyPaths: string[]): string | null {
307
+ const rel = path.relative(workspace, target).split(path.sep).join('/')
308
+ for (const denied of denyPaths) {
309
+ const normalized = denied.replace(/\\/g, '/').replace(/^\/+/, '')
310
+ if (normalized && (rel === normalized || rel.startsWith(normalized + '/'))) return normalized
311
+ }
312
+ // v3.2.5 P2:.env 的所有变体(.env、.env.local、.env.production、.env.example…)一律拒绝写入,
313
+ // 避免仅靠枚举漏掉 .env.production 之类。
314
+ const baseName = rel.split('/').pop() ?? ''
315
+ if (/^\.env(\.|$)/i.test(baseName)) return '.env*'
316
+ return null
317
+ }
318
+
319
+ function createPrTool(client?: PullRequestClient): WorkerTool {
320
+ return {
321
+ name: 'create_pr',
322
+ description: '在允许的仓库创建一个 Pull Request(需要宿主注入 PR 客户端;默认不可用)',
323
+ parameters: {
324
+ type: 'object',
325
+ properties: {
326
+ title: { type: 'string' }, body: { type: 'string' },
327
+ branch: { type: 'string' }, base: { type: 'string' },
328
+ },
329
+ required: ['title', 'body'],
330
+ },
331
+ async execute(args) {
332
+ if (!client) return '【系统拒绝】PR 后端未配置;请输出最终报告,由 Router/宿主完成回写。'
333
+ // v3.2.5 P2:分支/base 白名单校验——Worker 不得直接对默认分支开 PR,也不得使用非法分支名。
334
+ const PROTECTED_BASES = ['main', 'master', 'trunk', 'release', 'production', 'stable']
335
+ const branch = typeof args.branch === 'string' ? args.branch.trim() : ''
336
+ const base = typeof args.base === 'string' && args.base.trim() ? args.base.trim() : undefined
337
+ if (!branch) return '【系统拒绝】create_pr 必须显式提供 branch(分支需由宿主/分身先推送)。'
338
+ if (!/^[A-Za-z0-9._/-]{1,120}$/.test(branch)) return '【系统拒绝】branch 含非法字符。'
339
+ if (branch.startsWith('-') || branch.includes('..') || branch.includes('//')) return '【系统拒绝】branch 不合法。'
340
+ if (PROTECTED_BASES.includes(branch.toLowerCase())) return '【系统拒绝】不能直接向受保护分支开 PR(branch=' + branch + ')。'
341
+ if (base && PROTECTED_BASES.includes(base.toLowerCase()) === false) return '【系统拒绝】base 必须是受保护主干分支,当前 base=' + base + '。'
342
+ if (base && branch.toLowerCase() === base.toLowerCase()) return '【系统拒绝】branch 与 base 相同。'
343
+ try {
344
+ const url = await client.createPullRequest({
345
+ title: requireString(args, 'title'),
346
+ body: requireString(args, 'body'),
347
+ branch,
348
+ base,
349
+ })
350
+ return `PR created: ${url}`
351
+ } catch (error) {
352
+ return `创建 PR 失败: ${error instanceof Error ? error.message : String(error)}`
353
+ }
354
+ },
355
+ }
356
+ }
357
+
358
+ export function createSandboxTools(options: SandboxOptions): WorkerTool[] {
359
+ const workspace = path.resolve(options.workspace)
360
+ const denyPatterns = [...DEFAULT_DENY_PATTERNS, ...(options.denyPatterns ?? [])]
361
+ const denyWritePaths = [...DEFAULT_DENY_WRITE_PATHS, ...(options.denyWritePaths ?? [])]
362
+ const backgroundAfterMs = Math.max(20, Number(options.backgroundAfterMs ?? LONG_SYNC_COMMAND_THRESHOLD_MS) || LONG_SYNC_COMMAND_THRESHOLD_MS)
363
+ interface ShellJob { id: string; status: 'running' | 'done'; result?: ShellResult; startedAt: number; timeoutMs: number; cleanup?: ReturnType<typeof setTimeout> }
364
+ const shellJobs = new Map<string, ShellJob>()
365
+ let shellJobSeq = 0
366
+ const formatShellResult = (result: ShellResult, timeoutMs: number): string => {
367
+ const stdout = truncateOutput(result.stdout)
368
+ const stderr = truncateOutput(result.stderr)
369
+ if (result.timedOut || result.aborted) return `执行报错: 超时(>${timeoutMs}ms)或已中止;进程树已终止\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`
370
+ return `STDOUT:\n${stdout}\nSTDERR:\n${stderr}`
371
+ }
372
+ const startShellJob = (command: string, context: ToolExecutionContext): ShellJob => {
373
+ if (shellJobs.size >= 50) {
374
+ const oldestDone = [...shellJobs.values()].find((job) => job.status === 'done')
375
+ if (oldestDone) { if (oldestDone.cleanup) clearTimeout(oldestDone.cleanup); shellJobs.delete(oldestDone.id) }
376
+ else throw new Error('too-many-background-shell-jobs')
377
+ }
378
+ const id = `vm-job-${Date.now().toString(36)}-${(++shellJobSeq).toString(36)}`
379
+ const job: ShellJob = { id, status: 'running', startedAt: Date.now(), timeoutMs: Math.max(1, context.timeoutMs) }
380
+ shellJobs.set(id, job)
381
+ void runShell(command, { cwd: workspace, timeoutMs: Math.max(1, context.timeoutMs), signal: context.signal })
382
+ .then((result) => {
383
+ job.result = result
384
+ job.status = 'done'
385
+ job.cleanup = setTimeout(() => shellJobs.delete(id), 10 * 60_000)
386
+ job.cleanup.unref?.()
387
+ })
388
+ .catch((error) => {
389
+ job.result = { stdout: '', stderr: String(error instanceof Error ? error.message : error), code: null, timedOut: false, aborted: false }
390
+ job.status = 'done'
391
+ })
392
+ return job
393
+ }
394
+ return [
395
+ {
396
+ name: 'fs_read_file',
397
+ description: '读取 workspace 内文件的 UTF-8 内容(最大 256KB)',
398
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
399
+ async execute(args) {
400
+ const file = await resolveInside(workspace, requireString(args, 'path'))
401
+ const stat = await fs.stat(file)
402
+ if (!stat.isFile()) throw new Error('not-a-file')
403
+ if (stat.size > MAX_READ_BYTES) throw new Error('file-too-large')
404
+ return await fs.readFile(file, 'utf8')
405
+ },
406
+ },
407
+ {
408
+ name: 'fs_list_dir',
409
+ description: '列出 workspace 内目录条目(最多 200 项)',
410
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
411
+ async execute(args) {
412
+ const dir = await resolveInside(workspace, args.path === undefined ? '.' : requireString(args, 'path'))
413
+ const entries = await fs.readdir(dir, { withFileTypes: true })
414
+ return entries.slice(0, 200).map((entry) => `${entry.isDirectory() ? 'dir ' : 'file'} ${entry.name}`).join('\n') || '(empty)'
415
+ },
416
+ },
417
+ {
418
+ name: 'fs_write_file',
419
+ description: '写入文件(workspace 内且位于 Mandate.scope;拒绝 .git/.env 等敏感路径)',
420
+ parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
421
+ async execute(args, context) {
422
+ // v3.2.3:Mandate 每次从 context 取,避免构造闭包与本次 Ticket 不一致。
423
+ const mandateRoot = resolveMandateRoot(workspace, context.mandate.scope)
424
+ const target = await resolveInside(workspace, requireString(args, 'path'))
425
+ if (!isInside(mandateRoot, target)) throw new Error('path-outside-mandate')
426
+ const denied = deniedWritePath(workspace, target, denyWritePaths)
427
+ if (denied) throw new Error(`sensitive-path-denied:${denied}`)
428
+ const content = requireString(args, 'content')
429
+ if (Buffer.byteLength(content, 'utf8') > MAX_WRITE_BYTES) throw new Error('content-too-large')
430
+ await fs.mkdir(path.dirname(target), { recursive: true })
431
+ const realParent = await realpathNearest(path.dirname(target))
432
+ if (!isInside(await realpathNearest(mandateRoot), realParent)) throw new Error('path-outside-mandate')
433
+ await fs.writeFile(target, content, 'utf8')
434
+ return `written ${Buffer.byteLength(content, 'utf8')} bytes to ${path.relative(workspace, target)}`
435
+ },
436
+ },
437
+ {
438
+ name: 'exec_shell_in_vm',
439
+ description: '在分身 workspace 内执行 Shell(需 Mandate.allowShell;脱敏环境、黑名单、进程树超时)',
440
+ parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
441
+ async execute(args, context) {
442
+ // P0-2 fail-closed:没有隔离证明就不允许 Shell(黑名单不是安全边界)。
443
+ if (options.vmIsolation !== true) {
444
+ return '【系统拒绝】Shell 仅在已证明隔离的 VM/容器内可用;当前运行态未提供隔离证明(DSH_MASTER_VM_ISOLATION=attested)。'
445
+ }
446
+ if (context.mandate.allowShell !== true) return '【系统拒绝】当前工单未授权执行 Shell 命令。'
447
+ const command = requireString(args, 'command').trim()
448
+ if (!command) throw new Error('command-required')
449
+ const denied = commandDenied(command, denyPatterns)
450
+ if (denied) return `【系统拒绝】命令命中安全黑名单(${denied})。`
451
+ // P0-2:命令级禁锢(重定向/管道/cd/../绝对路径/env 展开/解释器/git 写操作)。
452
+ const escaped = shellConfinementViolation(command)
453
+ if (escaped) return `【系统拒绝】命令试图逃出 workspace/Mandate jail(${escaped})。`
454
+ const job = startShellJob(command, context)
455
+ let timer: ReturnType<typeof setTimeout> | undefined
456
+ try {
457
+ return await Promise.race([
458
+ (async () => {
459
+ while (job.status === 'running') await new Promise((resolve) => setTimeout(resolve, 10))
460
+ return formatShellResult(job.result!, context.timeoutMs)
461
+ })(),
462
+ new Promise<string>((resolve) => {
463
+ timer = setTimeout(() => resolve(`后台任务 ${job.id} 已启动(超过 ${backgroundAfterMs}ms);请调用 vm_job_result 查询,参数 {"jobId":"${job.id}"}。`), backgroundAfterMs)
464
+ timer.unref?.()
465
+ }),
466
+ ])
467
+ } finally { if (timer) clearTimeout(timer) }
468
+ },
469
+ },
470
+ {
471
+ name: 'vm_job_result',
472
+ description: '查询 exec_shell_in_vm 后台作业的终态与输出;参数 jobId',
473
+ parameters: { type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'] },
474
+ async execute(args) {
475
+ const id = requireString(args, 'jobId')
476
+ const job = shellJobs.get(id)
477
+ if (!job) return `【系统拒绝】未知或已过期的后台任务: ${id}`
478
+ if (job.status === 'running') return `{"jobId":"${id}","status":"running"}`
479
+ return formatShellResult(job.result!, job.timeoutMs)
480
+ },
481
+ },
482
+ createPrTool(options.prClient),
483
+ ]
484
+ }
485
+
486
+ export interface ToolRegistry {
487
+ schemas(): ToolSchema[]
488
+ execute(call: { name: string; arguments: string }, context: ToolExecutionContext): Promise<string>
489
+ }
490
+
491
+ export function createToolRegistry(tools: WorkerTool[]): ToolRegistry {
492
+ const byName = new Map(tools.map((tool) => [tool.name, tool]))
493
+ return {
494
+ schemas() {
495
+ return tools.map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }))
496
+ },
497
+ async execute(call, context) {
498
+ const tool = byName.get(String(call?.name ?? ''))
499
+ if (!tool) return `【系统拒绝】未知工具: ${String(call?.name ?? '')}`
500
+ let args: Record<string, unknown> = {}
501
+ try {
502
+ const parsed = JSON.parse(String(call?.arguments ?? '{}'))
503
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) args = parsed as Record<string, unknown>
504
+ else return '执行报错: 工具参数必须是 JSON object'
505
+ } catch (error) {
506
+ return `执行报错: 参数 JSON 解析失败: ${error instanceof Error ? error.message : String(error)}`
507
+ }
508
+ const controller = new AbortController()
509
+ const timer = setTimeout(() => controller.abort(), Math.max(1, context.timeoutMs))
510
+ const onAbort = () => controller.abort()
511
+ context.signal.addEventListener('abort', onAbort, { once: true })
512
+ try {
513
+ return await Promise.race([
514
+ tool.execute(args, { ...context, signal: controller.signal }),
515
+ new Promise<string>((resolve) => {
516
+ controller.signal.addEventListener('abort', () => resolve(`执行报错: 工具超时或被中止(>${context.timeoutMs}ms)`), { once: true })
517
+ }),
518
+ ])
519
+ } catch (error) {
520
+ return `执行报错: ${error instanceof Error ? error.message : String(error)}`
521
+ } finally {
522
+ clearTimeout(timer)
523
+ context.signal.removeEventListener('abort', onAbort)
524
+ }
525
+ },
526
+ }
527
+ }
528
+
529
+ export function isPathInside(root: string, target: string): boolean {
530
+ return isInside(path.resolve(root), path.resolve(target))
531
+ }