@hope_phenom/dsh-plugin-remote-use 0.0.0-stage → 0.2.24
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/LICENSE +21 -0
- package/README.md +207 -2
- package/README.zh.md +195 -0
- package/assets/icon.svg +47 -0
- package/cordis.patch.yml +41 -0
- package/lib/bridge/acp-bridge.js +905 -0
- package/lib/bridge/peer-connection.js +730 -0
- package/lib/client.js +2842 -0
- package/lib/core/acp-model-overlay.js +121 -0
- package/lib/core/approval-queue.js +216 -0
- package/lib/core/audit-log.js +322 -0
- package/lib/core/desktop-notify.js +173 -0
- package/lib/core/environment-prompt.js +76 -0
- package/lib/core/relay-supervisor.js +278 -0
- package/lib/core/review-engine.js +679 -0
- package/lib/core/sandbox-diagnosis.js +258 -0
- package/lib/core/sandbox-probe.js +310 -0
- package/lib/core/self-acp-spec.js +117 -0
- package/lib/core/selfcheck-runner.js +150 -0
- package/lib/core/settings-store.js +226 -0
- package/lib/core/shot-gc.js +334 -0
- package/lib/core/task-cwd.js +50 -0
- package/lib/core/task-memory.js +163 -0
- package/lib/core/task-registry.js +238 -0
- package/lib/core/toolcall-cache.js +206 -0
- package/lib/core/transfer-queue.js +58 -0
- package/lib/core/transfer.js +1144 -0
- package/lib/core/worker-arming.js +334 -0
- package/lib/index.js +2207 -0
- package/lib/provider/out-of-process-helpers.js +76 -0
- package/lib/provider/remote-provider.js +261 -0
- package/lib/tools/mediator-shim.mjs +180 -0
- package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
- package/lib/tools/remote-tools.js +680 -0
- package/lib/tools/selfcheck-fix-prompt.md +206 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/slave/acp-profile-approval.patch.yml +66 -0
- package/slave/file-transfer.mjs +552 -0
- package/slave/integrity-probe.ps1 +99 -0
- package/slave/make-launch-spec.mjs +184 -0
- package/slave/net-address.mjs +138 -0
- package/slave/node_modules/.package-lock.json +26 -0
- package/slave/node_modules/ws/LICENSE +20 -0
- package/slave/node_modules/ws/README.md +548 -0
- package/slave/node_modules/ws/browser.js +8 -0
- package/slave/node_modules/ws/index.js +22 -0
- package/slave/node_modules/ws/lib/buffer-util.js +131 -0
- package/slave/node_modules/ws/lib/constants.js +19 -0
- package/slave/node_modules/ws/lib/event-target.js +292 -0
- package/slave/node_modules/ws/lib/extension.js +203 -0
- package/slave/node_modules/ws/lib/limiter.js +55 -0
- package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/slave/node_modules/ws/lib/receiver.js +743 -0
- package/slave/node_modules/ws/lib/sender.js +607 -0
- package/slave/node_modules/ws/lib/stream.js +161 -0
- package/slave/node_modules/ws/lib/subprotocol.js +62 -0
- package/slave/node_modules/ws/lib/validation.js +152 -0
- package/slave/node_modules/ws/lib/websocket-server.js +562 -0
- package/slave/node_modules/ws/lib/websocket.js +1421 -0
- package/slave/node_modules/ws/package.json +74 -0
- package/slave/node_modules/ws/wrapper.mjs +21 -0
- package/slave/pairing.mjs +175 -0
- package/slave/permission-pairing.mjs +240 -0
- package/slave/prune-shots.mjs +170 -0
- package/slave/relay.mjs +1351 -0
- package/slave/repair-workspace-integrity.ps1 +75 -0
- package/slave/screenshot.ps1 +114 -0
- package/slave/selfcheck.ps1 +350 -0
- package/slave/stage-app.ps1 +116 -0
- package/slave/workspace-integrity.mjs +157 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 审批决策核心(INTERFACES.md 【A】;规则依据 DESIGN §8.3 / §10.0.1 / §13.4)。
|
|
3
|
+
*
|
|
4
|
+
* **纯逻辑模块**:不引入任何依赖、不用 `node:fs`、不做 I/O、不用网络。
|
|
5
|
+
*
|
|
6
|
+
* 为什么必须有这个模块(实测,DESIGN §10.0.1):
|
|
7
|
+
* 1. ACP 的 `session/request_permission` **只带 `toolCallId`**——没有工具名、没有参数,
|
|
8
|
+
* 也没有申请的沙箱档位(§13.3 / §13.4)。
|
|
9
|
+
* 2. 模型会**过度申请权限**:一次「写单个文件到工作区外」被沙箱拒绝后,它申请的是
|
|
10
|
+
* `danger-full-access`(最高档,整个文件系统无限制),而不是够用的窄档。
|
|
11
|
+
* ⇒ 主机若无条件放行,**第一次越权申请就等于交出全盘控制**。
|
|
12
|
+
*
|
|
13
|
+
* 因此这里的规则引擎从缓存取回 `rawInput`,读出 `sandbox_permissions`,与本次操作
|
|
14
|
+
* 实际所需的最小档位比较:申请宽于所需即拒绝;`danger-full-access` 一律要求人工确认,
|
|
15
|
+
* 规则不得自动放行。
|
|
16
|
+
*
|
|
17
|
+
* **规则顺序本身是被测契约**(顺序写错必须能让测试失败):
|
|
18
|
+
* 1. 缓存缺失(`rawInput === undefined`)→ `reject-once` / `source='cache-miss'`
|
|
19
|
+
* 2. 档位检查(`danger-full-access` → 人工;宽于所需 → `reject-once` / `'tier-check'`)
|
|
20
|
+
* 3. 任务内拒绝记忆 → `reject-once` / `'memory'`
|
|
21
|
+
* 4. 任务内批准记忆 → `allow-once` / `'memory'`
|
|
22
|
+
* 5. 任务级授权范围(`allowedPaths` / `allowedTools`)→ `allow-once` / `'policy'`
|
|
23
|
+
* 6. 其余 → 上报人工(缺省 `optionId` 为 `reject-once`,人工批准后由调用方覆盖)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** 沙箱档位,从窄到宽(DESIGN §8.3 档位比较表)。 */
|
|
27
|
+
export const TIER = Object.freeze(['read-only', 'workspace-write', 'danger-full-access'])
|
|
28
|
+
|
|
29
|
+
/** 审批应答的 optionId:ACP 硬编码只有这两个值(DESIGN §2.3)。 */
|
|
30
|
+
export const OPTION = /** @type {{ readonly ALLOW_ONCE: 'allow-once', readonly REJECT_ONCE: 'reject-once' }} */ ({
|
|
31
|
+
ALLOW_ONCE: 'allow-once',
|
|
32
|
+
REJECT_ONCE: 'reject-once',
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 默认「写类工具」名单。INTERFACES.md 要求至少含这四个;
|
|
37
|
+
* 调用方可用 `policy.writeTools` 扩展(例如再包一层自定义写工具)。
|
|
38
|
+
*/
|
|
39
|
+
export const WRITE_TOOLS = Object.freeze(['write', 'edit', 'str_replace_editor', 'fs_write'])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 默认「命令类工具」名单:档位按 `workspace-write` 判,记忆键取命令首词。
|
|
43
|
+
*/
|
|
44
|
+
export const COMMAND_TOOLS = Object.freeze(['pwsh', 'bash', 'sh', 'cmd', 'powershell', 'shell'])
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `policy.requiredTier` 缺失/非法时的兜底所需档位(取中间档)。
|
|
48
|
+
* 决策路径**绝不抛错**:一次抛错等于审批无人应答,从机 turn 会永久挂起(DESIGN §8.4)。
|
|
49
|
+
*/
|
|
50
|
+
export const FALLBACK_TIER = 'workspace-write'
|
|
51
|
+
|
|
52
|
+
/** 无限制档:申请它必须人工确认,规则不得自动放行(DESIGN §8.3 / §10.0.1)。 */
|
|
53
|
+
const DANGER_TIER = 'danger-full-access'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {object} ApprovalRequest
|
|
57
|
+
* @property {string} toolCallId
|
|
58
|
+
* @property {string} toolName 来自 `tool_call` 缓存的 `title`
|
|
59
|
+
* @property {unknown} rawInput 缓存的完整参数;`undefined` = 缓存缺失
|
|
60
|
+
* @property {string} taskId
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {object} Decision
|
|
65
|
+
* @property {'allow-once'|'reject-once'} optionId
|
|
66
|
+
* @property {'memory'|'policy'|'human'|'cache-miss'|'tier-check'} source
|
|
67
|
+
* @property {string} reason 人类可读,进审计;被拒时会作为 diagnostic 反馈给模型
|
|
68
|
+
* @property {boolean} needsHuman `true` 表示规则引擎无法决定,需要上报人工
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 审批策略(`decideApproval` 的 `ctx.policy`)。
|
|
73
|
+
*
|
|
74
|
+
* `requiredTier` 是**本次操作所需的最小档位**,由调用方决定;调用方可用
|
|
75
|
+
* `inferRequiredTier(rawInput, { workspaceRoot, toolName, defaultTier })` 计算它。
|
|
76
|
+
* 下面三个带 `?` 的字段是**契约之外的兼容扩展**:只在 `requiredTier` 缺失/非法、
|
|
77
|
+
* 需要引擎自己兜底推断时才会被使用。
|
|
78
|
+
*
|
|
79
|
+
* @typedef {object} ApprovalPolicy
|
|
80
|
+
* @property {string} requiredTier
|
|
81
|
+
* @property {string[]} [allowedTools] 任务级授权:工具名白名单
|
|
82
|
+
* @property {string[]} [allowedPaths] 任务级授权:目录白名单(前缀包含)
|
|
83
|
+
* @property {string} [workspaceRoot] 兼容扩展:兜底推断用的工作区根
|
|
84
|
+
* @property {string[]} [writeTools] 兼容扩展:覆盖默认写类工具名单
|
|
85
|
+
* @property {string[]} [commandTools] 兼容扩展:覆盖默认命令类工具名单
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 规范化后的路径。
|
|
90
|
+
* @typedef {object} NormalizedPath
|
|
91
|
+
* @property {string} path 规范化路径(`'/'` 分隔,已折叠 `'.'` / `'..'`)
|
|
92
|
+
* @property {string} key 比较用键(Windows 风格路径已小写)
|
|
93
|
+
* @property {boolean} absolute 是否绝对路径
|
|
94
|
+
* @property {boolean} windows 是否 Windows 风格(盘符或 UNC)
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/** @type {Set<string>} */
|
|
98
|
+
const WRITE_TOOL_SET = new Set(WRITE_TOOLS)
|
|
99
|
+
|
|
100
|
+
/** @type {Set<string>} */
|
|
101
|
+
const COMMAND_TOOL_SET = new Set(COMMAND_TOOLS)
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 审批决策。**顺序与规则**见文件头(顺序是被测契约)。
|
|
105
|
+
*
|
|
106
|
+
* 唯一的副作用:命中规则 4 且记忆里是 `'once'` 作用域的批准时,会调用
|
|
107
|
+
* `memory.consumeOnce(key)` 把它用掉——「批准一次」必须真的只放行一次,不能依赖
|
|
108
|
+
* 调用方自觉(否则它会静默退化成任务级授权)。
|
|
109
|
+
*
|
|
110
|
+
* @param {ApprovalRequest} req
|
|
111
|
+
* @param {object} ctx
|
|
112
|
+
* @param {import('./task-memory.js').TaskMemory} ctx.memory
|
|
113
|
+
* @param {ApprovalPolicy} ctx.policy
|
|
114
|
+
* @returns {Decision}
|
|
115
|
+
*/
|
|
116
|
+
export function decideApproval(req, ctx) {
|
|
117
|
+
const request = isPlainObject(req) ? req : {}
|
|
118
|
+
const context = isPlainObject(ctx) ? ctx : {}
|
|
119
|
+
const policy = isPlainObject(context.policy) ? context.policy : {}
|
|
120
|
+
const memory = isPlainObject(context.memory) ? context.memory : undefined
|
|
121
|
+
const toolCallId = typeof request.toolCallId === 'string' && request.toolCallId !== '' ? request.toolCallId : undefined
|
|
122
|
+
const rawInput = request.rawInput
|
|
123
|
+
|
|
124
|
+
// ── 规则 1:缓存缺失 → 直接拒绝,绝不默认放行(DESIGN §5.2 / §8.3)──────────────
|
|
125
|
+
if (!isPlainObject(rawInput)) {
|
|
126
|
+
return decision(
|
|
127
|
+
OPTION.REJECT_ONCE,
|
|
128
|
+
'cache-miss',
|
|
129
|
+
false,
|
|
130
|
+
cacheMissReason(toolCallId, rawInput),
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── 规则 2:档位检查(DESIGN §8.3;主机审批请求看不到申请内容,只能靠缓存)─────
|
|
135
|
+
const requestedTier = readRequestedTier(rawInput)
|
|
136
|
+
const requiredTier = resolveRequiredTier(request, policy)
|
|
137
|
+
if (requestedTier !== undefined) {
|
|
138
|
+
if (!TIER.includes(requestedTier)) {
|
|
139
|
+
return decision(
|
|
140
|
+
OPTION.REJECT_ONCE,
|
|
141
|
+
'tier-check',
|
|
142
|
+
false,
|
|
143
|
+
unrecognizedTierReason(requestedTier, requiredTier),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
if (requestedTier === DANGER_TIER) {
|
|
147
|
+
// 无限制档:规则不得自动放行,必须人工确认(§10.0.1 的核心缺口)
|
|
148
|
+
return decision(
|
|
149
|
+
OPTION.REJECT_ONCE,
|
|
150
|
+
'tier-check',
|
|
151
|
+
true,
|
|
152
|
+
dangerTierReason(requiredTier),
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
if (tierIndex(requestedTier) > tierIndex(requiredTier)) {
|
|
156
|
+
return decision(
|
|
157
|
+
OPTION.REJECT_ONCE,
|
|
158
|
+
'tier-check',
|
|
159
|
+
false,
|
|
160
|
+
tooWideTierReason(requestedTier, requiredTier),
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── 规则 3 / 4:任务内记忆(键在档位检查之后才算,顺序由契约固定)──────────────
|
|
166
|
+
const key = memoryKey(request)
|
|
167
|
+
const state = typeof memory?.lookup === 'function' ? memory.lookup(key) : 'unknown'
|
|
168
|
+
if (state === 'rejected') {
|
|
169
|
+
return decision(OPTION.REJECT_ONCE, 'memory', false, rejectMemoryReason(key))
|
|
170
|
+
}
|
|
171
|
+
if (state === 'allowed') {
|
|
172
|
+
const consumedOnce =
|
|
173
|
+
typeof memory?.consumeOnce === 'function' ? memory.consumeOnce(key) === true : false
|
|
174
|
+
return decision(OPTION.ALLOW_ONCE, 'memory', false, approveMemoryReason(key, consumedOnce))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── 规则 5:任务级授权范围 ──────────────────────────────────────────────────
|
|
178
|
+
const authorization = matchTaskAuthorization(request, policy)
|
|
179
|
+
if (authorization !== undefined) {
|
|
180
|
+
return decision(OPTION.ALLOW_ONCE, 'policy', false, policyReason(key, authorization))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── 规则 6:上报人工(缺省 optionId 为 reject-once,人工批准后调用方覆盖)───────
|
|
184
|
+
return decision(OPTION.REJECT_ONCE, 'human', true, humanReason(key))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 从 `rawInput` 推断「本次操作实际需要的最小档位」。保守:判定不清时返回 `defaultTier`。
|
|
189
|
+
*
|
|
190
|
+
* 规则(按本实现的判定顺序):
|
|
191
|
+
* 1. 不是对象 / 空对象 / 无路径无命令信息 → `defaultTier`
|
|
192
|
+
* 2. 命令类工具(`toolName ∈ commandTools`,或未给工具名但 `rawInput.command` 非空)
|
|
193
|
+
* → `'workspace-write'`
|
|
194
|
+
* 3. 涉及文件路径(`rawInput.file_path` / `rawInput.path`):
|
|
195
|
+
* - 路径在 `workspaceRoot` 之外(或无法判定为之内)→ `'workspace-write'`
|
|
196
|
+
* - 路径在 `workspaceRoot` 之内 → 写类操作 `'workspace-write'`,否则 `'read-only'`
|
|
197
|
+
* - 无法解析成绝对路径(相对路径且没有绝对 `workspaceRoot`)→ `defaultTier`
|
|
198
|
+
*
|
|
199
|
+
* 写类判定优先用 `opts.toolName ∈ writeTools`;`toolName` 缺失时退化为看参数形状
|
|
200
|
+
* (`content` / `new_string` / `edits` / `patch` …),宁可判宽。
|
|
201
|
+
*
|
|
202
|
+
* ⚠️ 契约签名里没有 `toolName`,而「写操作仍需 workspace-write」只能由工具名判定;
|
|
203
|
+
* 因此本实现接受**契约之外的** `opts.toolName` / `opts.commandTools`(可选,缺省不报错)。
|
|
204
|
+
* `decideApproval` 会把 `req.toolName` 传进来。
|
|
205
|
+
*
|
|
206
|
+
* @param {unknown} rawInput
|
|
207
|
+
* @param {{ workspaceRoot?: string, writeTools?: string[], commandTools?: string[], toolName?: string, defaultTier: string }} opts
|
|
208
|
+
* @returns {string} 档位名(`TIER` 之一)
|
|
209
|
+
*/
|
|
210
|
+
export function inferRequiredTier(rawInput, opts) {
|
|
211
|
+
const options = isPlainObject(opts) ? opts : {}
|
|
212
|
+
const fallback = TIER.includes(options.defaultTier) ? options.defaultTier : FALLBACK_TIER
|
|
213
|
+
if (!isPlainObject(rawInput)) return fallback
|
|
214
|
+
|
|
215
|
+
const writeTools = toNameSet(options.writeTools, WRITE_TOOL_SET)
|
|
216
|
+
const commandTools = toNameSet(options.commandTools, COMMAND_TOOL_SET)
|
|
217
|
+
const toolName = typeof options.toolName === 'string' ? options.toolName : undefined
|
|
218
|
+
|
|
219
|
+
// 2) 命令类工具
|
|
220
|
+
const isCommandTool =
|
|
221
|
+
toolName !== undefined && toolName.trim() !== ''
|
|
222
|
+
? commandTools.has(toolName.trim().toLowerCase())
|
|
223
|
+
: firstToken(extractCommand(rawInput)) !== undefined
|
|
224
|
+
if (isCommandTool) return 'workspace-write'
|
|
225
|
+
|
|
226
|
+
// 1) / 3) 路径类
|
|
227
|
+
const targetRaw = extractPathValue(rawInput)
|
|
228
|
+
if (targetRaw === undefined) return fallback
|
|
229
|
+
const root = normalizePath(options.workspaceRoot)
|
|
230
|
+
if (root === undefined) return fallback
|
|
231
|
+
const target = normalizePath(targetRaw)
|
|
232
|
+
if (target === undefined) return fallback
|
|
233
|
+
const resolved = target.absolute ? target : resolveRelative(root, target)
|
|
234
|
+
if (resolved === undefined) return fallback
|
|
235
|
+
|
|
236
|
+
if (isPathInside(root, resolved)) {
|
|
237
|
+
return isWriteOperation(toolName, rawInput, writeTools) ? 'workspace-write' : 'read-only'
|
|
238
|
+
}
|
|
239
|
+
return 'workspace-write'
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 记忆键(导出以便测试与审计)。
|
|
244
|
+
*
|
|
245
|
+
* - 写类工具 → `write:<目录前缀>`(取 `rawInput.file_path` / `rawInput.path` 的父目录,
|
|
246
|
+
* **保留原样分隔符**,便于审计时和模型自己的路径对照)
|
|
247
|
+
* - 命令类工具 → `exec:<命令首词>`(`rawInput.command` 的第一个空白分隔 token)
|
|
248
|
+
* - 其余 → `tool:<toolName>`
|
|
249
|
+
*
|
|
250
|
+
* 兜底:写类工具但没给路径 → 退化为 `tool:<toolName>`;路径没有目录部分(如 `a.txt`)
|
|
251
|
+
* → `write:.`;工具名缺失 → `tool:unknown`。
|
|
252
|
+
*
|
|
253
|
+
* @param {ApprovalRequest} req
|
|
254
|
+
* @returns {string} 非空记忆键
|
|
255
|
+
*/
|
|
256
|
+
export function memoryKey(req) {
|
|
257
|
+
const request = isPlainObject(req) ? req : {}
|
|
258
|
+
const toolName =
|
|
259
|
+
typeof request.toolName === 'string' && request.toolName.trim() !== ''
|
|
260
|
+
? request.toolName.trim()
|
|
261
|
+
: undefined
|
|
262
|
+
const rawInput = request.rawInput
|
|
263
|
+
const kind = classifyTool(toolName, rawInput)
|
|
264
|
+
if (kind === 'write') {
|
|
265
|
+
const directory = parentDirectory(extractPathValue(rawInput))
|
|
266
|
+
if (directory !== undefined) return `write:${directory}`
|
|
267
|
+
} else if (kind === 'command') {
|
|
268
|
+
const token = firstToken(extractCommand(rawInput))
|
|
269
|
+
if (token !== undefined) return `exec:${token}`
|
|
270
|
+
}
|
|
271
|
+
return `tool:${toolName ?? 'unknown'}`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ─────────────────────────── 决策辅助 ─────────────────────────── */
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 构造 Decision(字段顺序与契约 typedef 一致)。
|
|
278
|
+
* @param {'allow-once'|'reject-once'} optionId
|
|
279
|
+
* @param {'memory'|'policy'|'human'|'cache-miss'|'tier-check'} source
|
|
280
|
+
* @param {boolean} needsHuman
|
|
281
|
+
* @param {string} reason
|
|
282
|
+
* @returns {Decision}
|
|
283
|
+
*/
|
|
284
|
+
function decision(optionId, source, needsHuman, reason) {
|
|
285
|
+
return { optionId, source, reason, needsHuman }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* 解析「本次操作所需的最小档位」:以 `policy.requiredTier` 为权威;
|
|
290
|
+
* 缺失/非法时才用 `inferRequiredTier` 兜底(决策路径不抛错、不静默放行)。
|
|
291
|
+
* @param {ApprovalRequest} request
|
|
292
|
+
* @param {ApprovalPolicy} policy
|
|
293
|
+
* @returns {string}
|
|
294
|
+
*/
|
|
295
|
+
function resolveRequiredTier(request, policy) {
|
|
296
|
+
if (TIER.includes(policy.requiredTier)) return policy.requiredTier
|
|
297
|
+
return inferRequiredTier(request.rawInput, {
|
|
298
|
+
workspaceRoot: policy.workspaceRoot,
|
|
299
|
+
toolName: request.toolName,
|
|
300
|
+
writeTools: policy.writeTools,
|
|
301
|
+
commandTools: policy.commandTools,
|
|
302
|
+
defaultTier: FALLBACK_TIER,
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 读出申请档位(可能不存在 = 不申请提权)。
|
|
308
|
+
*
|
|
309
|
+
* 同时接受 `sandbox_permissions`(契约字段)与 `sandboxPermissions`(防御性别名:
|
|
310
|
+
* 如果模型换了拼写而主机不认识,就会漏掉一次真实提权申请)。两者同时出现时取**更宽**
|
|
311
|
+
* 的那个(fail closed)。
|
|
312
|
+
*
|
|
313
|
+
* @param {Record<string, unknown>} rawInput
|
|
314
|
+
* @returns {string | undefined}
|
|
315
|
+
*/
|
|
316
|
+
function readRequestedTier(rawInput) {
|
|
317
|
+
const candidates = [rawInput.sandbox_permissions, rawInput.sandboxPermissions]
|
|
318
|
+
.filter((value) => typeof value === 'string' && value.trim() !== '')
|
|
319
|
+
.map((value) => /** @type {string} */ (value).trim())
|
|
320
|
+
if (candidates.length === 0) return undefined
|
|
321
|
+
for (const candidate of candidates) {
|
|
322
|
+
if (!TIER.includes(candidate)) return candidate // 未知档位 → 走「无法比较」的拒绝分支
|
|
323
|
+
}
|
|
324
|
+
return candidates.reduce((widest, current) =>
|
|
325
|
+
tierIndex(current) > tierIndex(widest) ? current : widest,
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 任务级授权范围匹配(`allowedTools` 精确匹配、`allowedPaths` 前缀包含)。
|
|
331
|
+
* @param {ApprovalRequest} request
|
|
332
|
+
* @param {ApprovalPolicy} policy
|
|
333
|
+
* @returns {string | undefined} 命中说明(拼进 reason);未命中 `undefined`
|
|
334
|
+
*/
|
|
335
|
+
function matchTaskAuthorization(request, policy) {
|
|
336
|
+
/** @type {string[]} */
|
|
337
|
+
const matches = []
|
|
338
|
+
const toolName = typeof request.toolName === 'string' ? request.toolName : undefined
|
|
339
|
+
const allowedTools = Array.isArray(policy.allowedTools) ? policy.allowedTools : []
|
|
340
|
+
if (toolName !== undefined && allowedTools.some((allowed) => allowed === toolName)) {
|
|
341
|
+
matches.push(`tool "${toolName}" is listed in allowedTools`)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const allowedPaths = Array.isArray(policy.allowedPaths) ? policy.allowedPaths : []
|
|
345
|
+
const targetRaw = extractPathValue(request.rawInput)
|
|
346
|
+
if (targetRaw !== undefined && allowedPaths.length > 0) {
|
|
347
|
+
const target = resolveTargetPath(targetRaw, policy.workspaceRoot)
|
|
348
|
+
if (target !== undefined) {
|
|
349
|
+
for (const entry of allowedPaths) {
|
|
350
|
+
const root = normalizePath(entry)
|
|
351
|
+
if (root !== undefined && isPathInside(root, target)) {
|
|
352
|
+
matches.push(`path "${targetRaw}" is under allowedPaths entry "${String(entry)}"`)
|
|
353
|
+
break
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return matches.length > 0 ? matches.join('; ') : undefined
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* ─────────────────────────── reason 文案 ─────────────────────────── */
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* @param {string | undefined} toolCallId
|
|
366
|
+
* @param {unknown} rawInput
|
|
367
|
+
* @returns {string}
|
|
368
|
+
*/
|
|
369
|
+
function cacheMissReason(toolCallId, rawInput) {
|
|
370
|
+
const id = toolCallId ?? '(missing toolCallId)'
|
|
371
|
+
const shape = rawInput === undefined ? 'undefined' : rawInput === null ? 'null' : typeof rawInput
|
|
372
|
+
return (
|
|
373
|
+
`Cache miss for toolCallId "${id}" (rawInput is ${shape}): the host has no recorded ` +
|
|
374
|
+
`tool_call, so it cannot read sandbox_permissions or the tool arguments. Denied by default ` +
|
|
375
|
+
`(fail closed). Re-issue the tool call so the host records it before requesting escalation.`
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* @param {string} requested
|
|
381
|
+
* @param {string} required
|
|
382
|
+
* @returns {string}
|
|
383
|
+
*/
|
|
384
|
+
function unrecognizedTierReason(requested, required) {
|
|
385
|
+
return (
|
|
386
|
+
`Requested sandbox tier "${requested}" is not a recognized tier ` +
|
|
387
|
+
`(narrow to wide: ${TIER.join(' < ')}), so it cannot be verified. Rejected. ` +
|
|
388
|
+
`Retry with the narrowest sufficient tier: "${required}".`
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* @param {string} required
|
|
394
|
+
* @returns {string}
|
|
395
|
+
*/
|
|
396
|
+
function dangerTierReason(required) {
|
|
397
|
+
const head =
|
|
398
|
+
`Requested sandbox tier "${DANGER_TIER}" is the unrestricted tier: it always requires ` +
|
|
399
|
+
`explicit human confirmation on the host and must never be auto-approved by a rule.`
|
|
400
|
+
if (tierIndex(required) < tierIndex(DANGER_TIER)) {
|
|
401
|
+
return `${head} If this operation only needs the narrowest sufficient tier, retry with "${required}" instead.`
|
|
402
|
+
}
|
|
403
|
+
return `${head} No narrower tier is sufficient for this operation, so human confirmation is mandatory.`
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* @param {string} requested
|
|
408
|
+
* @param {string} required
|
|
409
|
+
* @returns {string}
|
|
410
|
+
*/
|
|
411
|
+
function tooWideTierReason(requested, required) {
|
|
412
|
+
return (
|
|
413
|
+
`Requested sandbox tier "${requested}" is wider than the minimum required tier "${required}" ` +
|
|
414
|
+
`for this operation. Over-requesting is rejected: retry this exact operation with the ` +
|
|
415
|
+
`narrowest sufficient tier "${required}", or omit "sandbox_permissions" when no escalation is needed.`
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @param {string} key
|
|
421
|
+
* @returns {string}
|
|
422
|
+
*/
|
|
423
|
+
function rejectMemoryReason(key) {
|
|
424
|
+
return (
|
|
425
|
+
`Denied earlier in this task for approval key "${key}": the same class of request is not asked ` +
|
|
426
|
+
`again within this task. Use a different, narrower operation instead of retrying this one.`
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* @param {string} key
|
|
432
|
+
* @param {boolean} consumedOnce
|
|
433
|
+
* @returns {string}
|
|
434
|
+
*/
|
|
435
|
+
function approveMemoryReason(key, consumedOnce) {
|
|
436
|
+
return consumedOnce
|
|
437
|
+
? `Approved for this single occurrence by a once-scope approval recorded in this task ` +
|
|
438
|
+
`(key "${key}"); that once-scope grant is now consumed.`
|
|
439
|
+
: `Approved earlier in this task for approval key "${key}" (task-scope memory).`
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* @param {string} key
|
|
444
|
+
* @param {string} match
|
|
445
|
+
* @returns {string}
|
|
446
|
+
*/
|
|
447
|
+
function policyReason(key, match) {
|
|
448
|
+
return `Allowed by the task-level authorization declared for this task (approval key "${key}"): ${match}.`
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* @param {string} key
|
|
453
|
+
* @returns {string}
|
|
454
|
+
*/
|
|
455
|
+
function humanReason(key) {
|
|
456
|
+
return (
|
|
457
|
+
`No rule decided for approval key "${key}": no cache gap, no tier violation, and neither ` +
|
|
458
|
+
`approval memory nor task-level authorization matched. Escalating to the host human; if the ` +
|
|
459
|
+
`human does not answer this defaults to reject-once.`
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* ─────────────────────────── 纯工具函数 ─────────────────────────── */
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 档位在宽窄序中的序号;未知档位返回 `-1`。
|
|
467
|
+
* @param {string} tier
|
|
468
|
+
* @returns {number}
|
|
469
|
+
*/
|
|
470
|
+
function tierIndex(tier) {
|
|
471
|
+
const index = TIER.indexOf(tier)
|
|
472
|
+
return index >= 0 ? index : -1
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* @param {unknown} value
|
|
477
|
+
* @returns {boolean}
|
|
478
|
+
*/
|
|
479
|
+
function isPlainObject(value) {
|
|
480
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* 名单转小写集合;未给出/为空时用默认名单。
|
|
485
|
+
* @param {unknown} list
|
|
486
|
+
* @param {Set<string>} fallback
|
|
487
|
+
* @returns {Set<string>}
|
|
488
|
+
*/
|
|
489
|
+
function toNameSet(list, fallback) {
|
|
490
|
+
if (!Array.isArray(list) || list.length === 0) return fallback
|
|
491
|
+
/** @type {Set<string>} */
|
|
492
|
+
const set = new Set()
|
|
493
|
+
for (const entry of list) {
|
|
494
|
+
if (typeof entry === 'string' && entry.trim() !== '') set.add(entry.trim().toLowerCase())
|
|
495
|
+
}
|
|
496
|
+
return set.size > 0 ? set : fallback
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* 工具分类:`'write'` | `'command'` | `'other'`。
|
|
501
|
+
* 工具名(忽略大小写)是权威;工具名缺失时才用参数形状兜底。
|
|
502
|
+
* @param {string | undefined} toolName
|
|
503
|
+
* @param {unknown} rawInput
|
|
504
|
+
* @param {Set<string>} [writeTools]
|
|
505
|
+
* @param {Set<string>} [commandTools]
|
|
506
|
+
* @returns {'write' | 'command' | 'other'}
|
|
507
|
+
*/
|
|
508
|
+
function classifyTool(toolName, rawInput, writeTools = WRITE_TOOL_SET, commandTools = COMMAND_TOOL_SET) {
|
|
509
|
+
const name = typeof toolName === 'string' ? toolName.trim().toLowerCase() : ''
|
|
510
|
+
if (name !== '') {
|
|
511
|
+
if (writeTools.has(name)) return 'write'
|
|
512
|
+
if (commandTools.has(name)) return 'command'
|
|
513
|
+
return 'other'
|
|
514
|
+
}
|
|
515
|
+
if (hasWriteShape(rawInput)) return 'write'
|
|
516
|
+
if (firstToken(extractCommand(rawInput)) !== undefined) return 'command'
|
|
517
|
+
return 'other'
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* 是否写类操作。
|
|
522
|
+
* @param {string | undefined} toolName
|
|
523
|
+
* @param {unknown} rawInput
|
|
524
|
+
* @param {Set<string>} writeTools
|
|
525
|
+
* @returns {boolean}
|
|
526
|
+
*/
|
|
527
|
+
function isWriteOperation(toolName, rawInput, writeTools) {
|
|
528
|
+
if (typeof toolName === 'string' && toolName.trim() !== '') {
|
|
529
|
+
return writeTools.has(toolName.trim().toLowerCase())
|
|
530
|
+
}
|
|
531
|
+
return hasWriteShape(rawInput)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* 参数形状像不像写操作(仅在没有工具名时使用;宁可判宽)。
|
|
536
|
+
* @param {unknown} rawInput
|
|
537
|
+
* @returns {boolean}
|
|
538
|
+
*/
|
|
539
|
+
function hasWriteShape(rawInput) {
|
|
540
|
+
if (!isPlainObject(rawInput)) return false
|
|
541
|
+
return (
|
|
542
|
+
'content' in rawInput ||
|
|
543
|
+
'new_string' in rawInput ||
|
|
544
|
+
'new_str' in rawInput ||
|
|
545
|
+
'old_string' in rawInput ||
|
|
546
|
+
'old_str' in rawInput ||
|
|
547
|
+
'edits' in rawInput ||
|
|
548
|
+
'patch' in rawInput
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* 取目标路径(契约字段:`file_path` 优先,其次 `path`)。
|
|
554
|
+
* @param {unknown} rawInput
|
|
555
|
+
* @returns {string | undefined}
|
|
556
|
+
*/
|
|
557
|
+
function extractPathValue(rawInput) {
|
|
558
|
+
if (!isPlainObject(rawInput)) return undefined
|
|
559
|
+
const value = rawInput.file_path !== undefined ? rawInput.file_path : rawInput.path
|
|
560
|
+
if (typeof value !== 'string') return undefined
|
|
561
|
+
const trimmed = value.trim()
|
|
562
|
+
return trimmed === '' ? undefined : trimmed
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 取命令字符串。
|
|
567
|
+
* @param {unknown} rawInput
|
|
568
|
+
* @returns {string | undefined}
|
|
569
|
+
*/
|
|
570
|
+
function extractCommand(rawInput) {
|
|
571
|
+
if (!isPlainObject(rawInput)) return undefined
|
|
572
|
+
const value = rawInput.command
|
|
573
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* 命令首词(第一个空白分隔 token)。
|
|
578
|
+
* @param {string | undefined} command
|
|
579
|
+
* @returns {string | undefined}
|
|
580
|
+
*/
|
|
581
|
+
function firstToken(command) {
|
|
582
|
+
if (typeof command !== 'string') return undefined
|
|
583
|
+
const match = /^\s*(\S+)/.exec(command)
|
|
584
|
+
return match === null ? undefined : match[1]
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 父目录(保留原样分隔符;`a.txt` → `'.'`;`/a.txt` → `'/'`;`C:\` → `'C:\'`)。
|
|
589
|
+
* @param {string | undefined} value
|
|
590
|
+
* @returns {string | undefined}
|
|
591
|
+
*/
|
|
592
|
+
function parentDirectory(value) {
|
|
593
|
+
if (typeof value !== 'string') return undefined
|
|
594
|
+
const raw = value.trim()
|
|
595
|
+
if (raw === '') return undefined
|
|
596
|
+
const trimmed = raw.length > 1 ? raw.replace(/[\\/]+$/, '') : raw
|
|
597
|
+
if (/^[a-zA-Z]:$/.test(trimmed)) return `${trimmed}${raw.includes('\\') ? '\\' : '/'}`
|
|
598
|
+
const index = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
599
|
+
if (index < 0) return '.'
|
|
600
|
+
if (index === 0) return trimmed.slice(0, 1)
|
|
601
|
+
return trimmed.slice(0, index)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* 纯字符串路径规范化(不依赖 `node:path`,避免平台差异)。
|
|
606
|
+
* @param {unknown} value
|
|
607
|
+
* @returns {NormalizedPath | undefined} 无法解析时 `undefined`
|
|
608
|
+
*/
|
|
609
|
+
function normalizePath(value) {
|
|
610
|
+
if (typeof value !== 'string') return undefined
|
|
611
|
+
const raw = value.trim()
|
|
612
|
+
if (raw === '') return undefined
|
|
613
|
+
const windows = /^[a-zA-Z]:[\\/]/.test(raw) || /^[\\/]{2}/.test(raw)
|
|
614
|
+
const unified = raw.replace(/\\/g, '/')
|
|
615
|
+
const unc = unified.startsWith('//')
|
|
616
|
+
const startsSlash = unified.startsWith('/')
|
|
617
|
+
/** @type {string[]} */
|
|
618
|
+
const segments = []
|
|
619
|
+
for (const segment of unified.split('/')) {
|
|
620
|
+
if (segment === '' || segment === '.') continue
|
|
621
|
+
if (segment === '..') {
|
|
622
|
+
if (segments.length > 0 && segments[segments.length - 1] !== '..') segments.pop()
|
|
623
|
+
else segments.push('..')
|
|
624
|
+
continue
|
|
625
|
+
}
|
|
626
|
+
segments.push(segment)
|
|
627
|
+
}
|
|
628
|
+
let path = segments.join('/')
|
|
629
|
+
if (unc) path = `//${path}`
|
|
630
|
+
else if (startsSlash) path = `/${path}`
|
|
631
|
+
else if (path === '') path = '.'
|
|
632
|
+
return {
|
|
633
|
+
path,
|
|
634
|
+
key: windows ? path.toLowerCase() : path,
|
|
635
|
+
absolute: windows || startsSlash,
|
|
636
|
+
windows,
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* 相对路径按 root 解析(纯字符串拼接后重新规范化)。
|
|
642
|
+
* @param {NormalizedPath} root
|
|
643
|
+
* @param {NormalizedPath} relative
|
|
644
|
+
* @returns {NormalizedPath | undefined} root 不是绝对路径时 `undefined`
|
|
645
|
+
*/
|
|
646
|
+
function resolveRelative(root, relative) {
|
|
647
|
+
if (!root.absolute) return undefined
|
|
648
|
+
return normalizePath(`${root.path}/${relative.path}`)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 把 `rawInput` 里的路径解析为可比较的绝对路径。
|
|
653
|
+
* @param {unknown} value
|
|
654
|
+
* @param {unknown} workspaceRoot
|
|
655
|
+
* @returns {NormalizedPath | undefined} 无法解析成绝对路径时 `undefined`(调用方按「无法判断」处理)
|
|
656
|
+
*/
|
|
657
|
+
function resolveTargetPath(value, workspaceRoot) {
|
|
658
|
+
const target = normalizePath(value)
|
|
659
|
+
if (target === undefined) return undefined
|
|
660
|
+
if (target.absolute) return target
|
|
661
|
+
const root = normalizePath(workspaceRoot)
|
|
662
|
+
if (root === undefined) return undefined
|
|
663
|
+
return resolveRelative(root, target)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* target 是否位于 root 之内(含 root 自身)。任一侧是 Windows 风格时忽略大小写。
|
|
668
|
+
* @param {NormalizedPath} root
|
|
669
|
+
* @param {NormalizedPath} target
|
|
670
|
+
* @returns {boolean}
|
|
671
|
+
*/
|
|
672
|
+
function isPathInside(root, target) {
|
|
673
|
+
const caseInsensitive = root.windows || target.windows
|
|
674
|
+
const a = caseInsensitive ? root.key.toLowerCase() : root.key
|
|
675
|
+
const b = caseInsensitive ? target.key.toLowerCase() : target.key
|
|
676
|
+
if (a === b) return true
|
|
677
|
+
const prefix = a.endsWith('/') ? a : `${a}/`
|
|
678
|
+
return b.startsWith(prefix)
|
|
679
|
+
}
|