@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/rules.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// AST 规则层:读规则文件,对解析器给出的语法树事实做判定,并生成拒绝文案。
|
|
2
|
+
//
|
|
3
|
+
// 分工是刻意的:PowerShell 侧只说"事实"(有哪些命令、哪些成员调用、哪些重定向),
|
|
4
|
+
// 策略全在这里。换策略不用碰解析器,规则文件是唯一的策略来源。
|
|
5
|
+
//
|
|
6
|
+
// 失败一律"响":规则文件读坏时默认拒绝并说明原因,不学文本层那种静默失效
|
|
7
|
+
// (那条教训写在 AGENTS.md 里:watcher 吞掉编译错误,只留一份宿主日志,谁也看不见)。
|
|
8
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
9
|
+
import { pathToFileURL } from 'node:url'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { MIN_COOLDOWN_SECONDS, matchCondition } from './deliberate.js'
|
|
12
|
+
|
|
13
|
+
let yamlModule = null
|
|
14
|
+
|
|
15
|
+
/** 找 yaml 模块:先按常规解析,再退回 profile 的 node_modules(本地 link: 包的标准做法)。 */
|
|
16
|
+
export async function loadYaml(explicitPath) {
|
|
17
|
+
if (yamlModule !== null) return yamlModule
|
|
18
|
+
const candidates = []
|
|
19
|
+
if (explicitPath) candidates.push(explicitPath)
|
|
20
|
+
candidates.push(null)
|
|
21
|
+
const home = process.env.DSH_HOME
|
|
22
|
+
if (home) candidates.push(path.join(home, 'profiles', 'web', 'node_modules', 'yaml', 'dist', 'index.js'))
|
|
23
|
+
|
|
24
|
+
const tried = []
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
try {
|
|
27
|
+
const mod = candidate === null ? await import('yaml') : await import(pathToFileURL(candidate).href)
|
|
28
|
+
yamlModule = mod.default ?? mod
|
|
29
|
+
return yamlModule
|
|
30
|
+
} catch (error) {
|
|
31
|
+
tried.push(`${candidate ?? 'yaml'}(${error.code ?? error.message})`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`找不到 yaml 模块,试过:${tried.join(' / ')}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function parseRulesFile(file, options = {}) {
|
|
38
|
+
const YAML = await loadYaml(options.yamlPath)
|
|
39
|
+
const text = readFileSync(file, 'utf8')
|
|
40
|
+
let doc
|
|
41
|
+
try {
|
|
42
|
+
doc = YAML.parse(text)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new Error(`YAML 解析失败:${error.message}`)
|
|
45
|
+
}
|
|
46
|
+
return compileRules(doc, file)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function compileRules(doc, source) {
|
|
50
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
51
|
+
throw new Error('规则文件的顶层必须是映射(version / deny / deliberate)')
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
source,
|
|
55
|
+
deny: compileSection(doc.deny, 'deny', source, compileDeny),
|
|
56
|
+
deliberate: compileSection(doc.deliberate, 'deliberate', source, compileDeliberate),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** enabled: false 的条目整条跳过 —— 与文本层 rules.yaml 的约定一致。 */
|
|
61
|
+
function compileSection(raw, field, source, compile) {
|
|
62
|
+
if (raw === undefined) return []
|
|
63
|
+
if (!Array.isArray(raw)) throw new Error(`${field} 必须是列表`)
|
|
64
|
+
return raw
|
|
65
|
+
.map((entry, index) => (entry !== null && typeof entry === 'object' && entry.enabled === false ? null : compile(entry, source, index)))
|
|
66
|
+
.filter((entry) => entry !== null)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function compileDeny(entry, source, index) {
|
|
70
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
71
|
+
throw new Error(`deny[${index}] 必须是映射`)
|
|
72
|
+
}
|
|
73
|
+
const id = String(entry.id ?? '').trim()
|
|
74
|
+
if (id === '') throw new Error(`deny[${index}] 缺 id`)
|
|
75
|
+
|
|
76
|
+
const asSet = (value, field) => {
|
|
77
|
+
if (value === undefined) return new Set()
|
|
78
|
+
if (!Array.isArray(value)) throw new Error(`deny[${id}].${field} 必须是列表`)
|
|
79
|
+
return new Set(value.map((item) => String(item).toLowerCase()))
|
|
80
|
+
}
|
|
81
|
+
const commands = asSet(entry.commands, 'commands')
|
|
82
|
+
const memberNames = asSet(entry.memberNames, 'memberNames')
|
|
83
|
+
const memberTargetContains = entry.memberTargetContains === undefined
|
|
84
|
+
? []
|
|
85
|
+
: (Array.isArray(entry.memberTargetContains) ? entry.memberTargetContains : [entry.memberTargetContains]).map(String)
|
|
86
|
+
const redirects = entry.redirects === true
|
|
87
|
+
|
|
88
|
+
// 逐命令参数条件(2026-10-21 加):命令名命中之后,还要看**这条命令自己的参数**。
|
|
89
|
+
// 起因:`node` 的内联规则在文本层只能按"整行参数池"判 ⇒ `adb … screencap -p` 的 `-p`、
|
|
90
|
+
// `gradle -p` 的 `-p` 都会把同行的 `node 脚本` 一起拖下水(两条已知误报)。
|
|
91
|
+
// 语法树层知道参数属于哪条命令 ⇒ 条件只对**同一个命令节点**求值,串场不可能发生。
|
|
92
|
+
// 注:条件编译复用了三思层那套(flags / wildcard / paths / subcommands / argsExact / all),
|
|
93
|
+
// 报错文案里会写成 `deliberate[...]`(同一个函数),规则作者按行号定位即可。
|
|
94
|
+
let when = null
|
|
95
|
+
if (entry.when !== undefined && entry.when !== null) {
|
|
96
|
+
when = compileCondition(entry.when, id, 'when')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2026-10-27(独立化 P1):吸收文本层那三类规则 ——
|
|
100
|
+
// ① tools:非 shell 工具(edit / write…)的目标路径,例:记忆文件逻辑只读;
|
|
101
|
+
// ② paths:与 commands 做 AND —— 受保护路径上的删除/改名;
|
|
102
|
+
// ③ mentions:提及即停(目录名约定 `切勿执行` / `DO-NOT-RUN`)。
|
|
103
|
+
// needle 一律**大小写不敏感**(Windows 路径本来不分大小写),但保留原样存着,便于文案展示。
|
|
104
|
+
const asList = (value, field) => {
|
|
105
|
+
if (value === undefined) return []
|
|
106
|
+
if (!Array.isArray(value)) throw new Error(`deny[${id}].${field} 必须是列表`)
|
|
107
|
+
return value.map((item) => String(item)).filter((item) => item !== '')
|
|
108
|
+
}
|
|
109
|
+
const tools = asSet(entry.tools, 'tools')
|
|
110
|
+
const paths = asList(entry.paths, 'paths')
|
|
111
|
+
const mentions = asList(entry.mentions, 'mentions')
|
|
112
|
+
if (tools.size > 0 && paths.length === 0 && mentions.length === 0) {
|
|
113
|
+
throw new Error(`deny[${id}] 配了 tools 但既没有 paths 也没有 mentions —— 那样什么都匹配不到`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (commands.size === 0 && memberNames.size === 0 && !redirects && when === null
|
|
117
|
+
&& tools.size === 0 && paths.length === 0 && mentions.length === 0) {
|
|
118
|
+
throw new Error(`deny[${id}] 什么都没配(commands / memberNames / redirects / when / tools / paths / mentions 至少要有一个)`)
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
id,
|
|
122
|
+
why: String(entry.why ?? '').trim(),
|
|
123
|
+
commands,
|
|
124
|
+
memberNames,
|
|
125
|
+
memberTargetContains,
|
|
126
|
+
redirects,
|
|
127
|
+
when,
|
|
128
|
+
tools,
|
|
129
|
+
paths,
|
|
130
|
+
mentions,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 非 shell 工具的规则求值(2026-10-27 · 独立化 P1)。
|
|
136
|
+
* 这一支**不经过解析器**:只看工具参数里的路径与字串。
|
|
137
|
+
* ⚠️ 路径 needle 只比**路径型参数**(`file_path` / `path` / `dir` …)——
|
|
138
|
+
* 连 `content` 一起比的话,写一份"提到 MEMORY.md"的备忘也会被拦下(那是真误伤)。
|
|
139
|
+
*/
|
|
140
|
+
const PATH_KEY = /^(file_path|path|notebook_path|directory|dir|target|dest|destination|output)$/i
|
|
141
|
+
|
|
142
|
+
export function evaluateToolParams(rules, tool, args) {
|
|
143
|
+
const lowerTool = String(tool ?? '').toLowerCase()
|
|
144
|
+
const pathValues = []
|
|
145
|
+
const allStrings = []
|
|
146
|
+
for (const [key, value] of Object.entries(args ?? {})) {
|
|
147
|
+
if (typeof value !== 'string' || value === '') continue
|
|
148
|
+
allStrings.push({ key, value })
|
|
149
|
+
if (PATH_KEY.test(key)) pathValues.push(value)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const rule of rules.deny) {
|
|
153
|
+
if (rule.tools.size === 0 || !rule.tools.has(lowerTool)) continue
|
|
154
|
+
|
|
155
|
+
if (rule.paths.length > 0) {
|
|
156
|
+
const hit = pathValues.find((value) => rule.paths.some((needle) => value.toLowerCase().includes(needle.toLowerCase())))
|
|
157
|
+
if (hit !== undefined) return { rule, evidence: [{ kind: '目标路径', text: hit }] }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const needle of rule.mentions) {
|
|
161
|
+
const hit = allStrings.find(({ value }) => value.toLowerCase().includes(needle.toLowerCase()))
|
|
162
|
+
if (hit !== undefined) {
|
|
163
|
+
return { rule, evidence: [{ kind: '提及', text: `含「${needle}」(参数 ${hit.key}):${hit.value.slice(0, 160)}` }] }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 版本号:从包内 package.json 读,失败就退回 'unknown'。
|
|
172
|
+
* 为什么要进文案:同一台机器上可能同时存在"旧副本"和"新包",两份代码可能逐字节相同 ——
|
|
173
|
+
* 文案里带版本号,才能一眼看出**正在跑的是哪一份**(也方便用户报问题时报版本)。
|
|
174
|
+
*/
|
|
175
|
+
const VERSION = (() => {
|
|
176
|
+
try {
|
|
177
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
178
|
+
} catch {
|
|
179
|
+
return 'unknown'
|
|
180
|
+
}
|
|
181
|
+
})()
|
|
182
|
+
|
|
183
|
+
/** 统一的消息尾注:带版本号 + 规则文件路径。 */
|
|
184
|
+
function footer(rules) {
|
|
185
|
+
return `(dsh-ast-guard v${VERSION};规则来自 ${rules.source})`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 工具参数规则的拒绝文案:短、可读、必须带"该怎么做"。 */
|
|
189
|
+
export function buildToolDenialMessage({ rules, hit }) {
|
|
190
|
+
return [
|
|
191
|
+
`[ast-guard] 拒绝:${hit.rule.id}`,
|
|
192
|
+
'',
|
|
193
|
+
'命中的参数(这一条按**工具参数**判,不经过 shell):',
|
|
194
|
+
...hit.evidence.map((item) => ` - ${item.kind}:${item.text}`),
|
|
195
|
+
'',
|
|
196
|
+
hit.rule.why.trim(),
|
|
197
|
+
'',
|
|
198
|
+
footer(rules),
|
|
199
|
+
].join('\n')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function compileDeliberate(entry, source, index) {
|
|
203
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
204
|
+
throw new Error(`deliberate[${index}] 必须是映射`)
|
|
205
|
+
}
|
|
206
|
+
const id = String(entry.id ?? '').trim()
|
|
207
|
+
if (id === '') throw new Error(`deliberate[${index}] 缺 id`)
|
|
208
|
+
|
|
209
|
+
if (!Array.isArray(entry.commands) || entry.commands.length === 0) {
|
|
210
|
+
throw new Error(`deliberate[${id}].commands 必须是非空列表`)
|
|
211
|
+
}
|
|
212
|
+
const commands = new Set(entry.commands.map((item) => String(item).toLowerCase()))
|
|
213
|
+
|
|
214
|
+
const seconds = entry.cooldownSeconds === undefined ? MIN_COOLDOWN_SECONDS : Number(entry.cooldownSeconds)
|
|
215
|
+
if (!Number.isFinite(seconds) || seconds < MIN_COOLDOWN_SECONDS) {
|
|
216
|
+
throw new Error(`deliberate[${id}].cooldownSeconds 必须 ≥ ${MIN_COOLDOWN_SECONDS}(用户定的下限),收到 ${entry.cooldownSeconds}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let when = null
|
|
220
|
+
if (entry.when !== undefined && entry.when !== null) {
|
|
221
|
+
if (typeof entry.when !== 'object' || Array.isArray(entry.when)) {
|
|
222
|
+
throw new Error(`deliberate[${id}].when 必须是映射`)
|
|
223
|
+
}
|
|
224
|
+
if (entry.when.all !== undefined && entry.when.all !== null) {
|
|
225
|
+
// 合取(2026-10-10 加):列表里每组条件都要成立,组内仍是 OR。
|
|
226
|
+
// 起因:`git checkout -b x`(建分支,无害)与 `git checkout -- .`(丢整棵树)
|
|
227
|
+
// 只差一个参数 —— 单组条件分不开,必须相与。
|
|
228
|
+
if (!Array.isArray(entry.when.all) || entry.when.all.length === 0) {
|
|
229
|
+
throw new Error(`deliberate[${id}].when.all 必须是非空列表`)
|
|
230
|
+
}
|
|
231
|
+
when = {
|
|
232
|
+
all: entry.when.all.map((item, position) => compileCondition(item, id, `all[${position}]`)),
|
|
233
|
+
flags: [], wildcard: false, paths: [], subcommands: [], argsExact: [],
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
when = { ...compileCondition(entry.when, id, 'when'), all: null }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 三思窗口里的「要丢什么」:只读探针。命令文本**写死在规则里**,不是模型给的。
|
|
241
|
+
// 编译期拦一道:探针里出现写动词就直接拒绝 —— 探针必须是只读的。
|
|
242
|
+
const evidence = Array.isArray(entry.evidence) ? entry.evidence.map((item) => String(item)) : []
|
|
243
|
+
for (const probe of evidence) {
|
|
244
|
+
if (/(Remove-Item|Set-Content|Add-Content|Clear-Content|Out-File|Move-Item|Rename-Item|rmdir|del\s)/i.test(probe)) {
|
|
245
|
+
throw new Error(`deliberate[${id}].evidence 里有写动词,探针必须只读:${probe}`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { id, why: String(entry.why ?? '').trim(), commands, when, cooldownSeconds: seconds, evidence }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 一组 when 条件:组内各维 OR,至少要配一维。 */
|
|
253
|
+
function compileCondition(raw, id, where) {
|
|
254
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
255
|
+
throw new Error(`deliberate[${id}].${where} 必须是映射`)
|
|
256
|
+
}
|
|
257
|
+
const condition = {
|
|
258
|
+
flags: (raw.flags ?? []).map((flag) => String(flag).toLowerCase()),
|
|
259
|
+
wildcard: raw.wildcard === true,
|
|
260
|
+
paths: (raw.paths ?? []).map(String),
|
|
261
|
+
// 子命令:`reg add` / `git checkout` 这类**外部命令**靠第一个位置参数分。
|
|
262
|
+
// 外部命令的参数形态 PowerShell 解析器认不出来(`/v`、`/f` 都不是参数),
|
|
263
|
+
// 所以"第一个位置参数"正好就是它的子命令。
|
|
264
|
+
subcommands: (raw.subcommands ?? []).map((item) => String(item).toLowerCase()),
|
|
265
|
+
// 精确参数(2026-10-10 加):整个参数**等于**其中之一才算命中。
|
|
266
|
+
// 用来表达"目标是整棵树"这种判据 —— `git checkout -- .` 里的那个 `.`。
|
|
267
|
+
// 不能用 paths 代替:paths 是**包含**匹配,会把每个带点的参数都吃进来。
|
|
268
|
+
argsExact: (raw.argsExact ?? []).map(String),
|
|
269
|
+
// 子串匹配(2026-10-27 加,为吸收文本层的 `iex` 规则):参数里**含**这串就算。
|
|
270
|
+
// 文本层判的是"同一行里 iex 与 set-content 都出现"(前缀通配,只能覆盖几种顺序);
|
|
271
|
+
// 语法树层能精确到"**iex 自己的那个参数里**含写动词"——顺序、引号、换行都不影响。
|
|
272
|
+
argsContain: (raw.argsContain ?? []).map(String),
|
|
273
|
+
}
|
|
274
|
+
const empty = condition.flags.length === 0 && !condition.wildcard && condition.paths.length === 0
|
|
275
|
+
&& condition.subcommands.length === 0 && condition.argsExact.length === 0
|
|
276
|
+
&& condition.argsContain.length === 0
|
|
277
|
+
if (empty) {
|
|
278
|
+
throw new Error(`deliberate[${id}].${where} 什么都没配(flags / wildcard / paths / subcommands / argsExact / argsContain 至少一个)`)
|
|
279
|
+
}
|
|
280
|
+
return condition
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 判定。返回 null = 本层无意见,调用方应交给下游。 */
|
|
284
|
+
export function evaluate(rules, facts) {
|
|
285
|
+
for (const rule of rules.deny) {
|
|
286
|
+
const evidence = collectEvidence(rule, facts)
|
|
287
|
+
if (evidence.length > 0) return { action: 'deny', rule, evidence }
|
|
288
|
+
}
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function collectEvidence(rule, facts) {
|
|
293
|
+
const evidence = []
|
|
294
|
+
|
|
295
|
+
for (const command of facts.commands ?? []) {
|
|
296
|
+
const name = String(command.name ?? '')
|
|
297
|
+
// 命令词可能是**全路径**(`& "E:\nodejs24\node.exe" -e`)—— 只比裸名会漏掉这条路,
|
|
298
|
+
// 所以连 basename 一起比一次(`copy.exe` 与 `py.exe` 不会混,比文本层的 ends-with glob 更准)。
|
|
299
|
+
const named = name.toLowerCase()
|
|
300
|
+
const base = named.split(/[\\/]/).pop()
|
|
301
|
+
if (!rule.commands.has(named) && !rule.commands.has(base)) continue
|
|
302
|
+
const args = Array.isArray(command.args) ? command.args : []
|
|
303
|
+
|
|
304
|
+
// 配了 paths ⇒ 与命令名做 AND:受保护路径的删除/改名(2026-10-27 从文本层吸收)。
|
|
305
|
+
// 读和列举不受影响 —— 只有规则里点名的**动词**才会进到这里。
|
|
306
|
+
let pathsHit = null
|
|
307
|
+
if (rule.paths.length > 0) {
|
|
308
|
+
pathsHit = args.find((arg) => rule.paths.some((needle) => String(arg).toLowerCase().includes(needle.toLowerCase())))
|
|
309
|
+
if (pathsHit === undefined) continue
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 规则的 when(若配了)只对**这条命令自己的参数**求值 —— 这正是它比文本层准的地方:
|
|
313
|
+
// 别的命令的 `-p` / `-c` 不会串进来(`adb … -p` 之于 `node 脚本`、`gradle -p` 同理)。
|
|
314
|
+
if (rule.when !== null && rule.when !== undefined) {
|
|
315
|
+
const matched = [...new Set(matchCondition(rule.when, args))]
|
|
316
|
+
if (matched.length === 0) continue
|
|
317
|
+
evidence.push({ kind: '命令', text: (args.length > 0 ? args.join(' ') : name) + `(${matched.join(' / ')})` })
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
evidence.push({
|
|
321
|
+
kind: '命令',
|
|
322
|
+
text: (args.length > 0 ? args.join(' ') : name) + (pathsHit === null ? '' : '(含受保护路径)'),
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 提及即停(2026-10-27 从文本层吸收):任何命令名或参数里出现这串字,就算 ——
|
|
327
|
+
// 判据**故意不含动词**:执行类动词的名单永远差一条,而"路径名"是唯一稳定的东西。
|
|
328
|
+
for (const needle of rule.mentions) {
|
|
329
|
+
for (const command of facts.commands ?? []) {
|
|
330
|
+
const texts = [String(command.name ?? ''), ...(Array.isArray(command.args) ? command.args : []).map(String)]
|
|
331
|
+
const found = texts.find((text) => text.toLowerCase().includes(needle.toLowerCase()))
|
|
332
|
+
if (found === undefined) continue
|
|
333
|
+
evidence.push({ kind: '提及', text: `含「${needle}」:${found}` })
|
|
334
|
+
break
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const member of facts.members ?? []) {
|
|
339
|
+
const name = String(member.name ?? '')
|
|
340
|
+
if (!rule.memberNames.has(name.toLowerCase())) continue
|
|
341
|
+
const target = String(member.target ?? '')
|
|
342
|
+
if (rule.memberTargetContains.length > 0 && !rule.memberTargetContains.some((needle) => target.includes(needle))) continue
|
|
343
|
+
evidence.push({ kind: '.NET 调用', text: `${target}::${name}` })
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (rule.redirects) {
|
|
347
|
+
for (const redirect of facts.redirects ?? []) {
|
|
348
|
+
const target = String(redirect.target ?? '')
|
|
349
|
+
if (target === '$null') continue
|
|
350
|
+
evidence.push({ kind: '重定向', text: `${redirect.append === true ? '>>' : '>'} ${target}` })
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return evidence
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const MAX_EVIDENCE = 10
|
|
358
|
+
|
|
359
|
+
// 实测案例(视频观看任务):一条 pwsh 里混着"列目录 + 抽 6 帧 + 拼预览 + 删临时帧",
|
|
360
|
+
// 只有最后那句 `Remove-Item "$out\_p*.png"` 命中通配 —— 结果**整条都没跑**,前面几步全白干,
|
|
361
|
+
// 智能体得从头重做。它自己看出来了("Also the whole command was blocked, so nothing ran"),
|
|
362
|
+
// 但不该指望每次都看得出来。这句话直接说给它听,同时教它怎么组织命令。
|
|
363
|
+
const WHOLE_CALL_BLOCKED = [
|
|
364
|
+
'整条命令都不会执行 —— 包括里面那些本身无害的步骤(列目录、抽帧、构建……都算)。',
|
|
365
|
+
'所以要重做时,把有风险的那一步单独发一次,别和别的步骤混在同一次调用里。',
|
|
366
|
+
].join('\n')
|
|
367
|
+
|
|
368
|
+
// 「不要绕道」这段话是必须写在文案里的。理由:名单永远列不全(动态调用、启动器参数、
|
|
369
|
+
// 换语言换 API 都够不着),能兜住"换个写法做同样的事"的只有这段文字 ——
|
|
370
|
+
// 而这正是工作区验证过唯一生效的通道:对已经在跑任务的智能体,生效的只有〔错误/拒绝〕和〔内容本身〕。
|
|
371
|
+
const NO_WORKAROUND_DENY = [
|
|
372
|
+
'【不要绕道】被拦的意思是"这件事不该这么做",不是"这条路走不通、换一条"。',
|
|
373
|
+
'不要改用别的写法去达成同样的事 —— 换语言、换 API、换命令都算',
|
|
374
|
+
'(比如 python 的 os.remove / open(..., "w")、cmd 的 del 与重定向、.NET 的文件方法)。',
|
|
375
|
+
'同样的事、同样的危害,换个写法一样是危害;而且绕过去的那些写法本层多半看不见 —— 那正是要避免的。',
|
|
376
|
+
].join('\n')
|
|
377
|
+
|
|
378
|
+
// 三思不是拒绝 —— 等一会儿就允许执行。所以这里要挡的是另一件事:
|
|
379
|
+
// **嫌等冷却麻烦,于是改道去做同一件事**。那是三思最容易诱发的绕道。
|
|
380
|
+
const NO_WORKAROUND_DELIBERATE = [
|
|
381
|
+
'【不要绕道】如果你只是嫌"要等冷却"麻烦,不要改用别的写法去达成同一件事 ——',
|
|
382
|
+
'换语言、换 API、换命令都算。想执行,就按上面的流程确认;不想执行,就放弃它。',
|
|
383
|
+
].join('\n')
|
|
384
|
+
|
|
385
|
+
export function buildDenialMessage({ rules, hit, facts, command }) {
|
|
386
|
+
const lines = []
|
|
387
|
+
lines.push(`[ast-guard] 拒绝:${hit.rule.id}`)
|
|
388
|
+
lines.push('')
|
|
389
|
+
lines.push('语法树看到的(来自 PowerShell 自带解析器,不是文本匹配 —— 引号、换行、参数位置都不影响它):')
|
|
390
|
+
for (const item of hit.evidence.slice(0, MAX_EVIDENCE)) lines.push(` - ${item.kind}:${item.text}`)
|
|
391
|
+
if (hit.evidence.length > MAX_EVIDENCE) lines.push(` - 另有 ${hit.evidence.length - MAX_EVIDENCE} 处同类命中`)
|
|
392
|
+
lines.push('')
|
|
393
|
+
lines.push(WHOLE_CALL_BLOCKED)
|
|
394
|
+
|
|
395
|
+
const dynamic = Number(facts.dynamic ?? 0)
|
|
396
|
+
if (dynamic > 0) {
|
|
397
|
+
lines.push('')
|
|
398
|
+
lines.push(`[注意] 这条命令里有 ${dynamic} 处动态调用(\`& $var\` 一类),语法树看不见它要跑什么:`)
|
|
399
|
+
for (const text of (facts.dynamicTexts ?? []).slice(0, 3)) lines.push(` - ${text}`)
|
|
400
|
+
lines.push(' 解析器也不确定的部分,按本意自己核对一遍。')
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (hit.rule.why !== '') {
|
|
404
|
+
lines.push('')
|
|
405
|
+
lines.push(hit.rule.why)
|
|
406
|
+
}
|
|
407
|
+
lines.push('')
|
|
408
|
+
lines.push(NO_WORKAROUND_DENY)
|
|
409
|
+
lines.push('')
|
|
410
|
+
lines.push(`(dsh-ast-guard v${VERSION};规则来自 ${rules.source};这条不是文本匹配的误报,是语法树里真的有这个节点。)`)
|
|
411
|
+
return lines.join('\n')
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function buildDeliberateMessage({ rules, rule, hits, facts, evidence, cooldownSeconds, pendingId }) {
|
|
415
|
+
const lines = []
|
|
416
|
+
lines.push(`[ast-guard] 三思:${rule.id}`)
|
|
417
|
+
lines.push('')
|
|
418
|
+
lines.push('语法树看到的(来自 PowerShell 自带解析器,不是文本匹配):')
|
|
419
|
+
for (const hit of hits.slice(0, 6)) {
|
|
420
|
+
lines.push(` - 命令:${hit.command}`)
|
|
421
|
+
for (const reason of hit.reasons.slice(0, 3)) lines.push(` 触发原因:${reason}`)
|
|
422
|
+
}
|
|
423
|
+
if (hits.length > 6) lines.push(` - 另有 ${hits.length - 6} 处同类命中`)
|
|
424
|
+
|
|
425
|
+
// 「这条命令会丢什么」—— 守卫自己跑的只读探针现场产出(2026-10-10 加,起因见 evidence.js)。
|
|
426
|
+
// 放在最前面:它是决定"要不要确认"的**唯一材料** —— 命令文本本身看不出这件事。
|
|
427
|
+
if (Array.isArray(evidence) && evidence.length > 0) {
|
|
428
|
+
lines.push('')
|
|
429
|
+
lines.push('**这条命令会丢什么** —— 下面由只读探针**现跑**,数字来自命令本身,不是守卫估的:')
|
|
430
|
+
lines.push(...evidence)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
lines.push('')
|
|
434
|
+
lines.push(WHOLE_CALL_BLOCKED)
|
|
435
|
+
|
|
436
|
+
const dynamic = Number(facts.dynamic ?? 0)
|
|
437
|
+
if (dynamic > 0) {
|
|
438
|
+
lines.push('')
|
|
439
|
+
lines.push(`[注意] 这条命令里有 ${dynamic} 处动态调用(\`& $var\` 一类),语法树看不见它要跑什么:`)
|
|
440
|
+
for (const text of (facts.dynamicTexts ?? []).slice(0, 3)) lines.push(` - ${text}`)
|
|
441
|
+
lines.push(' 解析器也不确定的部分,按本意自己核对一遍。')
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
lines.push('')
|
|
445
|
+
lines.push('**这不是拒绝。** 拒绝的写法在拒绝模式里,那些会直接不让你执行;')
|
|
446
|
+
lines.push('这一档的意思是:**这条命令有正当用途、但代价高,所以先停下来认真看一眼。**')
|
|
447
|
+
lines.push('')
|
|
448
|
+
lines.push('要判断的是两件事,缺一不可 —— 别只往一个方向想:')
|
|
449
|
+
lines.push(' 1. **必要性**:这件事非做不可吗?不做会怎样?是不是真有更便宜的做法(备份、挪走、换个工具)?')
|
|
450
|
+
lines.push(' 2. **安全性**:目标和范围对不对?会不会连累不该碰的东西?出错能不能回退?')
|
|
451
|
+
lines.push('')
|
|
452
|
+
lines.push('两种回答都是正常的,**别把"放弃"当默认答案**:')
|
|
453
|
+
lines.push(' - 确认有必要、也确认安全 → 冷却结束后确认,然后执行它。**这是正常路径,不是闯关。**')
|
|
454
|
+
lines.push(' - 发现没必要 / 不安全 / 有更便宜的做法 → 放弃,或者换个做法。那是真的换对了,不是被迫绕道。')
|
|
455
|
+
lines.push('')
|
|
456
|
+
lines.push('两条出口的代价都不高,**别单纯为了"省手续"去走第三条路**:')
|
|
457
|
+
lines.push(' 该做 → 走确认(代价就是等一次冷却 + 一句确认);不该做 → 放弃。')
|
|
458
|
+
lines.push(' 真要换做法,先问自己一句:**新做法是真的更安全 / 更便宜,还是只是绕开了这一步?**')
|
|
459
|
+
lines.push('')
|
|
460
|
+
lines.push(`冷却期 ${cooldownSeconds} 秒:这段时间内你的任何回答都无效 —— 回答"是"也一样。`)
|
|
461
|
+
lines.push('窗口内回答会被拒,并告诉你还剩几秒。')
|
|
462
|
+
lines.push('这段时间就拿上面那两个问题过一遍,别急着答。')
|
|
463
|
+
lines.push('')
|
|
464
|
+
lines.push('冷却结束后用 ast_confirm 工具回答 —— **必须带上下面这个编号**:')
|
|
465
|
+
lines.push(` 待确认编号:${pendingId}`)
|
|
466
|
+
lines.push(` ast_confirm({ id: "${pendingId}", decision: "yes" }) —— 确认「有必要且安全」,放行这一次(一次性,绑定到这条命令)`)
|
|
467
|
+
lines.push(` ast_confirm({ id: "${pendingId}", decision: "no" }) —— 放弃,不做它`)
|
|
468
|
+
lines.push('不带编号的"是"一律无效。这条待确认项 2 分钟不处理会自动作废(防"忘了之后顺手确认"),')
|
|
469
|
+
lines.push('届时把命令重发一次即可重新走冷却。')
|
|
470
|
+
|
|
471
|
+
if (rule.why !== '') {
|
|
472
|
+
lines.push('')
|
|
473
|
+
lines.push(rule.why)
|
|
474
|
+
}
|
|
475
|
+
lines.push('')
|
|
476
|
+
lines.push(NO_WORKAROUND_DELIBERATE)
|
|
477
|
+
lines.push('')
|
|
478
|
+
lines.push(`(dsh-ast-guard v${VERSION};规则来自 ${rules.source};按效应触发,不是按命令名。)`)
|
|
479
|
+
return lines.join('\n')
|
|
480
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfitsec/dsh-ast-guard",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "PowerShell syntax-tree guard for DSH: a resident parser plus its own rule file. Judges pwsh commands by syntax tree (not by text), and adds a deliberation tier for costly-but-legitimate actions.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek-harness",
|
|
7
|
+
"dsh",
|
|
8
|
+
"dsh-plugin",
|
|
9
|
+
"powershell",
|
|
10
|
+
"command-guard",
|
|
11
|
+
"syntax-tree",
|
|
12
|
+
"safety"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/CFITCorporation/dsh-ast-guard.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/CFITCorporation/dsh-ast-guard#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/CFITCorporation/dsh-ast-guard/issues"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "lib/index.js",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"default": "./lib/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"lib",
|
|
32
|
+
"cordis.patch.yml",
|
|
33
|
+
"rules",
|
|
34
|
+
"test",
|
|
35
|
+
"docs",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "node test/smoke.mjs && node test/hook.mjs"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22.19"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"yaml": "^2.9.0"
|
|
46
|
+
},
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"author": "CFITSec",
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"dsh": {
|
|
53
|
+
"bundle": {
|
|
54
|
+
"patch": "./cordis.patch.yml"
|
|
55
|
+
},
|
|
56
|
+
"compatibility": {
|
|
57
|
+
"profiles": [
|
|
58
|
+
"web"
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|