@gavinc-cn/dsh-path-rules 0.0.0-stage → 0.1.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.
@@ -0,0 +1,72 @@
1
+ // dsh-path-rules 的工具映射层(纯函数):工具名 + 参数 → 读写意图与目标。
2
+ //
3
+ // 只处理 spec §5.2 表里的工具;**其余一切工具一律返回 {kind:'pass'}(透传)**:
4
+ // 未受控工具若自行写盘,本闸门覆盖不到,这是 spec §9-2 的已知盲区,
5
+ // 不能靠"猜一个读写意图"来假装覆盖。
6
+
7
+ /** 取 file_path 的读工具。 */
8
+ const READ_FILE_TOOLS = new Set(['read', 'read_image'])
9
+ /** 取 file_path 的写工具。 */
10
+ const WRITE_FILE_TOOLS = new Set(['write', 'edit'])
11
+ /** 取 path(可缺省为会话 cwd)的搜索工具。 */
12
+ const SEARCH_TOOLS = new Set(['glob', 'grep'])
13
+ /** bash 里 workdir 的候选参数名(bash 工具有 workdir,部分调用用 cwd)。 */
14
+ const WORKDIR_KEYS = ['workdir', 'cwd']
15
+
16
+ /** 非空字符串判定:类型不符或空白一律当"不可用"。 */
17
+ function nonEmptyString(value) {
18
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
19
+ }
20
+
21
+ /** 统一的 pass 结果。 */
22
+ function pass() {
23
+ return { kind: 'pass' }
24
+ }
25
+
26
+ /**
27
+ * 把一个工具调用映射成读写意图。
28
+ *
29
+ * @param name - 工具名(大小写不敏感)。
30
+ * @param args - 工具参数(工具自身会做 schema 校验,这里只做保守提取)。
31
+ * @returns {kind:'pass'}
32
+ * | {kind:'read'|'write', targets:string[]}
33
+ * | {kind:'shell', command:string, workdir:string, shell:'bash'|'pwsh'}
34
+ * `targets` 为空数组表示"没有显式路径"(搜索工具的 path 缺省 = 会话 cwd,由调用方补)。
35
+ */
36
+ export function classifyTool(name, args) {
37
+ const tool = String(name ?? '').toLowerCase()
38
+ const a = args !== null && typeof args === 'object' ? args : {}
39
+ if (READ_FILE_TOOLS.has(tool) || WRITE_FILE_TOOLS.has(tool)) {
40
+ const target = nonEmptyString(a['file_path'])
41
+ if (target === undefined) return pass()
42
+ return { kind: READ_FILE_TOOLS.has(tool) ? 'read' : 'write', targets: [target] }
43
+ }
44
+ if (SEARCH_TOOLS.has(tool)) {
45
+ const raw = a['path']
46
+ if (raw !== undefined && nonEmptyString(raw) === undefined) return pass()
47
+ const target = nonEmptyString(raw)
48
+ return { kind: 'read', targets: target === undefined ? [] : [target] }
49
+ }
50
+ if (tool === 'str_replace_editor') {
51
+ const command = nonEmptyString(a['command'])
52
+ const target = nonEmptyString(a['path'])
53
+ if (command === undefined || target === undefined) return pass()
54
+ return { kind: command === 'view' ? 'read' : 'write', targets: [target] }
55
+ }
56
+ if (tool === 'bash' || tool === 'pwsh') {
57
+ const command = nonEmptyString(a['command'])
58
+ if (command === undefined) return pass()
59
+ let workdir = ''
60
+ if (tool === 'bash') {
61
+ for (const key of WORKDIR_KEYS) {
62
+ const value = nonEmptyString(a[key])
63
+ if (value !== undefined) {
64
+ workdir = value
65
+ break
66
+ }
67
+ }
68
+ }
69
+ return { kind: 'shell', command, workdir, shell: tool === 'bash' ? 'bash' : 'pwsh' }
70
+ }
71
+ return pass()
72
+ }
package/lib/index.mjs ADDED
@@ -0,0 +1,466 @@
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 { classifyTool } from './classify.mjs'
22
+ import { combine, classifyPath, decideTarget, normalizePath, normalizeRoots, underRoot } from './rules.mjs'
23
+
24
+ export const name = 'dsh-path-rules'
25
+
26
+ /** 默认审计日志(与既有守卫同目录)。 */
27
+ const DEFAULT_LOG = join(homedir(), '.kimi-code', 'hooks', 'logs', 'path_rules.dsh.log')
28
+
29
+ /** 父链上溯的最大层数(防环 + 防病态深链)。 */
30
+ const MAX_PARENT_HOPS = 8
31
+
32
+ /** 允许的 unanalyzable 策略。 */
33
+ const UNANALYZABLE_POLICIES = new Set(['deny', 'ask'])
34
+
35
+ /** 允许的 failMode。 */
36
+ const FAIL_MODES = new Set(['closed', 'open'])
37
+
38
+ /** 校验一串"必须是绝对路径"的配置项。 */
39
+ function absolutePathList(value, field) {
40
+ if (value === undefined) return []
41
+ if (!Array.isArray(value)) throw new Error(`dsh-path-rules: ${field} must be an array of absolute paths`)
42
+ for (const item of value) {
43
+ if (typeof item !== 'string' || !isAbsolute(item)) {
44
+ throw new Error(`dsh-path-rules: ${field} entries must be absolute paths, got ${JSON.stringify(item)}`)
45
+ }
46
+ }
47
+ return value
48
+ }
49
+
50
+ /**
51
+ * 归一化插件配置(只做校验与默认值,不做 realpath —— realpath 由 apply() 在加载时做一次)。
52
+ *
53
+ * @param raw - profile 覆盖行给的 config(可为空)。
54
+ * @returns 完整的 Config;非法配置直接抛错(拒绝加载,不改变现状行为)。
55
+ */
56
+ export function normalizeConfig(raw) {
57
+ const c = raw ?? {}
58
+ if (c.unanalyzable !== undefined && !UNANALYZABLE_POLICIES.has(c.unanalyzable)) {
59
+ throw new Error(`dsh-path-rules: unanalyzable must be "deny" or "ask", got ${JSON.stringify(c.unanalyzable)}`)
60
+ }
61
+ if (c.failMode !== undefined && !FAIL_MODES.has(c.failMode)) {
62
+ throw new Error(`dsh-path-rules: failMode must be "closed" or "open", got ${JSON.stringify(c.failMode)}`)
63
+ }
64
+ const activationPreset = c.activationPreset === undefined ? 'path-rules' : c.activationPreset
65
+ if (typeof activationPreset !== 'string' || activationPreset.trim().length === 0) {
66
+ throw new Error('dsh-path-rules: activationPreset must be a non-empty string')
67
+ }
68
+ return {
69
+ enabled: c.enabled !== false,
70
+ activationPreset: activationPreset.trim(),
71
+ whitelist: absolutePathList(c.whitelist, 'whitelist'),
72
+ blacklist: absolutePathList(c.blacklist, 'blacklist'),
73
+ allowTempRoots: c.allowTempRoots !== false,
74
+ unanalyzable: c.unanalyzable ?? 'deny',
75
+ failMode: c.failMode ?? 'closed',
76
+ dryRun: c.dryRun === true,
77
+ modelFallback: c.modelFallback === true,
78
+ logPath: typeof c.logPath === 'string' && c.logPath.trim().length > 0 ? c.logPath.trim() : DEFAULT_LOG,
79
+ }
80
+ }
81
+
82
+ /** 取调用所属会话。 */
83
+ function sessionOf(exec) {
84
+ return exec?.agent?.session
85
+ }
86
+
87
+ /** 子代理会话判定(框架只把它们标在 header 上)。 */
88
+ function isSubagentSession(session) {
89
+ const header = session?.header ?? {}
90
+ return header.origin === 'subagent' || (typeof header.delegationDepth === 'number' && header.delegationDepth > 0)
91
+ }
92
+
93
+ /** 该会话是否有审批通道:子代理恒无(框架把它们的 approval 钉成 never)。 */
94
+ function hasNoApprovalChannel(session, services) {
95
+ if (isSubagentSession(session)) return true
96
+ return services.approvalOverrideOf?.(session) === 'never'
97
+ }
98
+
99
+ /**
100
+ * 模式是否激活:本会话的预设身份命中 activationPreset;
101
+ * 否则若是子代理,沿 header.parentSession 上溯(≤ MAX_PARENT_HOPS 层、带环检测)。
102
+ */
103
+ function isActive(session, config, services) {
104
+ if (services.presetOf(session) === config.activationPreset) return true
105
+ if (!isSubagentSession(session)) return false
106
+ const visited = new Set([session])
107
+ let current = session
108
+ for (let hops = 0; hops < MAX_PARENT_HOPS; hops += 1) {
109
+ const parentId = current?.header?.parentSession
110
+ if (typeof parentId !== 'string' || parentId.length === 0) return false
111
+ const parent = services.getSession?.(parentId)
112
+ if (parent === undefined || parent === null || visited.has(parent)) return false
113
+ visited.add(parent)
114
+ if (services.presetOf(parent) === config.activationPreset) return true
115
+ current = parent
116
+ }
117
+ return false
118
+ }
119
+
120
+ /** 组装判定用的根集合(project 取沙箱口径的 workspaceRoot)。 */
121
+ function buildRoots(session, config, services) {
122
+ const project = services.workspaceRootOf(session)
123
+ const normalizedProject = typeof project === 'string' && project.length > 0 ? normalizePath(project, project) : null
124
+ const temp = config.allowTempRoots
125
+ ? [...new Set([normalizePath('/tmp', normalizedProject ?? '/'), normalizePath(tmpdir(), normalizedProject ?? '/')])]
126
+ : []
127
+ return {
128
+ project: normalizedProject,
129
+ whitelist: config.whitelist,
130
+ blacklist: config.blacklist,
131
+ temp: temp.filter((path) => path.length > 0),
132
+ }
133
+ }
134
+
135
+ /** 沙箱升权请求的 reason 前缀(框架固定格式)。 */
136
+ const SANDBOX_ESCALATION_PREFIX = 'escalate sandbox to '
137
+
138
+ /**
139
+ * 目标是否落在沙箱可写根内(会话工作区 / 临时区)。
140
+ * 这是"谁负责审批"的分界线(spec §4.4):根内的黑名单写只有插件能拦;根外的写由沙箱升权审批。
141
+ */
142
+ function insideWritableRoots(target, roots) {
143
+ if (typeof roots.project === 'string' && roots.project.length > 0 && underRoot(target, roots.project)) return true
144
+ return roots.temp.some((root) => underRoot(target, root))
145
+ }
146
+
147
+ /**
148
+ * 单目标的分层判定:`allow` / `ask` / `delegate`(委派给沙箱升权审批)。
149
+ * - 黑名单读 ⇒ ask(沙箱从不拦读,这是唯一防线);
150
+ * - 黑名单写 ⇒ 沙箱可写根内 ask,根外 delegate(沙箱会拦并弹审批);
151
+ * - 项目外读 ⇒ allow;项目外写 ⇒ delegate;
152
+ * - 白名单/项目内/临时区 ⇒ allow。
153
+ */
154
+ function decideLayered(target, effect, roots) {
155
+ const kind = classifyPath(target, roots)
156
+ if (kind === 'blacklist') {
157
+ if (effect === 'write' && !insideWritableRoots(target, roots)) return { kind: 'delegate' }
158
+ return { kind: 'ask', code: 'BLACKLIST', reason: `目标命中黑名单:${target}` }
159
+ }
160
+ if (kind === 'outside') return effect === 'write' ? { kind: 'delegate' } : { kind: 'allow' }
161
+ return { kind: 'allow' }
162
+ }
163
+
164
+ /** 从会话日志按 callId 找该次工具调用的目标(用于升权请求的白名单自动放行)。 */
165
+ function escalationTargetOf(session, callId) {
166
+ const events = session?.events
167
+ if (!Array.isArray(events)) return undefined
168
+ for (let i = events.length - 1; i >= 0; i -= 1) {
169
+ const event = events[i]
170
+ if (!event || event.type !== 'tool/call' || event.data?.callId !== callId) continue
171
+ let args
172
+ try {
173
+ args = JSON.parse(event.data.arguments)
174
+ } catch {
175
+ return undefined
176
+ }
177
+ if (args === null || typeof args !== 'object') return undefined
178
+ for (const key of ['file_path', 'path']) {
179
+ if (typeof args[key] === 'string' && args[key].length > 0) return { kind: 'file', value: args[key] }
180
+ }
181
+ const toolName = String(event.data.name ?? '')
182
+ if ((toolName === 'bash' || toolName === 'pwsh') && typeof args.command === 'string') {
183
+ return { kind: 'command', value: args.command }
184
+ }
185
+ return undefined
186
+ }
187
+ return undefined
188
+ }
189
+
190
+ /** 从一次工具调用的参数里提取升权目标(与 escalationTargetOf 的提取规则一致)。 */
191
+ function escalationTargetFromArgs(args) {
192
+ if (args === null || typeof args !== 'object') return undefined
193
+ for (const key of ['file_path', 'path']) {
194
+ if (typeof args[key] === 'string' && args[key].length > 0) return { kind: 'file', value: args[key] }
195
+ }
196
+ if (typeof args.command === 'string') return { kind: 'command', value: args.command }
197
+ return undefined
198
+ }
199
+
200
+ /** 升权请求的目标是否命中白名单(文件按 realpath 后前缀;命令按粗粒度子串,沿用旧插件口径)。 */
201
+ function escalationIsWhitelisted(target, config, session) {
202
+ if (config.whitelist.length === 0) return false
203
+ if (target.kind === 'file') {
204
+ const resolved = normalizePath(target.value, session?.header?.cwd ?? process.cwd())
205
+ return config.whitelist.some((root) => underRoot(resolved, root))
206
+ }
207
+ return config.whitelist.some((root) => target.value.includes(root))
208
+ }
209
+
210
+ /** 统一的透传结果。 */
211
+ function pass() {
212
+ return { kind: 'pass' }
213
+ }
214
+
215
+ /** 会话工作目录:header.cwd 优先,其次沙箱工作区根,最后进程 cwd。 */
216
+ function sessionCwdOf(session, services) {
217
+ const cwd = session?.header?.cwd
218
+ if (typeof cwd === 'string' && cwd.trim().length > 0) return cwd.trim()
219
+ const workspaceRoot = services.workspaceRootOf(session)
220
+ if (typeof workspaceRoot === 'string' && workspaceRoot.length > 0) return workspaceRoot
221
+ return process.cwd()
222
+ }
223
+
224
+ /**
225
+ * 判定一次工具调用(纯判定:不写日志、不触 IO、不改状态)。
226
+ *
227
+ * @param exec - tools/pre-execute 的 exec(用 name / arguments / agent.session)。
228
+ * @param config - normalizeConfig 的结果(白/黑名单应已 realpath 过)。
229
+ * @param services - `{ presetOf, workspaceRootOf, approvalOverrideOf, getSession }` 适配层。
230
+ * @returns Decision:`{ kind: 'pass'|'allow'|'ask'|'deny', code?, reason?, displayReason? }`。
231
+ */
232
+ export function decideCall(exec, config, services) {
233
+ const svc = services ?? {}
234
+ if (!config.enabled) return pass()
235
+ const session = sessionOf(exec)
236
+ if (session === undefined || session === null) return pass()
237
+ if (typeof svc.presetOf !== 'function' || typeof svc.workspaceRootOf !== 'function') return pass()
238
+ if (!isActive(session, config, svc)) return pass()
239
+
240
+ const sessionCwd = sessionCwdOf(session, svc)
241
+ const effect = classifyTool(exec.name, exec.arguments)
242
+ if (effect.kind === 'pass') return pass()
243
+ const roots = buildRoots(session, config, svc)
244
+
245
+ let decisions = []
246
+ let unanalyzable = null
247
+ if (effect.kind === 'read' || effect.kind === 'write') {
248
+ const targets = effect.targets.length > 0 ? effect.targets : [sessionCwd]
249
+ decisions = targets.map((target) => decideLayered(normalizePath(target, sessionCwd), effect.kind, roots))
250
+ } else {
251
+ const cwd = effect.workdir.length > 0 ? effect.workdir : sessionCwd
252
+ const analysis = analyzeCommand(effect.command, { shell: effect.shell, cwd })
253
+ unanalyzable = analysis.unanalyzable
254
+ if (unanalyzable === null) {
255
+ decisions = [
256
+ ...analysis.reads.map((target) => decideLayered(normalizePath(target, cwd), 'read', roots)),
257
+ ...analysis.writes.map((target) => decideLayered(normalizePath(target, cwd), 'write', roots)),
258
+ ]
259
+ }
260
+ }
261
+
262
+ let decision
263
+ if (unanalyzable !== null) {
264
+ const guidance = ';请改写成可静态判定的形式(显式绝对路径、限定的写目标,或拆分命令)'
265
+ decision = config.unanalyzable === 'ask'
266
+ ? { kind: 'ask', code: 'UNANALYZABLE', reason: `无法静态判定该命令的读写目标(${unanalyzable.reason})${guidance}` }
267
+ : { kind: 'deny', code: 'UNANALYZABLE', reason: `无法静态判定该命令的读写目标(${unanalyzable.reason}),已拒绝${guidance}` }
268
+ } else {
269
+ // 任一 ask 优先(需要用户批准);否则只要有一个目标要"交沙箱审批"就整体透传(沙箱会拦、会弹框);
270
+ // 全部 allow 才由我们直接放行。
271
+ const firstAsk = decisions.find((item) => item.kind === 'ask')
272
+ if (firstAsk !== undefined) {
273
+ decision = { kind: 'ask', code: firstAsk.code, reason: firstAsk.reason }
274
+ } else if (decisions.some((item) => item.kind === 'delegate')) {
275
+ return pass()
276
+ } else {
277
+ return { kind: 'allow' }
278
+ }
279
+ }
280
+
281
+ if (decision.kind === 'ask' && hasNoApprovalChannel(session, svc)) {
282
+ return {
283
+ kind: 'deny',
284
+ code: 'NO_APPROVAL_CHANNEL',
285
+ reason: `${decision.reason ?? '该操作需要审批'};但当前会话没有审批通道(子代理的 approval 被框架钉成 never),请到主会话执行,或改写成项目内/白名单内的目标`,
286
+ }
287
+ }
288
+ return {
289
+ kind: decision.kind,
290
+ code: decision.code,
291
+ reason: decision.reason,
292
+ displayReason: `路径规则:${decision.reason ?? '需要审批'}`,
293
+ }
294
+ }
295
+
296
+ // ─────────────────────────── Cordis 接线 ───────────────────────────
297
+
298
+ /** 追加一行审计日志;日志不可写时静默(不影响主流程)。 */
299
+ function logLine(logPath, event, summary, detail) {
300
+ try {
301
+ mkdirSync(dirname(logPath), { recursive: true })
302
+ const stamp = new Date().toISOString().slice(0, 19)
303
+ appendFileSync(logPath, `${stamp} | ${event} | ${String(summary ?? '')} | ${String(detail ?? '')}\n`, 'utf8')
304
+ } catch {
305
+ /* 日志失败不影响主流程 */
306
+ }
307
+ }
308
+
309
+ /** 给模型看的规则说明;**只有本会话真的受该模式管辖时才注入**(否则模型会误判自己的权限档)。 */
310
+ function renderRules(config, session, services) {
311
+ if (session === undefined || session === null) return ''
312
+ if (typeof services.presetOf !== 'function') return ''
313
+ try {
314
+ if (!isActive(session, config, services)) return ''
315
+ } catch {
316
+ return ''
317
+ }
318
+ const cwd = typeof session?.header?.cwd === 'string' ? session.header.cwd : ''
319
+ const lines = [
320
+ '[path-rules] 当前权限模式:路径规则。',
321
+ `会话工作区(可自由读写):${cwd.length > 0 ? cwd : '(未知)'}`,
322
+ '工作区外:读取允许;写入需要用户审批(会弹工具审批框)。',
323
+ ]
324
+ if (config.whitelist.length > 0) lines.push(`白名单(可自由读写):${config.whitelist.join(', ')}`)
325
+ if (config.blacklist.length > 0) lines.push(`黑名单(读写都需要审批):${config.blacklist.join(', ')}`)
326
+ lines.push('bash/pwsh:只读命令直接放行;写命令请写成可静态判定的形式(显式路径),判不出的命令会被拒绝。')
327
+ lines.push('工作区外写入若先被沙箱拒绝:用 sandbox_permissions(能覆盖该目标的最窄更宽模式)+ justification 重试同一调用,用户审批后即可写入;白名单内的目标会自动放行。')
328
+ return lines.join('\n')
329
+ }
330
+
331
+ /** 日志里的调用摘要(arguments 不可读时不抛)。 */
332
+ function describeCall(exec) {
333
+ const toolName = String(exec?.name ?? '?')
334
+ try {
335
+ const args = exec?.arguments
336
+ const text = typeof args === 'object' && args !== null ? JSON.stringify(args) : String(args ?? '')
337
+ return `${toolName} ${text.length > 120 ? `${text.slice(0, 120)}…` : text}`
338
+ } catch {
339
+ return `${toolName} (arguments 不可读)`
340
+ }
341
+ }
342
+
343
+ /** 按"更严者胜"合成下游结论与我方判定(spec §4.2 ⑦ / D7)。 */
344
+ function compose(downstream, decision, config, log, exec) {
345
+ const kind = downstream?.kind ?? 'allow'
346
+ if (kind === 'deny' || kind === 'cancel') {
347
+ log('pass', `downstream:${kind}`, describeCall(exec))
348
+ return downstream
349
+ }
350
+ if (config.dryRun) {
351
+ log('dry-run', `${decision.kind}${decision.code === undefined ? '' : `:${decision.code}`}`, describeCall(exec))
352
+ return downstream
353
+ }
354
+ if (decision.kind === 'ask') {
355
+ log('ask', `${decision.code ?? ''} ${decision.reason ?? ''}`.trim(), describeCall(exec))
356
+ return { kind: 'ask', reason: decision.reason, displayReason: decision.displayReason }
357
+ }
358
+ if (decision.kind === 'deny') {
359
+ log('deny', `${decision.code ?? ''} ${decision.reason ?? ''}`.trim(), describeCall(exec))
360
+ return { kind: 'deny', reason: decision.reason }
361
+ }
362
+ if (decision.kind === 'allow') {
363
+ log('allow', 'path-rules', describeCall(exec))
364
+ return downstream
365
+ }
366
+ return downstream
367
+ }
368
+
369
+ /**
370
+ * 插件入口:注册 pre-execute 钩子与规则说明注入。
371
+ *
372
+ * 所需服务一律用 ctx.get 取,缺失时按"未激活"退化 —— 不因缺服务让插件加载失败,
373
+ * 也不改变现状行为(spec §4.2 ①、§5.4)。
374
+ */
375
+ export function apply(ctx, rawConfig) {
376
+ const base = normalizeConfig(rawConfig)
377
+ const config = {
378
+ ...base,
379
+ whitelist: normalizeRoots(base.whitelist, process.cwd()),
380
+ blacklist: normalizeRoots(base.blacklist, process.cwd()),
381
+ }
382
+ const log = (event, summary, detail) => logLine(config.logPath, event, summary, detail)
383
+ log('plugin-loaded', `enabled=${config.enabled}`, `activationPreset=${config.activationPreset} dryRun=${config.dryRun}`)
384
+ if (config.modelFallback) {
385
+ log('error', 'warn:model-fallback-ignored', 'modelFallback 本期不改变判定行为(模型裁决路径需另立 spec)')
386
+ }
387
+
388
+ const services = {
389
+ presetOf: (session) => ctx.get('permissionPresets')?.current?.(session),
390
+ workspaceRootOf: (session) => ctx.get('sandboxPolicy')?.resolve?.({ session })?.workspaceRoot,
391
+ approvalOverrideOf: (session) => ctx.get('approval')?.overrideOf?.(session),
392
+ getSession: (id) => ctx.get('sessions')?.get?.(id),
393
+ }
394
+
395
+ // 按 callId 记住最近一次工具调用的参数:真实运行态里审批请求附带的对象不暴露 session.events,
396
+ // 无法按 callId 回查会话日志(探针实测 hasEvents=false),所以白名单升权自动放行要依赖这份记忆。
397
+ const recentCallArgs = new Map()
398
+ const REMEMBERED_CALLS_MAX = 200
399
+ const rememberCall = (exec) => {
400
+ try {
401
+ if (typeof exec?.callId !== 'string') return
402
+ recentCallArgs.set(exec.callId, exec.arguments)
403
+ if (recentCallArgs.size > REMEMBERED_CALLS_MAX) {
404
+ recentCallArgs.delete(recentCallArgs.keys().next().value)
405
+ }
406
+ } catch {
407
+ /* 参数不可读时不记(不影响判定) */
408
+ }
409
+ }
410
+
411
+ ctx.inject(['systemPrompt'], (scope) => {
412
+ scope.systemPrompt.context({
413
+ name: 'path-rules',
414
+ order: 111,
415
+ text: (context) => renderRules(config, context?.agent?.session, services),
416
+ })
417
+ })
418
+
419
+ // 白名单目标的沙箱升权请求自动放行(承接已退役的 sandbox-path-allowlist 能力):
420
+ // 只作答 "escalate sandbox to " 开头的请求,且目标必须命中白名单;其余一律交给下游弹框。
421
+ ctx.on('approval/request', (request, next) => {
422
+ if (typeof request?.callId !== 'string') return next()
423
+ if (typeof request.reason !== 'string' || !request.reason.startsWith(SANDBOX_ESCALATION_PREFIX)) return next()
424
+ try {
425
+ const remembered = recentCallArgs.get(request.callId)
426
+ const session = request.agent?.session
427
+ // remembered 优先:真实运行态里审批请求附带的 session 不暴露 events,无法按 callId 回查
428
+ // 会话日志(实测 hasEvents=false),只有 pre-execute 时记下的参数可靠。
429
+ const target = escalationTargetFromArgs(remembered) ?? escalationTargetOf(session, request.callId)
430
+ if (target === undefined || !escalationIsWhitelisted(target, config, session)) return next()
431
+ // remembered 只进审计日志,便于区分"走记忆命中"与"走会话日志兜底命中"
432
+ log('allow', 'whitelist-escalation', `${request.callId} remembered=${remembered !== undefined} ${target.value}`)
433
+ return 'allowed-once'
434
+ } catch (error) {
435
+ log('error', 'approval-error', String(error))
436
+ return next()
437
+ }
438
+ }, { prepend: true })
439
+
440
+ ctx.on('tools/pre-execute', async (exec, next) => {
441
+ rememberCall(exec)
442
+ // 先取下游结论:既满足 waterfall"调用 next 才不否决整条链"的语义,又能做"更严者胜"合成
443
+ let downstream
444
+ try {
445
+ downstream = await next()
446
+ } catch (error) {
447
+ // 下游监听器自身抛错:记录后原样抛给框架,不在这里替它做决定
448
+ log('error', 'downstream-error', String(error))
449
+ throw error
450
+ }
451
+ let decision
452
+ try {
453
+ decision = decideCall(exec, config, services)
454
+ } catch (error) {
455
+ log('error', 'gate-error', `${String(error)} | ${describeCall(exec)}`)
456
+ if (config.failMode === 'closed') {
457
+ return {
458
+ kind: 'deny',
459
+ reason: `路径规则闸门内部错误(fail-closed):${String(error)};可切换权限档或停用本插件后重试`,
460
+ }
461
+ }
462
+ return downstream
463
+ }
464
+ return compose(downstream, decision, config, log, exec)
465
+ })
466
+ }
package/lib/rules.mjs ADDED
@@ -0,0 +1,139 @@
1
+ // dsh-path-rules 的路径语义层(纯函数,无插件依赖)。
2
+ //
3
+ // 设计要点(spec §5.1):
4
+ // * normalizePath 只对「存在的最深前缀」做 realpath,剩余段原样拼回:
5
+ // 既不放过"项目内软链指向项目外"的逃逸,也不要求目标本身已存在
6
+ // (write 创建新文件时目标通常还不存在)。
7
+ // * underRoot 是词法前缀判断:target 等于 root,或以 `root + sep` 开头;
8
+ // 同名前缀的兄弟目录(/proj/abc vs /proj/ab)不算在其下。
9
+
10
+ import { realpathSync } from 'node:fs'
11
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
12
+
13
+ /** realpath 一个路径;不存在/无权限时返回 undefined(不抛)。 */
14
+ function tryRealpath(path) {
15
+ try {
16
+ return realpathSync.native(path)
17
+ } catch {
18
+ return undefined
19
+ }
20
+ }
21
+
22
+ /**
23
+ * 把任意路径写法归一化成可用于判定比较的绝对路径。
24
+ *
25
+ * 目标存在 ⇒ 直接 realpath;目标不存在 ⇒ 向上找到最深已存在祖先做 realpath,
26
+ * 再把中间的路径段原样拼回(这样符号链接目录下的新文件也能按真实位置判定)。
27
+ *
28
+ * @param raw - 原始路径(可为相对路径);空/空白返回空串。
29
+ * @param cwd - 相对路径的解析基准(会话工作目录)。
30
+ * @returns 归一化后的绝对路径。
31
+ */
32
+ export function normalizePath(raw, cwd) {
33
+ const text = String(raw ?? '').trim()
34
+ if (text.length === 0) return ''
35
+ const absolute = isAbsolute(text) ? text : resolve(cwd ?? process.cwd(), text)
36
+ const real = tryRealpath(absolute)
37
+ if (real !== undefined) return real
38
+ const tail = []
39
+ let current = absolute
40
+ for (;;) {
41
+ const parent = dirname(current)
42
+ if (parent === current) return absolute
43
+ tail.unshift(basename(current))
44
+ const parentReal = tryRealpath(parent)
45
+ if (parentReal !== undefined) return join(parentReal, ...tail)
46
+ current = parent
47
+ }
48
+ }
49
+
50
+ /**
51
+ * 判断 target 是否位于 root 之下(含相等)。
52
+ *
53
+ * @param target - 已归一化的目标路径。
54
+ * @param root - 已归一化的根路径。
55
+ * @returns 同名前缀的兄弟目录返回 false。
56
+ */
57
+ export function underRoot(target, root) {
58
+ if (typeof target !== 'string' || typeof root !== 'string') return false
59
+ if (target.length === 0 || root.length === 0) return false
60
+ if (target === root) return true
61
+ const boundary = root.endsWith(sep) ? root : root + sep
62
+ return target.startsWith(boundary)
63
+ }
64
+
65
+ /**
66
+ * 把配置里的一串路径归一化成根列表:逐项 normalizePath、丢弃空白项、去重保序。
67
+ *
68
+ * @param paths - 配置里的绝对路径数组(可为空/非数组)。
69
+ * @param cwd - 相对路径的解析基准(正常配置要求绝对路径,此参数只作兜底)。
70
+ * @returns 归一化后的根列表。
71
+ */
72
+ export function normalizeRoots(paths, cwd) {
73
+ const list = Array.isArray(paths) ? paths : []
74
+ const roots = []
75
+ for (const item of list) {
76
+ const normalized = normalizePath(item, cwd)
77
+ if (normalized.length > 0 && !roots.includes(normalized)) roots.push(normalized)
78
+ }
79
+ return roots
80
+ }
81
+
82
+ /** 目标是否命中根列表中的任一根(词法前缀,含相等)。 */
83
+ function matchesAnyRoot(target, list) {
84
+ return Array.isArray(list) && list.some((root) => underRoot(target, root))
85
+ }
86
+
87
+ /**
88
+ * 判定目标落在哪一类,顺序固定:黑名单 → 白名单 → 项目内 → 临时区 → 项目外。
89
+ * 「先命中者胜」是 spec §4.2 的硬约定:黑名单压过一切(含项目内与白名单)。
90
+ *
91
+ * @param target - 已归一化的目标路径。
92
+ * @param roots - { project, whitelist, blacklist, temp }。
93
+ * @returns 'blacklist' | 'whitelist' | 'project' | 'temp' | 'outside'。
94
+ */
95
+ export function classifyPath(target, roots) {
96
+ const r = roots ?? {}
97
+ if (matchesAnyRoot(target, r.blacklist)) return 'blacklist'
98
+ if (matchesAnyRoot(target, r.whitelist)) return 'whitelist'
99
+ if (typeof r.project === 'string' && r.project.length > 0 && underRoot(target, r.project)) return 'project'
100
+ if (matchesAnyRoot(target, r.temp)) return 'temp'
101
+ return 'outside'
102
+ }
103
+
104
+ /**
105
+ * 单目标决策:黑名单 ⇒ ask;项目外且写 ⇒ ask;其余 ⇒ allow。
106
+ *
107
+ * @param target - 已归一化的目标路径。
108
+ * @param effect - 'read' | 'write'。
109
+ * @param roots - 同 classifyPath。
110
+ * @returns { kind: 'allow'|'ask', code?: ReasonCode, reason?: string }。
111
+ */
112
+ export function decideTarget(target, effect, roots) {
113
+ const kind = classifyPath(target, roots)
114
+ if (kind === 'blacklist') {
115
+ return { kind: 'ask', code: 'BLACKLIST', reason: `目标命中黑名单:${target}` }
116
+ }
117
+ if (kind === 'outside' && effect === 'write') {
118
+ return { kind: 'ask', code: 'PROJECT_OUTSIDE_WRITE', reason: `项目外写入:${target}` }
119
+ }
120
+ return { kind: 'allow' }
121
+ }
122
+
123
+ /**
124
+ * 多目标合成:最严者胜(任一 ask 则整体 ask)。
125
+ *
126
+ * @param decisions - decideTarget 的结果列表。
127
+ * @returns { kind: 'allow'|'ask', codes: ReasonCode[] }(codes 去重保序,仅收 ask 的)。
128
+ */
129
+ export function combine(decisions) {
130
+ const list = Array.isArray(decisions) ? decisions : []
131
+ const codes = []
132
+ let ask = false
133
+ for (const decision of list) {
134
+ if (decision?.kind !== 'ask') continue
135
+ ask = true
136
+ if (decision.code !== undefined && !codes.includes(decision.code)) codes.push(decision.code)
137
+ }
138
+ return ask ? { kind: 'ask', codes } : { kind: 'allow', codes: [] }
139
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Path rules guard",
4
+ "description": "Read/write inside the session workspace; reads outside allowed, writes outside require approval; allowlist bypasses; denylist always asks."
5
+ }
6
+ }