@gavinc-cn/dsh-path-rules 0.0.0-stage → 0.1.1

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/lib/index.mjs ADDED
@@ -0,0 +1,634 @@
1
+ // dsh-path-rules:DSH 原生「路径规则」权限模式的逐调用闸门。
2
+ //
3
+ // 本文件只做接线与判定编排,规则细节都在三个纯函数单元里:
4
+ // rules.mjs 路径语义(归一化 / 黑名单优先的分类 / 多目标合成)
5
+ // classify.mjs 工具 → 读写意图
6
+ // analyze.mjs bash / pwsh 词法分析 → 读写目标
7
+ //
8
+ // 判定链(spec §4.2):
9
+ // ① enabled=false / 服务缺失 / 无 agent ⇒ pass(透传,不介入)
10
+ // ② 模式未激活(预设身份不是 activationPreset,且父链上也找不到)⇒ pass
11
+ // ③ 工具分派:pass ⇒ pass;read/write ⇒ 逐目标判定;shell ⇒ 先静态分析
12
+ // ④ 合成:多目标最严者胜(任一 ask 则 ask)
13
+ // ⑤ unanalyzable ⇒ 按 unanalyzable 策略:deny(默认,带改写指引)或 ask
14
+ // ⑥ 审批通道检查:子代理 / 会话 approval 为 never ⇒ 就地把 ask 改写成 deny
15
+ // (⑦ 与下游合成、⑧ failMode 兜底在 apply() 里做,见 Task 8)
16
+
17
+ import { appendFileSync, mkdirSync } from 'node:fs'
18
+ import { homedir, tmpdir } from 'node:os'
19
+ import { dirname, isAbsolute, join } from 'node:path'
20
+ import { analyzeCommand } from './analyze.mjs'
21
+ import {
22
+ MAX_AUTO_ENTRIES,
23
+ expandHome,
24
+ filterAutoWhitelistDirs,
25
+ loadAutoWhitelist,
26
+ saveAutoWhitelist,
27
+ sensitiveRoots,
28
+ writableDirsOf,
29
+ } from './auto-whitelist.mjs'
30
+ import { classifyTool } from './classify.mjs'
31
+ import { combine, classifyPath, decideTarget, normalizePath, normalizeRoots, underRoot } from './rules.mjs'
32
+
33
+ export const name = 'dsh-path-rules'
34
+
35
+ /** 默认审计日志(与既有守卫同目录)。 */
36
+ const DEFAULT_LOG = join(homedir(), '.kimi-code', 'hooks', 'logs', 'path_rules.dsh.log')
37
+
38
+ /** 父链上溯的最大层数(防环 + 防病态深链)。 */
39
+ const MAX_PARENT_HOPS = 8
40
+
41
+ /** 允许的 unanalyzable 策略。 */
42
+ const UNANALYZABLE_POLICIES = new Set(['deny', 'ask'])
43
+
44
+ /** 允许的 failMode。 */
45
+ const FAIL_MODES = new Set(['closed', 'open'])
46
+
47
+ /** 校验一串"必须是绝对路径"的配置项。 */
48
+ function absolutePathList(value, field) {
49
+ if (value === undefined) return []
50
+ if (!Array.isArray(value)) throw new Error(`dsh-path-rules: ${field} must be an array of absolute paths`)
51
+ for (const item of value) {
52
+ if (typeof item !== 'string' || !isAbsolute(item)) {
53
+ throw new Error(`dsh-path-rules: ${field} entries must be absolute paths, got ${JSON.stringify(item)}`)
54
+ }
55
+ }
56
+ return value
57
+ }
58
+
59
+ /**
60
+ * 归一化插件配置(只做校验与默认值,不做 realpath —— realpath 由 apply() 在加载时做一次)。
61
+ *
62
+ * @param raw - profile 覆盖行给的 config(可为空)。
63
+ * @returns 完整的 Config;非法配置直接抛错(拒绝加载,不改变现状行为)。
64
+ */
65
+ export function normalizeConfig(raw) {
66
+ const c = raw ?? {}
67
+ if (c.unanalyzable !== undefined && !UNANALYZABLE_POLICIES.has(c.unanalyzable)) {
68
+ throw new Error(`dsh-path-rules: unanalyzable must be "deny" or "ask", got ${JSON.stringify(c.unanalyzable)}`)
69
+ }
70
+ if (c.failMode !== undefined && !FAIL_MODES.has(c.failMode)) {
71
+ throw new Error(`dsh-path-rules: failMode must be "closed" or "open", got ${JSON.stringify(c.failMode)}`)
72
+ }
73
+ if (c.autoWhitelistFile !== undefined && (typeof c.autoWhitelistFile !== 'string' || c.autoWhitelistFile.trim().length === 0)) {
74
+ throw new Error('dsh-path-rules: autoWhitelistFile must be a non-empty string')
75
+ }
76
+ const activationPreset = c.activationPreset === undefined ? 'path-rules' : c.activationPreset
77
+ if (typeof activationPreset !== 'string' || activationPreset.trim().length === 0) {
78
+ throw new Error('dsh-path-rules: activationPreset must be a non-empty string')
79
+ }
80
+ return {
81
+ enabled: c.enabled !== false,
82
+ activationPreset: activationPreset.trim(),
83
+ whitelist: absolutePathList(c.whitelist, 'whitelist'),
84
+ blacklist: absolutePathList(c.blacklist, 'blacklist'),
85
+ allowTempRoots: c.allowTempRoots !== false,
86
+ unanalyzable: c.unanalyzable ?? 'deny',
87
+ failMode: c.failMode ?? 'closed',
88
+ dryRun: c.dryRun === true,
89
+ modelFallback: c.modelFallback === true,
90
+ autoWhitelist: c.autoWhitelist === true,
91
+ autoWhitelistFile: typeof c.autoWhitelistFile === 'string' ? c.autoWhitelistFile.trim() : '',
92
+ logPath: typeof c.logPath === 'string' && c.logPath.trim().length > 0 ? c.logPath.trim() : DEFAULT_LOG,
93
+ }
94
+ }
95
+
96
+ /** 取调用所属会话。 */
97
+ function sessionOf(exec) {
98
+ return exec?.agent?.session
99
+ }
100
+
101
+ /** 子代理会话判定(框架只把它们标在 header 上)。 */
102
+ function isSubagentSession(session) {
103
+ const header = session?.header ?? {}
104
+ return header.origin === 'subagent' || (typeof header.delegationDepth === 'number' && header.delegationDepth > 0)
105
+ }
106
+
107
+ /** 该会话是否有审批通道:子代理恒无(框架把它们的 approval 钉成 never)。 */
108
+ function hasNoApprovalChannel(session, services) {
109
+ if (isSubagentSession(session)) return true
110
+ return services.approvalOverrideOf?.(session) === 'never'
111
+ }
112
+
113
+ /**
114
+ * 模式是否激活:本会话的预设身份命中 activationPreset;
115
+ * 否则若是子代理,沿 header.parentSession 上溯(≤ MAX_PARENT_HOPS 层、带环检测)。
116
+ */
117
+ function isActive(session, config, services) {
118
+ if (services.presetOf(session) === config.activationPreset) return true
119
+ if (!isSubagentSession(session)) return false
120
+ const visited = new Set([session])
121
+ let current = session
122
+ for (let hops = 0; hops < MAX_PARENT_HOPS; hops += 1) {
123
+ const parentId = current?.header?.parentSession
124
+ if (typeof parentId !== 'string' || parentId.length === 0) return false
125
+ const parent = services.getSession?.(parentId)
126
+ if (parent === undefined || parent === null || visited.has(parent)) return false
127
+ visited.add(parent)
128
+ if (services.presetOf(parent) === config.activationPreset) return true
129
+ current = parent
130
+ }
131
+ return false
132
+ }
133
+
134
+ /** 组装判定用的根集合(project 取沙箱口径的 workspaceRoot)。 */
135
+ function buildRoots(session, config, services) {
136
+ const project = services.workspaceRootOf(session)
137
+ const normalizedProject = typeof project === 'string' && project.length > 0 ? normalizePath(project, project) : null
138
+ const temp = config.allowTempRoots
139
+ ? [...new Set([normalizePath('/tmp', normalizedProject ?? '/'), normalizePath(tmpdir(), normalizedProject ?? '/')])]
140
+ : []
141
+ return {
142
+ project: normalizedProject,
143
+ whitelist: config.whitelist,
144
+ blacklist: config.blacklist,
145
+ temp: temp.filter((path) => path.length > 0),
146
+ }
147
+ }
148
+
149
+ /** 沙箱升权请求的 reason 前缀(框架固定格式)。 */
150
+ const SANDBOX_ESCALATION_PREFIX = 'escalate sandbox to '
151
+
152
+ /**
153
+ * 目标是否落在沙箱可写根内(会话工作区 / 临时区)。
154
+ * 这是"谁负责审批"的分界线(spec §4.4):根内的黑名单写只有插件能拦;根外的写由沙箱升权审批。
155
+ */
156
+ function insideWritableRoots(target, roots) {
157
+ if (typeof roots.project === 'string' && roots.project.length > 0 && underRoot(target, roots.project)) return true
158
+ return roots.temp.some((root) => underRoot(target, root))
159
+ }
160
+
161
+ /**
162
+ * 单目标的分层判定:`allow` / `ask` / `delegate`(委派给沙箱升权审批)。
163
+ * - 黑名单读 ⇒ ask(沙箱从不拦读,这是唯一防线);
164
+ * - 黑名单写 ⇒ 沙箱可写根内 ask,根外 delegate(沙箱会拦并弹审批);
165
+ * - 项目外读 ⇒ allow;项目外写 ⇒ delegate;
166
+ * - 白名单/项目内/临时区 ⇒ allow。
167
+ */
168
+ function decideLayered(target, effect, roots) {
169
+ const kind = classifyPath(target, roots)
170
+ if (kind === 'blacklist') {
171
+ if (effect === 'write' && !insideWritableRoots(target, roots)) return { kind: 'delegate' }
172
+ return { kind: 'ask', code: 'BLACKLIST', reason: `目标命中黑名单:${target}` }
173
+ }
174
+ if (kind === 'outside') return effect === 'write' ? { kind: 'delegate' } : { kind: 'allow' }
175
+ return { kind: 'allow' }
176
+ }
177
+
178
+ /** 从会话日志按 callId 找该次工具调用的目标(用于升权请求的白名单自动放行)。 */
179
+ function escalationTargetOf(session, callId) {
180
+ const events = session?.events
181
+ if (!Array.isArray(events)) return undefined
182
+ for (let i = events.length - 1; i >= 0; i -= 1) {
183
+ const event = events[i]
184
+ if (!event || event.type !== 'tool/call' || event.data?.callId !== callId) continue
185
+ let args
186
+ try {
187
+ args = JSON.parse(event.data.arguments)
188
+ } catch {
189
+ return undefined
190
+ }
191
+ if (args === null || typeof args !== 'object') return undefined
192
+ for (const key of ['file_path', 'path']) {
193
+ if (typeof args[key] === 'string' && args[key].length > 0) return { kind: 'file', value: args[key] }
194
+ }
195
+ const toolName = String(event.data.name ?? '')
196
+ if ((toolName === 'bash' || toolName === 'pwsh') && typeof args.command === 'string') {
197
+ return { kind: 'command', value: args.command }
198
+ }
199
+ return undefined
200
+ }
201
+ return undefined
202
+ }
203
+
204
+ /** 从一次工具调用的参数里提取升权目标(与 escalationTargetOf 的提取规则一致)。 */
205
+ function escalationTargetFromArgs(args) {
206
+ if (args === null || typeof args !== 'object') return undefined
207
+ for (const key of ['file_path', 'path']) {
208
+ if (typeof args[key] === 'string' && args[key].length > 0) return { kind: 'file', value: args[key] }
209
+ }
210
+ if (typeof args.command === 'string') return { kind: 'command', value: args.command }
211
+ return undefined
212
+ }
213
+
214
+ /**
215
+ * 命令类升权请求是否允许自动放行(2026-10-18 严格化)。
216
+ *
217
+ * 旧口径只看"命令文本里是否出现白名单路径",`cp /white/f.txt /elsewhere/f.txt` 也会被静默批准
218
+ * (自动白名单让白名单动态变化后风险更大)。这里改为按静态分析出的**写目标**判定:
219
+ *
220
+ * @returns true = 全部写目标都在白名单/项目内/临时区,且至少一个命中白名单 ⇒ 可自动放行;
221
+ * false = 存在黑名单或项目外写目标 ⇒ 必须交给用户;
222
+ * undefined = 判不出(参数缺失/不可解析/没有写目标)⇒ 退回旧的子串口径兜底。
223
+ */
224
+ function commandEscalationIsWhitelisted(config, session, services, toolName, args) {
225
+ if (args === undefined) return undefined
226
+ const cwd = sessionCwdOf(session, services)
227
+ const derived = writableDirsOf(toolName, args, cwd)
228
+ if (derived.pairs.length === 0) return undefined
229
+ const roots = buildRoots(session, config, services)
230
+ let sawWhitelist = false
231
+ for (const pair of derived.pairs) {
232
+ const kind = classifyPath(pair.target, roots)
233
+ if (kind === 'blacklist' || kind === 'outside') return false
234
+ if (kind === 'whitelist') sawWhitelist = true
235
+ }
236
+ return sawWhitelist ? true : undefined
237
+ }
238
+
239
+ /**
240
+ * 升权请求的目标是否命中白名单(文件按 realpath 后前缀;命令优先按静态写目标严格判定,
241
+ * 判不出时再用粗粒度子串兜底)。
242
+ */
243
+ function escalationIsWhitelisted(target, config, session, commandCheck) {
244
+ if (config.whitelist.length === 0) return false
245
+ if (target.kind === 'file') {
246
+ const resolved = normalizePath(target.value, session?.header?.cwd ?? process.cwd())
247
+ return config.whitelist.some((root) => underRoot(resolved, root))
248
+ }
249
+ const strict = typeof commandCheck === 'function' ? commandCheck() : undefined
250
+ if (strict !== undefined) return strict
251
+ return config.whitelist.some((root) => target.value.includes(root))
252
+ }
253
+
254
+ /** 统一的透传结果。 */
255
+ function pass() {
256
+ return { kind: 'pass' }
257
+ }
258
+
259
+ /** 会话工作目录:header.cwd 优先,其次沙箱工作区根,最后进程 cwd。 */
260
+ function sessionCwdOf(session, services) {
261
+ const cwd = session?.header?.cwd
262
+ if (typeof cwd === 'string' && cwd.trim().length > 0) return cwd.trim()
263
+ const workspaceRoot = services.workspaceRootOf(session)
264
+ if (typeof workspaceRoot === 'string' && workspaceRoot.length > 0) return workspaceRoot
265
+ return process.cwd()
266
+ }
267
+
268
+ /**
269
+ * 判定一次工具调用(纯判定:不写日志、不触 IO、不改状态)。
270
+ *
271
+ * @param exec - tools/pre-execute 的 exec(用 name / arguments / agent.session)。
272
+ * @param config - normalizeConfig 的结果(白/黑名单应已 realpath 过)。
273
+ * @param services - `{ presetOf, workspaceRootOf, approvalOverrideOf, getSession }` 适配层。
274
+ * @returns Decision:`{ kind: 'pass'|'allow'|'ask'|'deny', code?, reason?, displayReason? }`。
275
+ */
276
+ export function decideCall(exec, config, services) {
277
+ const svc = services ?? {}
278
+ if (!config.enabled) return pass()
279
+ const session = sessionOf(exec)
280
+ if (session === undefined || session === null) return pass()
281
+ if (typeof svc.presetOf !== 'function' || typeof svc.workspaceRootOf !== 'function') return pass()
282
+ if (!isActive(session, config, svc)) return pass()
283
+
284
+ const sessionCwd = sessionCwdOf(session, svc)
285
+ const effect = classifyTool(exec.name, exec.arguments)
286
+ if (effect.kind === 'pass') return pass()
287
+ const roots = buildRoots(session, config, svc)
288
+
289
+ let decisions = []
290
+ let unanalyzable = null
291
+ if (effect.kind === 'read' || effect.kind === 'write') {
292
+ const targets = effect.targets.length > 0 ? effect.targets : [sessionCwd]
293
+ decisions = targets.map((target) => decideLayered(normalizePath(target, sessionCwd), effect.kind, roots))
294
+ } else {
295
+ const cwd = effect.workdir.length > 0 ? effect.workdir : sessionCwd
296
+ const analysis = analyzeCommand(effect.command, { shell: effect.shell, cwd })
297
+ unanalyzable = analysis.unanalyzable
298
+ if (unanalyzable === null) {
299
+ decisions = [
300
+ ...analysis.reads.map((target) => decideLayered(normalizePath(target, cwd), 'read', roots)),
301
+ ...analysis.writes.map((target) => decideLayered(normalizePath(target, cwd), 'write', roots)),
302
+ ]
303
+ }
304
+ }
305
+
306
+ let decision
307
+ if (unanalyzable !== null) {
308
+ const guidance = ';请改写成可静态判定的形式(显式绝对路径、限定的写目标,或拆分命令)'
309
+ decision = config.unanalyzable === 'ask'
310
+ ? { kind: 'ask', code: 'UNANALYZABLE', reason: `无法静态判定该命令的读写目标(${unanalyzable.reason})${guidance}` }
311
+ : { kind: 'deny', code: 'UNANALYZABLE', reason: `无法静态判定该命令的读写目标(${unanalyzable.reason}),已拒绝${guidance}` }
312
+ } else {
313
+ // 任一 ask 优先(需要用户批准);否则只要有一个目标要"交沙箱审批"就整体透传(沙箱会拦、会弹框);
314
+ // 全部 allow 才由我们直接放行。
315
+ const firstAsk = decisions.find((item) => item.kind === 'ask')
316
+ if (firstAsk !== undefined) {
317
+ decision = { kind: 'ask', code: firstAsk.code, reason: firstAsk.reason }
318
+ } else if (decisions.some((item) => item.kind === 'delegate')) {
319
+ return pass()
320
+ } else {
321
+ return { kind: 'allow' }
322
+ }
323
+ }
324
+
325
+ if (decision.kind === 'ask' && hasNoApprovalChannel(session, svc)) {
326
+ return {
327
+ kind: 'deny',
328
+ code: 'NO_APPROVAL_CHANNEL',
329
+ reason: `${decision.reason ?? '该操作需要审批'};但当前会话没有审批通道(子代理的 approval 被框架钉成 never),请到主会话执行,或改写成项目内/白名单内的目标`,
330
+ }
331
+ }
332
+ return {
333
+ kind: decision.kind,
334
+ code: decision.code,
335
+ reason: decision.reason,
336
+ displayReason: `路径规则:${decision.reason ?? '需要审批'}`,
337
+ }
338
+ }
339
+
340
+ // ─────────────────────────── Cordis 接线 ───────────────────────────
341
+
342
+ /** 追加一行审计日志;日志不可写时静默(不影响主流程)。 */
343
+ function logLine(logPath, event, summary, detail) {
344
+ try {
345
+ mkdirSync(dirname(logPath), { recursive: true })
346
+ const stamp = new Date().toISOString().slice(0, 19)
347
+ appendFileSync(logPath, `${stamp} | ${event} | ${String(summary ?? '')} | ${String(detail ?? '')}\n`, 'utf8')
348
+ } catch {
349
+ /* 日志失败不影响主流程 */
350
+ }
351
+ }
352
+
353
+ /**
354
+ * 解析 DSH home:优先用宿主提供的 `dshHomePath` 服务(尊重 `DSH_HOME`),
355
+ * 缺失时按 `$DSH_HOME` → `~/.dsh` 回退。用于定位自动白名单状态文件与保护路径。
356
+ */
357
+ function resolveDshHome(ctx) {
358
+ const homePath = ctx.get?.('dshHomePath')
359
+ if (typeof homePath === 'function') {
360
+ try {
361
+ const home = homePath()
362
+ if (typeof home === 'string' && home.length > 0) return home
363
+ } catch {
364
+ /* 服务不可用时走环境变量回退 */
365
+ }
366
+ }
367
+ const fromEnv = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
368
+ return fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh')
369
+ }
370
+
371
+ /**
372
+ * 自动白名单状态文件路径:配置优先(支持 `~`),否则放在 `<dsh home>/path-rules/auto-whitelist.json`。
373
+ * 按平台分区写在文件内部(见 auto-whitelist.mjs),所以容器与宿主同步同一份文件也不会串味。
374
+ */
375
+ function resolveAutoWhitelistFile(config, ctx) {
376
+ if (config.autoWhitelistFile.length > 0) {
377
+ return normalizePath(expandHome(config.autoWhitelistFile, homedir()), process.cwd())
378
+ }
379
+ return normalizePath(join(resolveDshHome(ctx), 'path-rules', 'auto-whitelist.json'), process.cwd())
380
+ }
381
+
382
+ /** 给模型看的规则说明;**只有本会话真的受该模式管辖时才注入**(否则模型会误判自己的权限档)。 */
383
+ function renderRules(config, session, services, extras = {}) {
384
+ if (session === undefined || session === null) return ''
385
+ if (typeof services.presetOf !== 'function') return ''
386
+ try {
387
+ if (!isActive(session, config, services)) return ''
388
+ } catch {
389
+ return ''
390
+ }
391
+ const cwd = typeof session?.header?.cwd === 'string' ? session.header.cwd : ''
392
+ const declaredWhitelist = Array.isArray(extras.declaredWhitelist) ? extras.declaredWhitelist : config.whitelist
393
+ const autoEntries = Array.isArray(extras.autoEntries) ? extras.autoEntries : []
394
+ const lines = [
395
+ '[path-rules] 当前权限模式:路径规则。',
396
+ `会话工作区(可自由读写):${cwd.length > 0 ? cwd : '(未知)'}`,
397
+ '工作区外:读取允许;写入需要用户审批(会弹工具审批框)。',
398
+ ]
399
+ if (declaredWhitelist.length > 0) lines.push(`白名单(可自由读写):${declaredWhitelist.join(', ')}`)
400
+ if (autoEntries.length > 0) {
401
+ lines.push(`自动白名单(用户批准后自动学习,升权请求会被自动放行、不弹框):${autoEntries.map((entry) => entry.path).join(', ')}`)
402
+ }
403
+ if (config.blacklist.length > 0) lines.push(`黑名单(读写都需要审批):${config.blacklist.join(', ')}`)
404
+ lines.push('bash/pwsh:只读命令直接放行;写命令请写成可静态判定的形式(显式路径),判不出的命令会被拒绝。')
405
+ lines.push('工作区外写入若先被沙箱拒绝:用 sandbox_permissions(能覆盖该目标的最窄更宽模式)+ justification 重试同一调用,用户审批后即可写入;白名单与自动白名单内的目标会自动放行。')
406
+ return lines.join('\n')
407
+ }
408
+
409
+ /** 日志里的调用摘要(arguments 不可读时不抛)。 */
410
+ function describeCall(exec) {
411
+ const toolName = String(exec?.name ?? '?')
412
+ try {
413
+ const args = exec?.arguments
414
+ const text = typeof args === 'object' && args !== null ? JSON.stringify(args) : String(args ?? '')
415
+ return `${toolName} ${text.length > 120 ? `${text.slice(0, 120)}…` : text}`
416
+ } catch {
417
+ return `${toolName} (arguments 不可读)`
418
+ }
419
+ }
420
+
421
+ /** 按"更严者胜"合成下游结论与我方判定(spec §4.2 ⑦ / D7)。 */
422
+ function compose(downstream, decision, config, log, exec) {
423
+ const kind = downstream?.kind ?? 'allow'
424
+ if (kind === 'deny' || kind === 'cancel') {
425
+ log('pass', `downstream:${kind}`, describeCall(exec))
426
+ return downstream
427
+ }
428
+ if (config.dryRun) {
429
+ log('dry-run', `${decision.kind}${decision.code === undefined ? '' : `:${decision.code}`}`, describeCall(exec))
430
+ return downstream
431
+ }
432
+ if (decision.kind === 'ask') {
433
+ log('ask', `${decision.code ?? ''} ${decision.reason ?? ''}`.trim(), describeCall(exec))
434
+ return { kind: 'ask', reason: decision.reason, displayReason: decision.displayReason }
435
+ }
436
+ if (decision.kind === 'deny') {
437
+ log('deny', `${decision.code ?? ''} ${decision.reason ?? ''}`.trim(), describeCall(exec))
438
+ return { kind: 'deny', reason: decision.reason }
439
+ }
440
+ if (decision.kind === 'allow') {
441
+ log('allow', 'path-rules', describeCall(exec))
442
+ return downstream
443
+ }
444
+ return downstream
445
+ }
446
+
447
+ /**
448
+ * 插件入口:注册 pre-execute 钩子与规则说明注入。
449
+ *
450
+ * 所需服务一律用 ctx.get 取,缺失时按"未激活"退化 —— 不因缺服务让插件加载失败,
451
+ * 也不改变现状行为(spec §4.2 ①、§5.4)。
452
+ */
453
+ export function apply(ctx, rawConfig) {
454
+ const base = normalizeConfig(rawConfig)
455
+ const declaredWhitelist = normalizeRoots(base.whitelist, process.cwd())
456
+ const config = {
457
+ ...base,
458
+ whitelist: [...declaredWhitelist],
459
+ blacklist: normalizeRoots(base.blacklist, process.cwd()),
460
+ }
461
+ const log = (event, summary, detail) => logLine(config.logPath, event, summary, detail)
462
+ log('plugin-loaded', `enabled=${config.enabled} autoWhitelist=${config.autoWhitelist}`, `activationPreset=${config.activationPreset} dryRun=${config.dryRun}`)
463
+ if (config.modelFallback) {
464
+ log('error', 'warn:model-fallback-ignored', 'modelFallback 本期不改变判定行为(模型裁决路径需另立 spec)')
465
+ }
466
+
467
+ // 自动白名单的状态文件与保护路径(只在开关打开时才读盘;关闭时零 IO、零行为变化)。
468
+ const autoWhitelistFile = resolveAutoWhitelistFile(config, ctx)
469
+ const dshHome = resolveDshHome(ctx)
470
+ const protectedPaths = [...new Set([autoWhitelistFile, config.logPath, dshHome])]
471
+ /** 当前进程内的自动白名单条目(与 config.whitelist 同步追加;持久化只写本平台分区)。 */
472
+ const autoEntries = []
473
+ if (config.autoWhitelist) {
474
+ const loaded = loadAutoWhitelist(autoWhitelistFile, process.platform)
475
+ if (loaded.error !== undefined) log('error', 'auto-whitelist-load', loaded.error)
476
+ for (const entry of loaded.entries) {
477
+ const normalized = normalizePath(entry.path, process.cwd())
478
+ if (normalized.length === 0 || autoEntries.some((item) => item.path === normalized)) continue
479
+ autoEntries.push({ ...entry, path: normalized })
480
+ if (!config.whitelist.includes(normalized)) config.whitelist.push(normalized)
481
+ }
482
+ if (autoEntries.length > 0) log('auto-whitelist', `loaded=${autoEntries.length}`, autoWhitelistFile)
483
+ }
484
+
485
+ const services = {
486
+ presetOf: (session) => ctx.get('permissionPresets')?.current?.(session),
487
+ workspaceRootOf: (session) => ctx.get('sandboxPolicy')?.resolve?.({ session })?.workspaceRoot,
488
+ approvalOverrideOf: (session) => ctx.get('approval')?.overrideOf?.(session),
489
+ getSession: (id) => ctx.get('sessions')?.get?.(id),
490
+ }
491
+
492
+ // 按 callId 记住最近一次工具调用的参数:真实运行态里审批请求附带的对象不暴露 session.events,
493
+ // 无法按 callId 回查会话日志(探针实测 hasEvents=false),所以白名单升权自动放行要依赖这份记忆。
494
+ const recentCallArgs = new Map()
495
+ const REMEMBERED_CALLS_MAX = 200
496
+ const rememberCall = (exec) => {
497
+ try {
498
+ if (typeof exec?.callId !== 'string') return
499
+ recentCallArgs.set(exec.callId, exec.arguments)
500
+ if (recentCallArgs.size > REMEMBERED_CALLS_MAX) {
501
+ recentCallArgs.delete(recentCallArgs.keys().next().value)
502
+ }
503
+ } catch {
504
+ /* 参数不可读时不记(不影响判定) */
505
+ }
506
+ }
507
+
508
+ ctx.inject(['systemPrompt'], (scope) => {
509
+ scope.systemPrompt.context({
510
+ name: 'path-rules',
511
+ order: 111,
512
+ text: (context) => renderRules(config, context?.agent?.session, services, { declaredWhitelist, autoEntries }),
513
+ })
514
+ })
515
+
516
+ /**
517
+ * 把一次"用户批准的沙箱升权调用"学进自动白名单。
518
+ *
519
+ * 只在 path-rules 档激活的会话里学(其它档不改变任何状态);任何异常都吞掉并记日志 ——
520
+ * 它只是顺手降低重复弹框,绝不能影响审批结果本身。
521
+ */
522
+ const learnFromApproval = (request) => {
523
+ const session = request.agent?.session
524
+ if (session === undefined || session === null) return
525
+ if (!isActive(session, config, services)) return
526
+ const args = recentCallArgs.get(request.callId)
527
+ if (args === undefined) {
528
+ log('auto-whitelist-skip', 'no-remembered-args', request.callId)
529
+ return
530
+ }
531
+ const cwd = sessionCwdOf(session, services)
532
+ const derived = writableDirsOf(request.toolName, args, cwd)
533
+ if (derived.pairs.length === 0) {
534
+ log('auto-whitelist-skip', derived.reason ?? 'no-write-target', request.callId)
535
+ return
536
+ }
537
+ const roots = buildRoots(session, config, services)
538
+ const { accepted, skipped } = filterAutoWhitelistDirs(derived.pairs, {
539
+ roots,
540
+ protectedPaths,
541
+ sensitiveRoots: sensitiveRoots(),
542
+ })
543
+ for (const item of skipped) log('auto-whitelist-skip', `${item.reason} ${item.dir}`, describeCall({ name: request.toolName, arguments: args }))
544
+ if (accepted.length === 0) return
545
+
546
+ const added = []
547
+ for (const dir of accepted) {
548
+ if (autoEntries.some((entry) => entry.path === dir)) continue
549
+ autoEntries.push({
550
+ path: dir,
551
+ platform: process.platform,
552
+ addedAt: new Date().toISOString(),
553
+ tool: String(request.toolName ?? ''),
554
+ callId: request.callId,
555
+ })
556
+ if (!config.whitelist.includes(dir)) config.whitelist.push(dir)
557
+ added.push(dir)
558
+ }
559
+ // 上限按"最旧淘汰":自动白名单只是便利,不能无限膨胀。
560
+ while (autoEntries.length > MAX_AUTO_ENTRIES) {
561
+ const dropped = autoEntries.shift()
562
+ const index = config.whitelist.indexOf(dropped.path)
563
+ if (index >= 0) config.whitelist.splice(index, 1)
564
+ }
565
+ if (added.length === 0) return
566
+ const saved = saveAutoWhitelist(autoWhitelistFile, process.platform, autoEntries)
567
+ if (!saved.ok) log('error', 'auto-whitelist-save', saved.error)
568
+ log('auto-whitelist', `added=${added.length}`, added.join(', '))
569
+ }
570
+
571
+ // 白名单目标的沙箱升权请求自动放行(承接已退役的 sandbox-path-allowlist 能力):
572
+ // 只作答 "escalate sandbox to " 开头的请求,且目标必须命中白名单;其余一律交给下游弹框。
573
+ // autoWhitelist 打开时再包一层:等用户的结论,只有 allowed-once(用户点了同意)才学习。
574
+ ctx.on('approval/request', (request, next) => {
575
+ if (typeof request?.callId !== 'string') return next()
576
+ if (typeof request.reason !== 'string' || !request.reason.startsWith(SANDBOX_ESCALATION_PREFIX)) return next()
577
+
578
+ let target
579
+ try {
580
+ const remembered = recentCallArgs.get(request.callId)
581
+ const session = request.agent?.session
582
+ // remembered 优先:真实运行态里审批请求附带的 session 不暴露 events,无法按 callId 回查
583
+ // 会话日志(实测 hasEvents=false),只有 pre-execute 时记下的参数可靠。
584
+ target = escalationTargetFromArgs(remembered) ?? escalationTargetOf(session, request.callId)
585
+ const commandCheck = () => commandEscalationIsWhitelisted(config, session, services, request.toolName, remembered)
586
+ if (target !== undefined && escalationIsWhitelisted(target, config, session, commandCheck)) {
587
+ // remembered 只进审计日志,便于区分"走记忆命中"与"走会话日志兜底命中"
588
+ log('allow', 'whitelist-escalation', `${request.callId} remembered=${remembered !== undefined} ${target.value}`)
589
+ return 'allowed-once'
590
+ }
591
+ } catch (error) {
592
+ log('error', 'approval-error', String(error))
593
+ return next()
594
+ }
595
+ if (target === undefined || !config.autoWhitelist || !config.enabled || config.dryRun) return next()
596
+ return Promise.resolve(next()).then((outcome) => {
597
+ if (outcome === 'allowed-once') {
598
+ try {
599
+ learnFromApproval(request)
600
+ } catch (error) {
601
+ log('error', 'auto-whitelist-error', String(error))
602
+ }
603
+ }
604
+ return outcome
605
+ })
606
+ }, { prepend: true })
607
+
608
+ ctx.on('tools/pre-execute', async (exec, next) => {
609
+ rememberCall(exec)
610
+ // 先取下游结论:既满足 waterfall"调用 next 才不否决整条链"的语义,又能做"更严者胜"合成
611
+ let downstream
612
+ try {
613
+ downstream = await next()
614
+ } catch (error) {
615
+ // 下游监听器自身抛错:记录后原样抛给框架,不在这里替它做决定
616
+ log('error', 'downstream-error', String(error))
617
+ throw error
618
+ }
619
+ let decision
620
+ try {
621
+ decision = decideCall(exec, config, services)
622
+ } catch (error) {
623
+ log('error', 'gate-error', `${String(error)} | ${describeCall(exec)}`)
624
+ if (config.failMode === 'closed') {
625
+ return {
626
+ kind: 'deny',
627
+ reason: `路径规则闸门内部错误(fail-closed):${String(error)};可切换权限档或停用本插件后重试`,
628
+ }
629
+ }
630
+ return downstream
631
+ }
632
+ return compose(downstream, decision, config, log, exec)
633
+ })
634
+ }