@cfitsec/dsh-ast-guard 0.0.0-stage → 2.0.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/LICENSE +21 -0
- package/README.md +172 -2
- package/cordis.patch.yml +25 -0
- package/docs//347/273/264/346/212/244/350/247/204/345/210/231.md +217 -0
- package/lib/ast-daemon.ps1 +138 -0
- package/lib/daemon.js +185 -0
- package/lib/deliberate.js +275 -0
- package/lib/evidence.js +121 -0
- package/lib/index.js +322 -0
- package/lib/rules.js +480 -0
- package/package.json +60 -4
- package/rules/default.yaml +210 -0
- package/test/hook.mjs +96 -0
- package/test/import-check.mjs +31 -0
- package/test/smoke.mjs +109 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// dsh-ast-guard —— 把 pwsh / powershell 命令交给真正的语法树判定。
|
|
2
|
+
//
|
|
3
|
+
// 这是一个**独立**的守卫层:不依赖任何其它规则层,规则全部在 rulesFile 指向的 YAML 里。
|
|
4
|
+
// 命令送进随宿主启动的常驻 PowerShell 解析器,按**节点**判定(不是文本匹配)。
|
|
5
|
+
import { statSync } from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { sharedDaemon } from './daemon.js'
|
|
9
|
+
import { DeliberateDesk } from './deliberate.js'
|
|
10
|
+
import { parseRulesFile, evaluate, buildDenialMessage, buildDeliberateMessage, evaluateToolParams, buildToolDenialMessage } from './rules.js'
|
|
11
|
+
import { collectEvidence, evidenceCwds } from './evidence.js'
|
|
12
|
+
|
|
13
|
+
export const name = 'dsh-ast-guard'
|
|
14
|
+
export const inject = ['tools']
|
|
15
|
+
|
|
16
|
+
const SHELL_TOOLS = new Set(['pwsh', 'powershell'])
|
|
17
|
+
|
|
18
|
+
const DEFAULTS = {
|
|
19
|
+
// null = 用**包内自带**的 rules/default.yaml。
|
|
20
|
+
// 2026-10-27 定的:规则文件的位置与**插件安装位置**保持一致 ——
|
|
21
|
+
// 装上就有一套能跑的规则,不需要手动把文件搬到哪里去。
|
|
22
|
+
// 想用自己的规则:在 config 里写路径(相对会话 cwd,或绝对路径)。
|
|
23
|
+
rulesFile: null,
|
|
24
|
+
enabled: true,
|
|
25
|
+
failPolicy: 'closed',
|
|
26
|
+
onRuleError: 'closed',
|
|
27
|
+
timeoutMs: 5000,
|
|
28
|
+
powershell: 'powershell.exe',
|
|
29
|
+
yamlPath: null,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 包内默认规则:`<包>/rules/default.yaml` —— 与插件安装位置一致。 */
|
|
33
|
+
function defaultRulesFile() {
|
|
34
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'rules', 'default.yaml')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function apply(ctx, config) {
|
|
38
|
+
const options = { ...DEFAULTS, ...(config ?? {}) }
|
|
39
|
+
if (options.enabled !== true) {
|
|
40
|
+
ctx.logger?.info?.('ast-guard: enabled=false,本层不生效')
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 进程级单例:宿主会反复 apply(),不共享的话每个实例都会拉一个常驻 powershell(实测漏了一个)。
|
|
45
|
+
const daemon = sharedDaemon({
|
|
46
|
+
powershell: options.powershell,
|
|
47
|
+
timeoutMs: options.timeoutMs,
|
|
48
|
+
logger: ctx.logger,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// 随宿主启动,但不阻塞启动流程:第一次真正要用时再等它就绪,那时通常早就好了。
|
|
52
|
+
// 单例之后重复 apply 也不再重新拉起(start() 自己会判 running)。
|
|
53
|
+
daemon.start().catch((error) => {
|
|
54
|
+
ctx.logger?.warn?.(`ast-guard: 解析器启动失败,将在第一次用到时重试:${error.message}`)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// 三思台:冷却期状态、一次性放行都在这儿。按会话隔离 —— 同一会话同一时刻只留一条待确认。
|
|
58
|
+
const desk = new DeliberateDesk()
|
|
59
|
+
registerConfirmTool(ctx, desk)
|
|
60
|
+
|
|
61
|
+
// 规则按【绝对路径 + mtime】缓存:改文件立即生效,没改就不重复解析。
|
|
62
|
+
const cache = new Map()
|
|
63
|
+
|
|
64
|
+
async function rulesFor(cwd) {
|
|
65
|
+
const configured = options.rulesFile
|
|
66
|
+
const isDefault = configured === null || configured === undefined || configured === ''
|
|
67
|
+
const file = isDefault
|
|
68
|
+
? defaultRulesFile()
|
|
69
|
+
: (path.isAbsolute(configured) ? configured : path.join(cwd, configured))
|
|
70
|
+
|
|
71
|
+
let mtimeMs
|
|
72
|
+
try {
|
|
73
|
+
mtimeMs = statSync(file).mtimeMs
|
|
74
|
+
} catch {
|
|
75
|
+
// 两种"没有文件",含义务不相同:
|
|
76
|
+
// · 配了路径但没有文件 ⇒ 这个工作区没配规则 ⇒ **无意见**(不是错误)
|
|
77
|
+
// · **包内默认**规则都没有 ⇒ 安装不完整 ⇒ 当**错误**(走 onRuleError=closed ⇒ 拒绝并说明)
|
|
78
|
+
// —— "失败一律响"是这套东西的底线,默认规则缺失绝不能静默放行。
|
|
79
|
+
if (isDefault) return { file, rules: null, error: `包内默认规则不存在:${file}(安装不完整?)`, missing: false }
|
|
80
|
+
return { file, rules: null, error: null, missing: true }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const cached = cache.get(file)
|
|
84
|
+
if (cached !== undefined && cached.mtimeMs === mtimeMs) return cached
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const rules = await parseRulesFile(file, { yamlPath: options.yamlPath })
|
|
88
|
+
const entry = { file, mtimeMs, rules, error: null, missing: false }
|
|
89
|
+
cache.set(file, entry)
|
|
90
|
+
return entry
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// 坏的规则不顶掉上一份可用的,但必须报出来 ——
|
|
93
|
+
// 文本层那条"规则写坏即静默失效"是这套系统里最贵的教训,不在这里重演。
|
|
94
|
+
ctx.logger?.error?.(`ast-guard: 规则文件读不了(${file}):${error.message}`)
|
|
95
|
+
const entry = { file, mtimeMs, rules: cached?.rules ?? null, error: error.message, missing: false }
|
|
96
|
+
cache.set(file, entry)
|
|
97
|
+
return entry
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
102
|
+
const decision = await check(exec)
|
|
103
|
+
if (decision !== null) return decision
|
|
104
|
+
return next()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
async function check(exec) {
|
|
108
|
+
const tool = String(exec?.name ?? '')
|
|
109
|
+
const isShell = SHELL_TOOLS.has(tool)
|
|
110
|
+
const command = exec?.arguments?.command
|
|
111
|
+
if (isShell && (typeof command !== 'string' || command.trim() === '')) return null
|
|
112
|
+
|
|
113
|
+
const cwd = exec?.agent?.session?.header?.cwd ?? process.cwd()
|
|
114
|
+
const entry = await rulesFor(cwd)
|
|
115
|
+
if (entry.missing === true) return null
|
|
116
|
+
|
|
117
|
+
// 非 shell 工具(edit / write…):只查「工具参数规则」,不经过解析器(2026-10-27 · 独立化 P1)。
|
|
118
|
+
// ⚠️ 这一支**规则文件坏了就放行**(fail open):否则会连 edit 一起挡住,而"改规则文件"只能靠 edit
|
|
119
|
+
// —— 那是自锁。坏文件本身已经有 error 日志;shell 那一支仍然是 fail closed。
|
|
120
|
+
if (!isShell) {
|
|
121
|
+
if (entry.rules === null) return null
|
|
122
|
+
const hit = evaluateToolParams(entry.rules, tool, exec?.arguments ?? {})
|
|
123
|
+
if (hit === null) return null
|
|
124
|
+
return { kind: 'deny', reason: buildToolDenialMessage({ rules: entry.rules, hit }) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (entry.error !== null && entry.rules === null) {
|
|
128
|
+
if (options.onRuleError !== 'closed') return null
|
|
129
|
+
return { kind: 'deny', reason: ruleErrorMessage(entry) }
|
|
130
|
+
}
|
|
131
|
+
if (entry.rules === null) return null
|
|
132
|
+
|
|
133
|
+
let facts
|
|
134
|
+
try {
|
|
135
|
+
facts = await daemon.parse(command)
|
|
136
|
+
} catch (error) {
|
|
137
|
+
ctx.logger?.error?.(`ast-guard: 解析器不可用:${error.message}`)
|
|
138
|
+
if (options.failPolicy !== 'closed') return null
|
|
139
|
+
return { kind: 'deny', reason: unavailableMessage(error, entry.file) }
|
|
140
|
+
}
|
|
141
|
+
if (facts.fatal !== undefined) {
|
|
142
|
+
ctx.logger?.error?.(`ast-guard: 解析失败:${facts.fatal}`)
|
|
143
|
+
if (options.failPolicy !== 'closed') return null
|
|
144
|
+
return { kind: 'deny', reason: unavailableMessage(new Error(facts.fatal), entry.file) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 顺序是刻意的:**彻底拒绝优先于三思**。
|
|
148
|
+
// 一条命令里既有"没有正当用途的写法"又有"贵但正当的写法"时,直接拒 —— 三思是给后者的。
|
|
149
|
+
const hit = evaluate(entry.rules, facts)
|
|
150
|
+
if (hit !== null) {
|
|
151
|
+
return { kind: 'deny', reason: buildDenialMessage({ rules: entry.rules, hit, facts, command }) }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const sessionId = String(exec?.agent?.session?.header?.id ?? cwd)
|
|
155
|
+
const verdict = desk.check({ sessionId, command, facts, rules: entry.rules })
|
|
156
|
+
if (verdict === null) return null
|
|
157
|
+
if (verdict.action === 'allow-once') {
|
|
158
|
+
ctx.logger?.info?.(`ast-guard: 三思一次性放行(${verdict.ruleId})`)
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
// 2026-10-10:进冷却**之前**先跑规则里声明的只读探针。
|
|
162
|
+
// 窗口里没有「要丢什么」的清单,加多少条规则都拦不住"回退范围扩张"那类事 ——
|
|
163
|
+
// 因为决定要不要停的信息不在命令文本里(`git checkout -- .` 看起来完全无害),
|
|
164
|
+
// 在**工作区状态**里(`?? src/lab/` = 644 行从未提交,reflog 救不回)。
|
|
165
|
+
const evidence = await collectEvidence({
|
|
166
|
+
probes: verdict.pending.rule.evidence,
|
|
167
|
+
cwds: evidenceCwds(cwd, facts),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
kind: 'deny',
|
|
172
|
+
reason: buildDeliberateMessage({
|
|
173
|
+
rules: entry.rules,
|
|
174
|
+
rule: verdict.pending.rule,
|
|
175
|
+
hits: verdict.pending.hits,
|
|
176
|
+
facts,
|
|
177
|
+
evidence,
|
|
178
|
+
cooldownSeconds: verdict.pending.rule.cooldownSeconds,
|
|
179
|
+
pendingId: verdict.pending.id,
|
|
180
|
+
}),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function registerConfirmTool(ctx, desk) {
|
|
186
|
+
ctx.tools.register({
|
|
187
|
+
name: 'ast_confirm',
|
|
188
|
+
description: [
|
|
189
|
+
'Answer the ast-guard deliberation prompt for a command that was held for a cooling-off period.',
|
|
190
|
+
'You MUST pass the id shown in that prompt: a bare "yes" is rejected, so that a confirmation can',
|
|
191
|
+
'never land on a command you have forgotten about. During the window EVERY answer is rejected —',
|
|
192
|
+
'yes and no alike — and the call comes back with the seconds still left, so you can keep re-reading',
|
|
193
|
+
'the syntax tree. Only an answer given after the window is accepted.',
|
|
194
|
+
'A "yes" is one-shot, bound to that exact command, and expires if unused.',
|
|
195
|
+
].join(' '),
|
|
196
|
+
parameters: {
|
|
197
|
+
type: 'object',
|
|
198
|
+
additionalProperties: false,
|
|
199
|
+
properties: {
|
|
200
|
+
id: {
|
|
201
|
+
type: 'string',
|
|
202
|
+
description: 'The pending id shown in the deliberation prompt. Required — a confirmation without it is rejected.',
|
|
203
|
+
},
|
|
204
|
+
decision: {
|
|
205
|
+
type: 'string',
|
|
206
|
+
enum: ['yes', 'no'],
|
|
207
|
+
description: 'yes = allow this one execution; no = give it up.',
|
|
208
|
+
},
|
|
209
|
+
command: {
|
|
210
|
+
type: 'string',
|
|
211
|
+
description: 'Optional. The exact command you are confirming; when given it must match the held command.',
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
required: ['id', 'decision'],
|
|
215
|
+
},
|
|
216
|
+
output: {
|
|
217
|
+
schema: {
|
|
218
|
+
type: 'object',
|
|
219
|
+
additionalProperties: false,
|
|
220
|
+
properties: {
|
|
221
|
+
accepted: { type: 'boolean' },
|
|
222
|
+
reason: { type: 'string' },
|
|
223
|
+
remainingSeconds: { type: 'number' },
|
|
224
|
+
message: { type: 'string' },
|
|
225
|
+
},
|
|
226
|
+
required: ['accepted', 'message'],
|
|
227
|
+
},
|
|
228
|
+
render: (_args, value) => [{ type: 'text', text: value.message }],
|
|
229
|
+
},
|
|
230
|
+
async execute(args = {}, exec) {
|
|
231
|
+
const sessionId = String(exec?.agent?.session?.header?.id ?? '')
|
|
232
|
+
const result = desk.confirm({
|
|
233
|
+
sessionId,
|
|
234
|
+
id: args.id,
|
|
235
|
+
decision: args.decision,
|
|
236
|
+
command: typeof args.command === 'string' ? args.command : undefined,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
if (result.ok) {
|
|
240
|
+
if (result.decision === 'no') return { accepted: true, reason: 'no', message: result.message }
|
|
241
|
+
return {
|
|
242
|
+
accepted: true,
|
|
243
|
+
reason: 'yes',
|
|
244
|
+
message: [
|
|
245
|
+
`确认已接受(编号 ${result.id})。这条命令的一次性放行已生效。`,
|
|
246
|
+
`用掉或过期即作废,不会留到下一次。`,
|
|
247
|
+
'',
|
|
248
|
+
'你批准的是下面这一条 —— 再读一遍,确认它确实是你想的那个:',
|
|
249
|
+
...result.command.split('\n').map((line) => ` ${line}`),
|
|
250
|
+
'',
|
|
251
|
+
'现在可以重新执行它了。',
|
|
252
|
+
'(放行只针对这一条命令本身 —— 不要把它当成"类似的事也可以做"。)',
|
|
253
|
+
].join('\n'),
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (result.reason === 'too-early') {
|
|
258
|
+
const lines = [
|
|
259
|
+
`回答无效:冷却期还没结束,还剩 ${result.remainingSeconds} 秒。`,
|
|
260
|
+
'这段时间里回答"是"和回答"否"一样都无效 —— 这是故意的。',
|
|
261
|
+
`(这一条的编号是 ${result.pending.id}。)`,
|
|
262
|
+
'',
|
|
263
|
+
'待确认的命令:',
|
|
264
|
+
...result.pending.command.split('\n').map((line) => ` ${line}`),
|
|
265
|
+
'',
|
|
266
|
+
'语法树看到的:',
|
|
267
|
+
...result.pending.hits.slice(0, 4).flatMap((hit) => [
|
|
268
|
+
` - 命令:${hit.command}`,
|
|
269
|
+
...hit.reasons.slice(0, 2).map((reason) => ` 触发原因:${reason}`),
|
|
270
|
+
]),
|
|
271
|
+
'',
|
|
272
|
+
'再拿那两个问题过一遍,别只往"别做"一个方向想:',
|
|
273
|
+
' ① 必要性 —— 这件事非做不可吗?不做会怎样?有没有更便宜的做法?',
|
|
274
|
+
' ② 安全性 —— 目标和范围对不对?会不会连累不该碰的东西?出错能不能回退?',
|
|
275
|
+
'(确认有必要且安全,就在到点后确认执行 —— 那是正常路径。)',
|
|
276
|
+
'(别为了绕开这段等待去换个写法做同一件事 —— 那同样是绕道。)',
|
|
277
|
+
'(到时间后再调用一次 ast_confirm。)',
|
|
278
|
+
]
|
|
279
|
+
return {
|
|
280
|
+
accepted: false,
|
|
281
|
+
reason: 'too-early',
|
|
282
|
+
remainingSeconds: result.remainingSeconds,
|
|
283
|
+
message: lines.join('\n'),
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { accepted: false, reason: result.reason, message: result.message }
|
|
288
|
+
},
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function ruleErrorMessage(entry) {
|
|
293
|
+
return [
|
|
294
|
+
'[ast-guard] 拒绝:AST 规则文件读不了',
|
|
295
|
+
'',
|
|
296
|
+
`文件:${entry.file}`,
|
|
297
|
+
`错误:${entry.error}`,
|
|
298
|
+
'',
|
|
299
|
+
'为什么读不了就拒绝,而不是放行:规则写坏时静默放行是这套系统里最危险的失效模式 ——',
|
|
300
|
+
'文本层就栽在这上面(watcher 吞掉编译错误,只留一份宿主日志,谁也看不见)。',
|
|
301
|
+
'',
|
|
302
|
+
'修法:用 edit 工具改这个文件。edit / write 不受本层影响,不会被一起挡住。',
|
|
303
|
+
'改好之后下一条命令就会用上新规则(按 mtime 热生效,不用重启)。',
|
|
304
|
+
].join('\n')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function unavailableMessage(error, file) {
|
|
308
|
+
return [
|
|
309
|
+
'[ast-guard] 拒绝:语法树解析器不可用',
|
|
310
|
+
'',
|
|
311
|
+
`原因:${error.message}`,
|
|
312
|
+
`规则文件:${file}`,
|
|
313
|
+
`当前 failPolicy = closed(解析器不可用时拒绝)`,
|
|
314
|
+
'',
|
|
315
|
+
'为什么不可用也拒绝:文本层有已知盲区(引号里以 \\ 结尾会让分词器错位、多行命令的 glob 不跨行、',
|
|
316
|
+
'写动词当参数交给启动器时看不见)。这一层挡不住的东西,那些盲区也挡不住 —— 放行等于没装。',
|
|
317
|
+
'',
|
|
318
|
+
'解析器是随宿主启动的常驻 PowerShell 进程;它连续重启失败会进入降温,不再反复拉起。',
|
|
319
|
+
'要临时放行:把插件配置里的 failPolicy 改成 open(改完需重启宿主);',
|
|
320
|
+
'或者改用 edit / write / Copy-Item 完成任务 —— 它们不经过本层。',
|
|
321
|
+
].join('\n')
|
|
322
|
+
}
|