@cfitsec/dsh-ast-guard 0.0.0-stage → 2.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CFITSec
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,141 @@
1
- # Temporary Holding Version
1
+ # dsh-ast-guard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > DSH(DeepSeek Harness)的**命令守卫插件**:在工具执行前,把 `pwsh` / `powershell` 命令交给
4
+ > **PowerShell 自带的语法树解析器**判定,按**效应**拦截(不是按命令名);规则写在 YAML 里,
5
+ > 代码里不写死任何策略。
6
+
7
+ ## 前置
8
+
9
+ - **已经装好 DSH** —— 这是 DSH 插件,不是独立程序;
10
+ - **Windows**(解析器用 `powershell.exe`,5.1 即可);
11
+ - **Node ≥ 22.19**。
12
+
13
+ ## 拿到它
14
+
15
+ 还没发布到 npm,先 clone:
16
+
17
+ ```powershell
18
+ git clone <本仓库地址>
19
+ cd <你 clone 下来的目录>
20
+ ```
21
+
22
+ ## 装进 DSH
23
+
24
+ ```powershell
25
+ dsh plugin --profile web add <你 clone 下来的目录>
26
+ ```
27
+
28
+ **然后重启 DSH**(插件与配置在启动时加载,**不热更新**)。
29
+
30
+ 装好后插件会自己把配置挂上(包里有 `cordis.patch.yml`),规则用**包内**的 `rules/default.yaml` ——
31
+ **不需要你搬任何文件**。
32
+
33
+ <details>
34
+ <summary>没有 <code>dsh plugin add</code> 这个命令时,手动挂(两步)</summary>
35
+
36
+ 1. 在 `$env:DSH_HOME\profiles\<你的 profile>\package.json` 的 `dependencies` 里加一行(`DSH_HOME` 是 DSH 的主目录,默认在 `~/.dsh`):
37
+
38
+ ```json
39
+ "@cfitsec/dsh-ast-guard": "link:<你 clone 下来的绝对路径>"
40
+ ```
41
+
42
+ 2. 在同一个文件里,把它加进 `dsh.profile.bundles` 数组。
43
+
44
+ 然后**重启 DSH**。
45
+ </details>
46
+
47
+ ## 验证它真的在拦
48
+
49
+ 重启后,随便开一个 **pwsh** 会话,跑一条会被拦的命令:
50
+
51
+ ```powershell
52
+ Set-Content -Path "$env:TEMP\ast-guard-探针.txt" -Value x
53
+ ```
54
+
55
+ 应该看到:
56
+
57
+ ```
58
+ [ast-guard] 拒绝:content-write
59
+ …(dsh-ast-guard v0.1.0;规则来自 …\rules\default.yaml;…)
60
+ ```
61
+
62
+ **看到了 ⇒ 装好了。** 没看到的话:
63
+
64
+ - 看 DSH 日志里有没有 `ast-guard:` 开头的行(插件加载失败会写在那里);
65
+ - 确认**重启过**宿主;
66
+ - 在插件目录里跑一次 `npm install`(它只有一个依赖 `yaml`,装好再重启)。
67
+
68
+ ## 它管什么
69
+
70
+ - **写文件内容**:`Set-Content` / `Add-Content` / `Clear-Content` / `Out-File` / `Tee-Object` / `Export-*`、
71
+ `.NET` 的 `WriteAll*`、重定向 `>` `>>`(`$null` 豁免)、以及 `iex "Set-Content …"` 这种字符串里的写入;
72
+ - **删除 / 移动 / 改名**:受保护位置(`.git`、`node_modules`、`.ssh` …)、递归、通配、`-Force` 触发**三思**;
73
+ - **内联代码**:`node -e`、`python -c`、`powershell -Command`、`-EncodedCommand` …(逐命令看参数);
74
+ - **凭据文件**:`edit` / `write` 的目标路径(`.npmrc` / `.credentials` / `id_rsa` …),**只看路径,不碰正文**;
75
+ - **注册表**增删改、**git 范围收缩**(`git checkout -- .` 这类)⇒ **三思**。
76
+
77
+ **三思**不是拒绝:插一道 10 秒冷却窗口,窗口内回答"是"和"否"都无效;冷却结束时先跑规则里声明的
78
+ **只读探针**,把"这条命令会丢什么"现算出来放进窗口,再用 `ast_confirm` 带编号回答。
79
+
80
+ ## 改规则
81
+
82
+ 默认规则是一套能直接用的(写内容族 + 条目操作族 + 内联 + 凭据 + 受保护目录 + 五组三思)。
83
+ 想改成自己的:
84
+
85
+ 1. 复制一份:`Copy-Item rules\default.yaml my-rules.yaml`,按需要改;
86
+ 2. 在 profile 的插件配置里指过去(`$env:DSH_HOME\profiles\<profile>\cordis.patch.yml`):
87
+
88
+ ```yaml
89
+ - id: ast-guard
90
+ config:
91
+ rulesFile: <绝对路径,或相对会话 cwd 的路径>
92
+ ```
93
+
94
+ 3. **重启**(配置不热更新;规则文件本身改了会热加载)。
95
+
96
+ 完整规则词表:`commands` · `memberNames` · `redirects` ·
97
+ `when{ flags, wildcard, paths, subcommands, argsExact, argsContain, all }` ·
98
+ `tools` · `paths` · `mentions` · `evidence` · 三思的 `cooldownSeconds` / `when` / `evidence`。
99
+
100
+ ## 判不了就拒(fail-closed)
101
+
102
+ 规则文件读坏、解析器起不来 ⇒ **拒绝并说明怎么修**,不是静默放行。
103
+ 理由很简单:一个"看起来在拦、其实没判"的守卫比没有守卫更危险 —— 它骗的是使用者。
104
+ (对应配置:`onRuleError: closed` / `failPolicy: closed`,都能改成 `open`,但要想清楚代价。)
105
+
106
+ ## 边界(别误当成安全边界)
107
+
108
+ - 只接 `pwsh` / `powershell`;**bash 不在范围内** —— 规则层很简单,欢迎按目标 shell 自行移植。
109
+ - 护的是"**忘了那一次**",不防"故意绕":对故意的,靠回退点与审计,不靠墙。
110
+ - 看不见**字符串里**的东西(`Start-Process … -ArgumentList`、`[scriptblock]::Create("…")`、`cmd /c "…"`)。
111
+ - 覆盖面是**正面清单**,列不完;只读列举受保护目录**也会被拦**("提及即停"的已知代价)。
112
+
113
+ ## 跑自带的测试(可选)
114
+
115
+ > ✅ **测试随包发布**(在 `test/` 里,纯 dry-run:只解析、不执行、不联网、只写临时目录)。
116
+ > 所以从 npm 装好之后,也能直接拿它验证你改的规则。
117
+
118
+ 在包目录里(`npm publish` 装的话是 `node_modules/@cfitsec/dsh-ast-guard`):
119
+
120
+ ```powershell
121
+ npm install # 只有一个依赖 yaml(以插件方式装好时通常已就位)
122
+ npm test # = smoke + hook 两套
123
+ node test/import-check.mjs # 入口能否装载(不启动)
124
+ node test/smoke.mjs # 30 条裁决用例:只解析、不执行;不联网、不写任何文件
125
+ node test/hook.mjs # 12 条插件级用例:走真的 apply()(假 ctx)
126
+ ```
127
+
128
+ ## 改规则之前先读这一份
129
+
130
+ [`docs/维护规则.md`](docs/维护规则.md) —— 给**要动规则的人或智能体**:
131
+ 维度速查、加规则的流程、我们踩过的坑(别名 / `args[0]` / YAML 单引号 / cmdlet 没有子命令)、
132
+ 以及**永远不要做的三件事**(留反向后门、用内容白名单、静默放行)。
133
+
134
+ ## 与生态里同类项目的关系
135
+
136
+ npm 上已经有两个相邻的项目。先认账,再说差异:
137
+
138
+ | 项目 | 它做什么 | 我们的差异 |
139
+ |---|---|---|
140
+ | `dsh-ast-guard` | tree-sitter 解析 bash + powershell CST;`allow/ask/block`;DNA 模式;沙箱升级联动 | 我们用 **PowerShell 自带解析器**(不引 WASM grammar、零原生依赖);我们是 **三思冷却窗口**(不是弹窗四选);我们有 **只读探针**把"这条命令会丢什么"现算出来;规则是朴素 YAML + 自有维度集 |
141
+ | `dsh-pwsh-guard` | 禁 shell 文本 I/O(`Select-String` / `Get-Content` / `node -e` …),指向 `read` / `grep` / `glob` | 目标不同:它管**读的方式**,我们管**写内容 / 删除 / 范围收缩**。**可以并存** |
@@ -0,0 +1,25 @@
1
+ # dsh-ast-guard bundle patch: mount the syntax-tree guard.
2
+ #
3
+ # 这是一个**独立**的守卫层:把 pwsh / powershell 命令交给随宿主启动的常驻解析器,按语法树判定。
4
+ # 规则全在 `rulesFile` 指向的 YAML 里;默认用**包内自带**的 `rules/default.yaml`
5
+ # (规则跟插件走:装上就有一套能跑的规则,不用把文件搬到哪里去)。代码里不写死任何策略。
6
+ #
7
+ # 与"文本匹配式"守卫的关系:**可以并存,但不要依赖顺序**。
8
+ # 两者都用最朴素的 ctx.on 注册(cordis 的 register 默认 push,waterfall 从数组头 shift),
9
+ # "谁先跑"是插件加载顺序的副产物,不是接口承诺 —— 所以让每条规则自己说清"拦什么",
10
+ # 不要依赖"前一层放行了我才说话"这种假设。
11
+ - insert:
12
+ - id: ast-guard
13
+ name: "@cfitsec/dsh-ast-guard"
14
+ config:
15
+ # 默认 = **包内自带**的 `rules/default.yaml`(规则跟插件走:装上就有一套能跑的规则,不用搬文件)。
16
+ # 想用自己的规则:把它改成相对会话 cwd 的路径(如 `.dsh/ast-rules.yaml`),或绝对路径。
17
+ # rulesFile: .dsh/ast-rules.yaml
18
+ enabled: true
19
+ # 解析器不可用时:closed = 拒绝并说明(安全优先),open = 放行。
20
+ # 默认 closed:宁可停一下,也不要"看起来在拦、其实没判"。
21
+ failPolicy: closed
22
+ # 规则文件读坏时:closed = 拒绝并说明怎么修(默认,避免静默失效),open = 放行。
23
+ onRuleError: closed
24
+ # 单条命令的解析预算。实测日常命令 2~6 ms,200 行约 70 ms。
25
+ timeoutMs: 5000
@@ -0,0 +1,217 @@
1
+ # 规则参考(API 级)
2
+
3
+ > 读者:**要改这套规则的人或智能体**。目标:**读完不用看源码**就能写对、改对、验对。
4
+ > 本文的每条语义都对着代码核过;有"实测"标记的是当场跑探针验的(`compileRules` 直接调,无副作用)。
5
+
6
+ ---
7
+
8
+ ## 1. 文件结构
9
+
10
+ 规则文件是一个 **YAML 映射**(不是列表)。顶层只认三个键,其它**静默忽略**:
11
+
12
+ ```yaml
13
+ version: 1 # 可选,目前**不校验**(缺了、写成字符串都 OK);保留给将来做版本迁移
14
+ deny: [] # 可选,列表。命中 = 直接拒绝
15
+ deliberate: [] # 可选,列表。命中 = 进三思(冷却窗口)
16
+ ```
17
+
18
+ - 两个列表里**各自**都是"规则对象"的列表;写成对象(不是列表)会报 `deny 必须是列表`。
19
+ - 顶层是数组会报 `规则文件的顶层必须是映射(version / deny / deliberate)`。
20
+ - 两个键都省略 = 空规则集(不会报错,也什么都不拦)。
21
+
22
+ ---
23
+
24
+ ## 2. 规则通用字段(`deny` 与 `deliberate` 都有)
25
+
26
+ | 字段 | 类型 | 必填 | 默认 | 语义 |
27
+ |---|---|---|---|---|
28
+ | `id` | string | **是** | — | 规则标识。**会进文案**(`[ast-guard] 拒绝:<id>`)。**重复不报错、两条都生效** —— 自己保证唯一 |
29
+ | `enabled` | bool | 否 | `true` | `false` = **整条跳过**(连字段校验都不做,可以留半成品) |
30
+ | `why` | string | 否 | `''` | **会逐字进文案**(拒绝文案 / 三思窗口)。写"为什么拦 + 该怎么做",这是最主要的教学位 |
31
+ | `commands` | string[] | 见下 | `[]` | 命令名(大小写不敏感)。**别名必须逐条写**——语法树不解析别名 |
32
+ | `when` | map | 否 | — | 逐命令参数条件,见 §4 |
33
+
34
+ > `deny` 至少要有一个"匹配维度"(`commands` / `memberNames` / `redirects` / `when` / `tools` / `paths` / `mentions`),
35
+ > 否则报 `deny[<id>] 什么都没配(…)`。
36
+ > `deliberate` 的 `commands` **必须非空列表**,否则报 `deliberate[<id>].commands 必须是非空列表`。
37
+
38
+ ---
39
+
40
+ ## 3. 两档规则专属字段
41
+
42
+ ### 3.1 `deny`(直接拒绝)
43
+
44
+ | 字段 | 类型 | 语义 |
45
+ |---|---|---|
46
+ | `memberNames` | string[] | .NET 成员名(大小写不敏感)。如 `WriteAllText` / `Create` |
47
+ | `memberTargetContains` | string[] \| string | **按调用目标原文**做子串匹配(**大小写敏感**)。用来把 `[IO.File]::Create` 和 `[scriptblock]::Create` 分开 |
48
+ | `redirects` | bool | `true` = 把文件重定向 `>` / `>>` 也算命中。**目标是 `$null` 的自动豁免**(那是抑制输出) |
49
+ | `tools` | string[] | **非 shell 工具**名(`edit` / `write` …)。配了它就**必须**再给 `paths` 或 `mentions`,否则报错 |
50
+ | `paths` | string[] | **规则级**路径 needle:与 `commands` **相与**(命令名命中 + 参数含该子串)。**大小写不敏感** |
51
+ | `mentions` | string[] | **提及即停**:命令名或任意字符串参数里**含**该子串就命中(**大小写不敏感**)。可以单独用,不需要 `commands` |
52
+
53
+ ### 3.2 `deliberate`(三思)
54
+
55
+ | 字段 | 类型 | 必填 | 默认 | 语义 |
56
+ |---|---|---|---|---|
57
+ | `cooldownSeconds` | number | 否 | `10` | 冷却秒数,**必须 ≥ 10**,否则报 `必须 ≥ 10(用户定的下限)` |
58
+ | `evidence` | string[] | 否 | `[]` | 进窗口**前**跑的**只读**探针命令。**含写动词会被编译期拒绝**(`evidence 里有写动词,探针必须只读`) |
59
+
60
+ ---
61
+
62
+ ## 4. `when`:逐命令参数条件
63
+
64
+ **只对"这条命令自己的参数"求值** —— 同行别的命令的 `-p` / `-c` 不会串进来(这正是本层比文本匹配准的地方)。
65
+
66
+ | 维度 | 类型 | 语义 | 大小写 | 备注 |
67
+ |---|---|---|---|---|
68
+ | `flags` | string[] | 参数是某个标志的**前缀**即命中(`-Rec` ⇒ `-Recurse`) | 不敏感 | **必须列表**;写成字符串会抛 JS 报错。裸 `--` 不算标志 |
69
+ | `wildcard` | bool | 参数里出现 `*` 或 `?` | — | `true` 才开 |
70
+ | `paths` | string[] | 参数**包含**该子串 | **敏感** ⚠️ | 与规则级 `paths`(不敏感)**不一样**,这是已知的不一致 |
71
+ | `subcommands` | string[] | **第一个位置参数**(跳过取值型全局选项及其值) | 不敏感 | 外部命令(`reg`/`git`)适用;**cmdlet 的第一个位置参数是路径**,不要用这维 |
72
+ | `argsExact` | string[] | 参数**整个等于**其中一项 | 敏感 | 用来表达"目标是整棵树"的 `.` |
73
+ | `argsContain` | string[] | 参数**包含**该子串 | 不敏感 | 用来兜"字符串里的写命令":`iex "Set-Content …"` |
74
+ | `all` | condition[] | **分组相与**:每组都要成立(组内仍是 OR) | — | ⚠️ **仅 `deliberate` 支持**;`deny` 里写 `all` 会报"什么都没配" |
75
+
76
+ **坑(实测)**:维度名写错**不会**提示名字,只会报 `deliberate[<id>].when 什么都没配(…)`
77
+ —— 换句话说 **写错维度名 = 规则悄悄失效**。改完一定跑用例。
78
+
79
+ ---
80
+
81
+ ## 5. 求值顺序与组合
82
+
83
+ 1. **`deny` 整体先于三思**:一条命令只要命中任何 `deny` 规则就直接拒,不会进三思窗口。
84
+ 2. **同一档内按声明顺序,先命中者生效**:`deny` 与三思都是"遇到第一个命中的规则就返回"。
85
+ ⇒ **顺序有语义**:把宽规则写在窄规则前面,后面的永远不会被看到。
86
+ 3. **一条规则内**:`commands` / `memberNames` / `redirects` / `when` / `tools`+`paths` / `mentions`
87
+ 是**与**关系(都要成立);`when` 的**各维度之间是或**(命中任一即算这条条件成立),`when.all` 里的**组之间是与**。
88
+
89
+ ---
90
+
91
+ ## 6. 运行期行为
92
+
93
+ | 行为 | 说明 |
94
+ |---|---|
95
+ | 规则热更新 | 按**文件 mtime** 缓存:改了规则文件**立即生效**,不用重启 |
96
+ | 代码热更新 | ❌ **不热**:改了 `lib/*.js` 要**重启宿主**才生效 |
97
+ | 规则文件默认位置 | **包内** `rules/default.yaml` |
98
+ | 配了 `rulesFile` | 相对**会话 cwd** 解析;也可以写绝对路径 |
99
+ | 配了路径但文件不存在 | **本层无意见**(不是错误) |
100
+ | **默认**规则文件不存在 | **当错误处理** ⇒ 走 `onRuleError: closed` ⇒ 拒绝并说明("安装不完整?") |
101
+ | 解析器不可用 | 走 `failPolicy`:`closed`(默认)= 拒绝并说明;`open` = 放行 |
102
+ | 规则文件读坏 | 走 `onRuleError`:`closed`(默认)= 拒绝并说明怎么修;`open` = 放行 |
103
+ | 三思·冷却 | `cooldownSeconds`(≥10);**窗口内回答"是"和"否"都无效** |
104
+ | 三思·确认窗口 | 冷却结束后 60 秒内可以确认(`confirmWindowMs`) |
105
+ | 三思·待确认保留 | 2 分钟不处理自动作废(`pendingTtlMs`) |
106
+ | 三思·一次性放行 | 绑**命令哈希**;用掉或过期即作废;**按会话隔离** |
107
+
108
+ ---
109
+
110
+ ## 7. 编译错误对照表(照报文找原因)
111
+
112
+ | 报文(片段) | 原因 |
113
+ |---|---|
114
+ | `规则文件的顶层必须是映射` | YAML 顶层写成了列表/字符串 |
115
+ | `deny 必须是列表` / `deliberate 必须是列表` | 该键写成了对象 |
116
+ | `deny[<i>] 缺 id` | 少 `id` |
117
+ | `deny[<id>] 什么都没配(…)` | 没有任何匹配维度;**也可能是维度名拼错了** |
118
+ | `deny[<id>] 配了 tools 但既没有 paths 也没有 mentions` | `tools` 必须配路径或提及 |
119
+ | `deliberate[<id>].commands 必须是非空列表` | 三思规则没写命令 |
120
+ | `deliberate[<id>].cooldownSeconds 必须 ≥ 10` | 冷却写小了 |
121
+ | `deliberate[<id>].evidence 里有写动词` | 探针不是只读 |
122
+ | `(raw.flags ?? []).map is not a function` | `flags`(或其它 `when` 维度)写成了字符串而不是列表 |
123
+
124
+ ---
125
+
126
+ ## 8. 加规则:四步
127
+
128
+ 1. 写进规则文件(自己部署里就是 `rulesFile` 指向的那个;包内默认是 `rules/default.yaml`);
129
+ 2. **先跑对照**:`node test/smoke.mjs`(裁决)、`node test/hook.mjs`(接线与 fail-closed);
130
+ 3. **补对照**:每条"该拦"必须配一条"不该拦"。**没有对照的规则等于没验**;
131
+ 4. 真实使用里观察,把误报当 bug 处理。
132
+
133
+ ## 9. 永远不要做的三件事
134
+
135
+ 1. **不要留反向后门**:让某条规则"整体放行"的写法会短路后面对它的所有判断 —— **宁可少拦,不留后门**。
136
+ 2. **不要用内容白名单收窄内联代码**("代码里没有 fs. 就放行"):那就是后门,判据只能是形状。
137
+ 3. **不要静默放行**:一个"看起来在拦、其实没判"的守卫,比多一条误报贵得多。
138
+
139
+ ## 10. 结构性盲区(写规则时别假装能拦)
140
+
141
+ - 字符串里的代码:`iex $code`、`Start-Process … -ArgumentList`、`[scriptblock]::Create("…")`、`cmd /c "…"`;
142
+ - 脚本内部的路径与行为(规则只看命令行文本);
143
+ - 只接 `pwsh` / `powershell`;`bash` 不在范围内。
144
+
145
+ ## 11. `ast_confirm` 工具的接口
146
+
147
+ 三思窗口里唯一有效的回答方式。**不带编号的回答一律无效。**
148
+
149
+ | 参数 | 类型 | 必填 | 语义 |
150
+ |---|---|---|---|
151
+ | `id` | string | **是** | 窗口里显示的"待确认编号"。对不上就拒绝 |
152
+ | `decision` | `"yes"` \| `"no"` | **是** | `yes` = 放行**这一次**;`no` = 放弃 |
153
+ | `command` | string | 否 | 可选:附上你确认的那条命令原文。给了就必须**逐字**与被待确认的那条一致(用来防"确认了别的") |
154
+
155
+ 返回:`{ accepted: boolean, message: string, reason?: string, remainingSeconds?: number }`
156
+
157
+ - 冷却未满 ⇒ `accepted: false`、`reason: "too-early"`、`remainingSeconds` 有值;
158
+ - 编号对不上 / 没有待确认项 / 已过期 / 命令原文不一致 ⇒ `accepted: false` + 对应 `reason`;
159
+ - 接受后**一次性放行**生效:绑该命令的哈希,用掉或过期即作废。
160
+
161
+ > 用法:把命令**原样**重发一次即可(不要改任何一个字符,否则哈希对不上)。
162
+
163
+ ## 12. 完整示例(带注释)
164
+
165
+ ```yaml
166
+ version: 1
167
+
168
+ deny:
169
+ - id: no-write-verbs
170
+ why: |
171
+ PowerShell 写文件内容已禁用:会破坏编码,而且 shell 写入没有 diff、进不了回退。
172
+ 正确姿势:定点替换用 edit;整份新写用 write。
173
+ commands: [Set-Content, sc] # 别名要逐条写
174
+ memberTargetContains: ['File', 'Stream']
175
+ memberNames: [WriteAllText, Create]
176
+ redirects: true
177
+ when:
178
+ flags: ['-e', '-p'] # 列表;前缀即命中
179
+ argsContain: ['set-content'] # 只对这条命令自己的参数求值
180
+
181
+ - id: protect-credentials
182
+ why: |
183
+ 凭据文件只许走专门的流程。
184
+ tools: [edit, write]
185
+ paths: ['.npmrc', 'id_rsa'] # 只比路径型参数,不碰正文
186
+
187
+ - id: naming-convention
188
+ why: |
189
+ 名字里带这个字样的目录"提及即停"。
190
+ mentions: ['DO-NOT-RUN'] # 可以单独用,不需要 commands
191
+
192
+ deliberate:
193
+ - id: ask-before-delete # 简单三思:条件之间是"或"
194
+ cooldownSeconds: 10
195
+ why: |
196
+ 删除不可逆。更便宜的做法:先 Copy-Item 备份,或别删只挪。
197
+ commands: [Remove-Item, ri, rm] # 必须非空
198
+ when:
199
+ flags: ['-recurse', '-force']
200
+ wildcard: true
201
+ paths: ['src', 'docs']
202
+
203
+ - id: ask-before-scope-revert # 用 all 的三思:两组条件都成立才停
204
+ cooldownSeconds: 10
205
+ why: |
206
+ git 的范围收缩会丢掉工作区内容,而未提交的部分不进 reflog。
207
+ commands: [git]
208
+ when:
209
+ all:
210
+ - subcommands: [checkout, restore, reset, clean]
211
+ - argsExact: ['.']
212
+ flags: ['--hard', '-f', '-fd']
213
+ evidence:
214
+ - git status --porcelain # 只读探针:进窗口前现跑,把"会丢什么"算出来
215
+ - git ls-files --others --exclude-standard
216
+ ```
217
+
@@ -0,0 +1,138 @@
1
+ # Persistent AST parser for the DSH ast-guard plugin.
2
+ #
3
+ # Protocol: one JSON request per stdin line, one JSON response per stdout line.
4
+ # request : {"id":<int>,"command":"<text>"}
5
+ # response: {"id":<int>,"commands":[{"name":..,"args":[..]}],
6
+ # "members":[{"name":..,"target":..}],
7
+ # "redirects":[{"target":..,"append":<bool>}],
8
+ # "dynamic":<int>,"errors":[<message>]}
9
+ #
10
+ # THIS FILE MUST STAY PURE ASCII. Windows PowerShell 5.1 decodes a BOM-less .ps1 as
11
+ # GB2312, so one non-ASCII byte here turns into mojibake and the whole script fails
12
+ # to parse. Chinese in a denial message is written on the Node side instead.
13
+ #
14
+ # Explicit UTF-8 on both pipes, never [Console]::In / [Console]::Out: those use the
15
+ # console code page (936/GBK here) while Node speaks UTF-8, so a Chinese command gets
16
+ # decoded wrong and ConvertFrom-Json can fail outright -- which looked like a silent
17
+ # hang until it was traced to a single character.
18
+
19
+ $encoding = New-Object System.Text.UTF8Encoding $false
20
+ $stdout = New-Object System.IO.StreamWriter([Console]::OpenStandardOutput(), $encoding)
21
+ $stdout.AutoFlush = $true
22
+ $stdin = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), $encoding)
23
+
24
+ # Readiness handshake: the client waits for this line before sending any request.
25
+ $stdout.WriteLine('{"ready":true}')
26
+ $stdout.Flush()
27
+
28
+ while ($true) {
29
+ $line = $stdin.ReadLine()
30
+ if ($null -eq $line) { break }
31
+ if ($line.Trim() -eq '') { continue }
32
+
33
+ $req = $null
34
+ try { $req = $line | ConvertFrom-Json } catch { continue }
35
+ if ($null -eq $req) { continue }
36
+
37
+ # Alias reconciliation: ask PowerShell itself what a name is, and which aliases point at
38
+ # a canonical command. Rule files must match this, not someone's memory.
39
+ if ($null -ne $req.recon) {
40
+ $nameInfo = @{}
41
+ foreach ($name in @($req.recon.names)) {
42
+ $key = [string]$name
43
+ $cmd = Get-Command -Name $key -ErrorAction SilentlyContinue
44
+ if ($null -eq $cmd) { $nameInfo[$key] = @{ kind = 'missing'; definition = '' }; continue }
45
+ $kind = [string]$cmd.CommandType
46
+ $definition = if ($kind -eq 'Alias') { [string]$cmd.Definition } else { [string]$cmd.Name }
47
+ $nameInfo[$key] = @{ kind = $kind; definition = $definition }
48
+ }
49
+ $aliasInfo = @{}
50
+ foreach ($canonical in @($req.recon.aliasesOf)) {
51
+ $key = [string]$canonical
52
+ $aliasInfo[$key] = @(Get-Alias -Definition $key -ErrorAction SilentlyContinue | ForEach-Object { [string]$_.Name })
53
+ }
54
+ $out = @{ id = $req.id; names = $nameInfo; aliasesOf = $aliasInfo } | ConvertTo-Json -Compress -Depth 6
55
+ $stdout.WriteLine($out)
56
+ $stdout.Flush()
57
+ continue
58
+ }
59
+
60
+ $tokens = $null
61
+ $errs = $null
62
+ $text = [string]$req.command
63
+
64
+ try {
65
+ $ast = [System.Management.Automation.Language.Parser]::ParseInput($text, [ref]$tokens, [ref]$errs)
66
+ } catch {
67
+ $fail = @{ id = $req.id; fatal = [string]$_.Exception.Message } | ConvertTo-Json -Compress
68
+ $stdout.WriteLine($fail)
69
+ $stdout.Flush()
70
+ continue
71
+ }
72
+
73
+ # Every occurrence is reported WITH its arguments: the effect-based tier needs them
74
+ # (recursive / wildcard / -Force / which path), and two occurrences of the same name
75
+ # can carry different arguments. Only EXACT duplicates collapse (same name, same args) --
76
+ # that loses no information and keeps a 200-line script from returning 200 identical rows.
77
+ $commands = New-Object System.Collections.ArrayList
78
+ $commandSeen = @{}
79
+ $nullChar = [string][char]0
80
+ $dynamicCount = 0
81
+ $dynamicSeen = @{}
82
+ $dynamicTexts = New-Object System.Collections.ArrayList
83
+ foreach ($node in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true)) {
84
+ $name = [string]$node.GetCommandName()
85
+ if ([string]::IsNullOrWhiteSpace($name) -or $name.StartsWith('$') -or $name.StartsWith('(')) {
86
+ $dynamicCount++
87
+ $text = [string]$node.Extent.Text
88
+ if (-not $dynamicSeen.ContainsKey($text)) { $dynamicSeen[$text] = $true; [void]$dynamicTexts.Add($text) }
89
+ continue
90
+ }
91
+ $elements = New-Object System.Collections.ArrayList
92
+ foreach ($element in $node.CommandElements) { [void]$elements.Add([string]$element.Extent.Text) }
93
+ $key = $name + $nullChar + ($elements -join $nullChar)
94
+ if ($commandSeen.ContainsKey($key)) { $commandSeen[$key].count = $commandSeen[$key].count + 1; continue }
95
+ $entry = @{ name = $name; args = @($elements); count = 1 }
96
+ $commandSeen[$key] = $entry
97
+ [void]$commands.Add($entry)
98
+ }
99
+
100
+ $memberSeen = @{}
101
+ $members = New-Object System.Collections.ArrayList
102
+ foreach ($node in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.InvokeMemberExpressionAst] }, $true)) {
103
+ $key = ([string]$node.Expression.Extent.Text) + '::' + ([string]$node.Member.Value)
104
+ if ($memberSeen.ContainsKey($key)) { continue }
105
+ $memberSeen[$key] = $true
106
+ [void]$members.Add(@{
107
+ name = [string]$node.Member.Value
108
+ target = [string]$node.Expression.Extent.Text
109
+ })
110
+ }
111
+
112
+ $redirectSeen = @{}
113
+ $redirects = New-Object System.Collections.ArrayList
114
+ foreach ($node in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FileRedirectionAst] }, $true)) {
115
+ $target = [string]$node.Location.Extent.Text
116
+ $append = [bool]$node.Append
117
+ $key = $target + '|' + $append
118
+ if ($redirectSeen.ContainsKey($key)) { continue }
119
+ $redirectSeen[$key] = $true
120
+ [void]$redirects.Add(@{ target = $target; append = $append })
121
+ }
122
+
123
+ $messages = New-Object System.Collections.ArrayList
124
+ foreach ($e in $errs) { [void]$messages.Add([string]$e.Message) }
125
+
126
+ $response = @{
127
+ id = $req.id
128
+ commands = @($commands)
129
+ members = @($members)
130
+ redirects = @($redirects)
131
+ dynamic = $dynamicCount
132
+ dynamicTexts = @($dynamicTexts)
133
+ errors = @($messages)
134
+ }
135
+
136
+ $stdout.WriteLine(($response | ConvertTo-Json -Compress -Depth 6))
137
+ $stdout.Flush()
138
+ }