@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 +21 -0
- package/README.md +140 -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/daemon.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// 常驻 PowerShell 解析器的客户端:启动、握手、请求、超时、崩溃后重启。
|
|
2
|
+
//
|
|
3
|
+
// 为什么常驻:实测每命令起一个解释器要 1,130 ms,常驻后每命令 0.8 ms。
|
|
4
|
+
// 进程活着就用,死了就按需重启;重启失败达到上限就进入降温,避免刷屏式重启。
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const DEFAULT_SCRIPT = path.join(HERE, 'ast-daemon.ps1')
|
|
11
|
+
|
|
12
|
+
export class AstDaemon {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.script = options.script ?? DEFAULT_SCRIPT
|
|
15
|
+
this.powershell = options.powershell ?? 'powershell.exe'
|
|
16
|
+
this.timeoutMs = options.timeoutMs ?? 5000
|
|
17
|
+
this.maxRestarts = options.maxRestarts ?? 3
|
|
18
|
+
this.restartCooldownMs = options.restartCooldownMs ?? 30000
|
|
19
|
+
this.logger = options.logger ?? null
|
|
20
|
+
|
|
21
|
+
this.child = null
|
|
22
|
+
this.readyPromise = null
|
|
23
|
+
this.pending = new Map()
|
|
24
|
+
this.buffer = ''
|
|
25
|
+
this.seq = 0
|
|
26
|
+
this.restarts = 0
|
|
27
|
+
this.stopped = false
|
|
28
|
+
this.lastFailure = null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get running() {
|
|
32
|
+
return this.child !== null && this.child.exitCode === null && !this.child.killed
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
start() {
|
|
36
|
+
if (this.running) return this.readyPromise
|
|
37
|
+
if (this.stopped) return Promise.reject(new Error('daemon 已关闭'))
|
|
38
|
+
|
|
39
|
+
this.buffer = ''
|
|
40
|
+
const child = spawn(this.powershell, [
|
|
41
|
+
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', this.script,
|
|
42
|
+
], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
|
43
|
+
this.child = child
|
|
44
|
+
|
|
45
|
+
child.stdout.setEncoding('utf8')
|
|
46
|
+
child.stdout.on('data', (chunk) => this.#onData(chunk))
|
|
47
|
+
child.stderr.setEncoding('utf8')
|
|
48
|
+
child.stderr.on('data', (chunk) => {
|
|
49
|
+
const text = String(chunk).trim()
|
|
50
|
+
if (text !== '') this.logger?.warn?.(`ast-guard: 解析器 stderr:${text.slice(0, 400)}`)
|
|
51
|
+
})
|
|
52
|
+
child.on('error', (error) => this.#onExit(child, error))
|
|
53
|
+
child.on('exit', (code) => this.#onExit(child, new Error(`解析器进程退出(code ${code})`)))
|
|
54
|
+
|
|
55
|
+
// 三道保险,任何一条路径退出都不能把这孩子留成孤儿 ——
|
|
56
|
+
// 它一旦继承着宿主的管道句柄活下来,调用方会一直等那个句柄关闭。
|
|
57
|
+
if (this.exitHook === undefined) {
|
|
58
|
+
this.exitHook = () => { try { this.child?.kill() } catch { /* 已经没了 */ } }
|
|
59
|
+
process.once('exit', this.exitHook)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.readyPromise = new Promise((resolve, reject) => {
|
|
63
|
+
this.readyResolve = resolve
|
|
64
|
+
this.readyReject = reject
|
|
65
|
+
setTimeout(() => reject(new Error('解析器启动超时')), Math.max(this.timeoutMs, 10000)).unref?.()
|
|
66
|
+
})
|
|
67
|
+
return this.readyPromise
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#onData(chunk) {
|
|
71
|
+
this.buffer += chunk
|
|
72
|
+
let index
|
|
73
|
+
while ((index = this.buffer.indexOf('\n')) >= 0) {
|
|
74
|
+
const line = this.buffer.slice(0, index).trim()
|
|
75
|
+
this.buffer = this.buffer.slice(index + 1)
|
|
76
|
+
if (line === '') continue
|
|
77
|
+
|
|
78
|
+
let message
|
|
79
|
+
try { message = JSON.parse(line) } catch {
|
|
80
|
+
// 解析不了的行 = 响应被换行截断了,或解析器吐了别的东西。丢掉会变成静默超时,所以必须喊。
|
|
81
|
+
this.logger?.warn?.(`ast-guard: 解析器输出无法解析(前 200 字符):${line.slice(0, 200)}`)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (message.ready === true) {
|
|
86
|
+
this.restarts = 0
|
|
87
|
+
this.readyResolve?.()
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const entry = this.pending.get(message.id)
|
|
91
|
+
if (entry === undefined) continue
|
|
92
|
+
this.pending.delete(message.id)
|
|
93
|
+
clearTimeout(entry.timer)
|
|
94
|
+
entry.resolve(message)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#onExit(child, error) {
|
|
99
|
+
// 迟到的通知:老进程退出时新进程可能已经起来了。不认这一条,
|
|
100
|
+
// 否则会把新进程的引用清成 null 并误拒它的就绪承诺(实测就栽在这)。
|
|
101
|
+
if (child !== undefined && child !== this.child) return
|
|
102
|
+
this.lastFailure = error
|
|
103
|
+
this.logger?.warn?.(`ast-guard: ${error.message}`)
|
|
104
|
+
for (const [, entry] of this.pending) {
|
|
105
|
+
clearTimeout(entry.timer)
|
|
106
|
+
entry.reject(error)
|
|
107
|
+
}
|
|
108
|
+
this.pending.clear()
|
|
109
|
+
this.readyReject?.(error)
|
|
110
|
+
this.readyResolve = undefined
|
|
111
|
+
this.readyReject = undefined
|
|
112
|
+
this.readyPromise = null
|
|
113
|
+
this.child = null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async parse(command) {
|
|
117
|
+
return this.#send({ command })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 别名对账:问 PowerShell 自己"这名字是什么、别名指向谁"。
|
|
122
|
+
* 规则文件里的别名表必须和这个结果一致 —— 2026-09-22 凭记忆写漏过 ri / rm,
|
|
123
|
+
* 所以把它变成可以自动核对的东西,而不是靠谁记得。
|
|
124
|
+
*/
|
|
125
|
+
async recon(names, aliasesOf = []) {
|
|
126
|
+
return this.#send({ recon: { names, aliasesOf } })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async #send(payload) {
|
|
130
|
+
if (this.stopped) throw new Error('daemon 已关闭')
|
|
131
|
+
if (!this.running) {
|
|
132
|
+
if (this.restarts >= this.maxRestarts) {
|
|
133
|
+
throw new Error(`解析器重启次数超过上限(${this.maxRestarts}),暂停使用`)
|
|
134
|
+
}
|
|
135
|
+
this.restarts += 1
|
|
136
|
+
await this.start()
|
|
137
|
+
} else if (this.readyPromise !== null) {
|
|
138
|
+
await this.readyPromise
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const id = ++this.seq
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
this.pending.delete(id)
|
|
145
|
+
// 一条请求卡住 = 后面全部排队,守卫会因此把所有 pwsh 都拒掉。
|
|
146
|
+
// 所以直接把这个进程判死:exit 会拒掉其余在途请求,下次调用重新拉起。
|
|
147
|
+
try { this.child?.kill() } catch { /* 已经没了 */ }
|
|
148
|
+
reject(new Error(`解析请求超时(${this.timeoutMs} ms);该进程已判死,下次调用会重启`))
|
|
149
|
+
}, this.timeoutMs)
|
|
150
|
+
timer.unref?.()
|
|
151
|
+
this.pending.set(id, { resolve, reject, timer })
|
|
152
|
+
this.child.stdin.write(JSON.stringify({ id, ...payload }) + '\n')
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
stop() {
|
|
157
|
+
this.stopped = true
|
|
158
|
+
if (this.child !== null) {
|
|
159
|
+
try { this.child.stdin.end() } catch { /* 进程可能已经没了 */ }
|
|
160
|
+
try { this.child.kill() } catch { /* 同上 */ }
|
|
161
|
+
}
|
|
162
|
+
this.child = null
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 进程级单例。解析器是**无状态**的(一问一答,按 id 配对),所以跨插件实例共享完全安全。
|
|
167
|
+
//
|
|
168
|
+
// 为什么必须共享(2026-09-23 实测):宿主会**反复调用 apply()**,每次新建一个 AstDaemon
|
|
169
|
+
// —— 结果是同一个宿主底下挂着两个 powershell 进程(一个漏着不释放,另一个每次重建),
|
|
170
|
+
// 而且每次重建都要付一次约 700 ms 的进程启动。做成单例之后不管上层怎么反复 apply,
|
|
171
|
+
// 都是一个进程。
|
|
172
|
+
//
|
|
173
|
+
// ⚠️ 共享的东西**不能由某个实例的 dispose 关掉** —— 那会把别人的解析器一起弄没。
|
|
174
|
+
// 所以这里只在进程退出时收尾,不提供 stop()。
|
|
175
|
+
let shared = null
|
|
176
|
+
|
|
177
|
+
export function sharedDaemon(options = {}) {
|
|
178
|
+
if (shared === null) {
|
|
179
|
+
shared = new AstDaemon(options)
|
|
180
|
+
process.once('exit', () => {
|
|
181
|
+
try { shared?.stop() } catch { /* 可能已经在关了 */ }
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
return shared
|
|
185
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// 三思层:在「有正当用途、但代价高」的动作前插一道冷却期。
|
|
2
|
+
//
|
|
3
|
+
// 语义(用户 2026-09-22 定,逐条照做):
|
|
4
|
+
// - 拦截时给出完整的语法树摘要
|
|
5
|
+
// - 冷却期 x 秒(可配,**不低于 10**):这段时间内**任何回答 —— 是或否 —— 都无效**
|
|
6
|
+
// - 窗口内的回答会被拒,并告诉还剩几秒,同时提醒反复回看"这是不是我的本意"
|
|
7
|
+
// - 只有 ≥ x 秒之后的回答才有效
|
|
8
|
+
// - 确认是**一次性**的,绑定到那一条具体命令(规范化后的哈希),过期作废
|
|
9
|
+
//
|
|
10
|
+
// 时钟是注入的:测试可以把时间直接推快,不用真等 10 秒。
|
|
11
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
12
|
+
|
|
13
|
+
export const MIN_COOLDOWN_SECONDS = 10
|
|
14
|
+
|
|
15
|
+
/** 待确认项的编号:短、随机、每次拦截都换一个。 */
|
|
16
|
+
export function newPendingId() {
|
|
17
|
+
return randomBytes(3).toString('hex')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function canonicalCommand(text) {
|
|
21
|
+
return String(text ?? '').replace(/\r\n/g, '\n').trim()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function commandHash(text) {
|
|
25
|
+
return createHash('sha256').update(canonicalCommand(text), 'utf8').digest('hex').slice(0, 16)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// PowerShell 允许无歧义前缀(`-Rec` 就是 `-Recurse`),所以按"参数是规范标志的前缀"判,
|
|
29
|
+
// 与 PowerShell 自己的规则一致。歧义由规则作者承担:只列它真在意的标志。
|
|
30
|
+
export function flagMatches(arg, flag) {
|
|
31
|
+
const bare = String(arg).toLowerCase().replace(/:.*$/, '')
|
|
32
|
+
if (bare.length < 2 || !bare.startsWith('-')) return false
|
|
33
|
+
// ⚠️ 裸 `--` 是"选项结束"标记,不是标志。不排掉它,`--` 会成为**所有** `--xxx` 的前缀
|
|
34
|
+
// (`--hard` / `--force` / `--delete` 全中)—— 2026-10-10 实测:`git checkout -- <单个文件>`
|
|
35
|
+
// 因此被误判成"范围收缩",而对照组要求它必须放行。
|
|
36
|
+
if (bare === '--') return false
|
|
37
|
+
return flag.startsWith(bare)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 取值型全局选项:它们的**下一个参数是值**,不是位置参数。
|
|
41
|
+
* `git -C <仓库> checkout -- .` 的第一个位置参数是**仓库路径**,不是 `checkout` ——
|
|
42
|
+
* 而带上 `-C` 正是 2026-10-10 那次事故的写法(`git -C $proj checkout -- .`),
|
|
43
|
+
* 不跳过就整条规则失效。 */
|
|
44
|
+
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
|
|
45
|
+
|
|
46
|
+
function firstPositional(args) {
|
|
47
|
+
// ⚠️ 从 **1** 开始:daemon 给的形状是 `["git","reset","--hard"]` —— **args[0] 是命令名本身**。
|
|
48
|
+
// 2026-10-10 实测踩到:不跳它会取到 `git` 而不是 `reset`,于是整个 `subcommands` 维度
|
|
49
|
+
// 是哑的(10-03 加的注册表规则只靠 paths 那一半在工作,而当时没给语法树层写过用例)。
|
|
50
|
+
// 规则里列的子命令都不会等于命令名,所以无条件跳第一个是安全的。
|
|
51
|
+
for (let index = 1; index < args.length; index++) {
|
|
52
|
+
const arg = String(args[index])
|
|
53
|
+
if (GLOBAL_OPTIONS_WITH_VALUE.has(arg)) { index++; continue }
|
|
54
|
+
if (arg.startsWith('-')) continue
|
|
55
|
+
return arg
|
|
56
|
+
}
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 一条 when 条件:**组内各维是 OR**(命中任一维,这条条件就算成立),返回命中的理由。
|
|
62
|
+
* 这是"与"的零件 —— 2026-10-10 加。起因:`git checkout -b x`(建分支,无害)与
|
|
63
|
+
* `git checkout -- .`(丢整棵树)**只差一个参数**,一组条件分不开,必须两组相与。
|
|
64
|
+
*/
|
|
65
|
+
export function matchCondition(condition, args) {
|
|
66
|
+
const reasons = []
|
|
67
|
+
const subcommands = Array.isArray(condition.subcommands) ? condition.subcommands : []
|
|
68
|
+
const argsExact = Array.isArray(condition.argsExact) ? condition.argsExact : []
|
|
69
|
+
const flags = Array.isArray(condition.flags) ? condition.flags : []
|
|
70
|
+
const paths = Array.isArray(condition.paths) ? condition.paths : []
|
|
71
|
+
const argsContain = Array.isArray(condition.argsContain) ? condition.argsContain : []
|
|
72
|
+
|
|
73
|
+
// 子命令:第一个位置参数(跳过取值型全局选项,见上)。
|
|
74
|
+
// `reg add`、`git checkout` 这类外部命令的参数形态解析器认不出来,位置参数就是它的子命令。
|
|
75
|
+
// 取法是防御式的:规则对象可能是别处手写的(离线工具),缺某一维按空处理。
|
|
76
|
+
if (subcommands.length > 0) {
|
|
77
|
+
const first = firstPositional(args)
|
|
78
|
+
if (first !== undefined && subcommands.includes(String(first).toLowerCase())) reasons.push(`子命令 ${String(first)}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const arg of args) {
|
|
82
|
+
const text = String(arg)
|
|
83
|
+
if (argsExact.length > 0 && argsExact.includes(text)) reasons.push(`参数 ${text}`)
|
|
84
|
+
for (const flag of flags) if (flagMatches(text, flag)) reasons.push(`带标志 ${text}`)
|
|
85
|
+
if (condition.wildcard === true && /[*?]/.test(text)) reasons.push(`通配 ${text}`)
|
|
86
|
+
for (const needle of paths) if (text.includes(needle)) reasons.push(`落在受保护位置 ${text}`)
|
|
87
|
+
for (const needle of argsContain) if (text.toLowerCase().includes(needle.toLowerCase())) reasons.push(`参数含 ${needle}`)
|
|
88
|
+
}
|
|
89
|
+
return reasons
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 按效应判:命中命令名之后,还要看参数里有没有真的踩到"贵"的那一面。
|
|
94
|
+
* 规则没写 when 就是按命令名,一律三思。
|
|
95
|
+
* when.all(2026-10-10 加)= **合取**:列表里每组条件都必须成立;没写 all 就是单组,行为同以前。
|
|
96
|
+
*/
|
|
97
|
+
export function evaluateDeliberate(rule, facts) {
|
|
98
|
+
const hits = []
|
|
99
|
+
for (const command of facts.commands ?? []) {
|
|
100
|
+
const name = String(command.name ?? '')
|
|
101
|
+
// 同 rules.js:命令词可能是**全路径**,裸名匹配会漏 ⇒ 连 basename 一起比。
|
|
102
|
+
const named = name.toLowerCase()
|
|
103
|
+
const base = named.split(/[\\/]/).pop()
|
|
104
|
+
if (!rule.commands.has(named) && !rule.commands.has(base)) continue
|
|
105
|
+
const args = Array.isArray(command.args) ? command.args : []
|
|
106
|
+
const shown = args.join(' ') || name
|
|
107
|
+
|
|
108
|
+
if (rule.when === null) {
|
|
109
|
+
hits.push({ command: shown, reasons: ['规则没有限定效应条件,按命令名触发'] })
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const conditions = Array.isArray(rule.when.all) ? rule.when.all : [rule.when]
|
|
114
|
+
const reasons = []
|
|
115
|
+
let allMatched = true
|
|
116
|
+
for (const condition of conditions) {
|
|
117
|
+
const part = matchCondition(condition, args)
|
|
118
|
+
if (part.length === 0) { allMatched = false; break }
|
|
119
|
+
reasons.push(...part)
|
|
120
|
+
}
|
|
121
|
+
if (allMatched && reasons.length > 0) hits.push({ command: shown, reasons })
|
|
122
|
+
}
|
|
123
|
+
return hits
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class DeliberateDesk {
|
|
127
|
+
/**
|
|
128
|
+
* pendingTtlMs 是刻意的短:忘了的待确认项必须自己烂掉。
|
|
129
|
+
* 否则"很久之后顺手 confirm 一下"就会批准一条早就忘了的危险命令。
|
|
130
|
+
*/
|
|
131
|
+
constructor({ now = () => Date.now(), confirmWindowMs = 60000, pendingTtlMs = 120000 } = {}) {
|
|
132
|
+
this.now = now
|
|
133
|
+
this.confirmWindowMs = confirmWindowMs
|
|
134
|
+
this.pendingTtlMs = pendingTtlMs
|
|
135
|
+
this.pendings = new Map()
|
|
136
|
+
this.grants = new Map()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
static key(sessionId, hash) {
|
|
140
|
+
return `${sessionId}\u0000${hash}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 三态:allow-once(有效的一次性放行被消费)/ deliberate(拦截,进冷却)/ null(本层无意见)
|
|
145
|
+
*/
|
|
146
|
+
check({ sessionId, command, facts, rules }) {
|
|
147
|
+
const hash = commandHash(command)
|
|
148
|
+
const grantKey = DeliberateDesk.key(sessionId, hash)
|
|
149
|
+
const grant = this.grants.get(grantKey)
|
|
150
|
+
const now = this.now()
|
|
151
|
+
|
|
152
|
+
if (grant !== undefined) {
|
|
153
|
+
if (grant.used || grant.expiresAt <= now) this.grants.delete(grantKey)
|
|
154
|
+
else {
|
|
155
|
+
grant.used = true
|
|
156
|
+
this.grants.delete(grantKey)
|
|
157
|
+
return { action: 'allow-once', ruleId: grant.ruleId }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const rule of rules.deliberate) {
|
|
162
|
+
const hits = evaluateDeliberate(rule, facts)
|
|
163
|
+
if (hits.length === 0) continue
|
|
164
|
+
const pending = {
|
|
165
|
+
id: newPendingId(),
|
|
166
|
+
sessionId,
|
|
167
|
+
hash,
|
|
168
|
+
rule,
|
|
169
|
+
hits,
|
|
170
|
+
command: canonicalCommand(command),
|
|
171
|
+
requestedAt: now,
|
|
172
|
+
readyAt: now + rule.cooldownSeconds * 1000,
|
|
173
|
+
}
|
|
174
|
+
this.pendings.set(sessionId, pending)
|
|
175
|
+
return { action: 'deliberate', pending }
|
|
176
|
+
}
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** none / live / expired —— 区分开才能给出"该重发命令了"这种有用的拒绝理由。 */
|
|
181
|
+
stateFor(sessionId) {
|
|
182
|
+
const pending = this.pendings.get(sessionId)
|
|
183
|
+
if (pending === undefined) return { status: 'none' }
|
|
184
|
+
const ageMs = this.now() - pending.requestedAt
|
|
185
|
+
if (ageMs > this.pendingTtlMs) {
|
|
186
|
+
this.pendings.delete(sessionId)
|
|
187
|
+
return { status: 'expired', ageMs, command: pending.command }
|
|
188
|
+
}
|
|
189
|
+
return { status: 'live', pending }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 智能体来回答。三道门,缺一不可:
|
|
194
|
+
* ① 待确认项还活着(过期即作废,防"忘了之后顺手确认")
|
|
195
|
+
* ② 编号对得上(**不接受不带编号的"是"** —— 那是意外确认的唯一入口)
|
|
196
|
+
* ③ 冷却期已满(窗口内是或否都无效)
|
|
197
|
+
*/
|
|
198
|
+
confirm({ sessionId, id, decision, command }) {
|
|
199
|
+
const state = this.stateFor(sessionId)
|
|
200
|
+
|
|
201
|
+
if (state.status === 'none') {
|
|
202
|
+
return { ok: false, reason: 'no-pending', message: '这个会话当前没有等待确认的三思命令。' }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (state.status === 'expired') {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
reason: 'expired',
|
|
209
|
+
message: [
|
|
210
|
+
`这次确认被拒:那条命令是 ${Math.round(state.ageMs / 1000)} 秒前拦下的,`,
|
|
211
|
+
`已经超过等待上限(${Math.round(this.pendingTtlMs / 1000)} 秒),自动作废了。`,
|
|
212
|
+
'作废是故意的 —— 免得你在很久之后顺手确认一条自己已经忘了的危险命令。',
|
|
213
|
+
'',
|
|
214
|
+
'真要执行它,把那条命令重新发一次:会重新拦截、重新走一遍冷却期。',
|
|
215
|
+
].join('\n'),
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const pending = state.pending
|
|
220
|
+
if (typeof id !== 'string' || id !== pending.id) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
reason: 'wrong-id',
|
|
224
|
+
message: [
|
|
225
|
+
'这次确认被拒:编号对不上。',
|
|
226
|
+
`当前待确认的编号是 ${pending.id};你给的是 ${id === undefined ? '(没给)' : JSON.stringify(id)}。`,
|
|
227
|
+
'不带编号的"是"一律无效 —— 这是为了挡住"顺手确认一条自己已经忘了的命令"。',
|
|
228
|
+
'',
|
|
229
|
+
'待确认的命令:',
|
|
230
|
+
...pending.command.split('\n').map((line) => ` ${line}`),
|
|
231
|
+
].join('\n'),
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (command !== undefined && commandHash(command) !== pending.hash) {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
reason: 'mismatch',
|
|
239
|
+
message: '你附带的命令原文和待确认的那条对不上。待确认的是:\n ' + pending.command,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const now = this.now()
|
|
244
|
+
const remainingMs = pending.readyAt - now
|
|
245
|
+
if (remainingMs > 0) {
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
reason: 'too-early',
|
|
249
|
+
remainingSeconds: Math.ceil(remainingMs / 1000),
|
|
250
|
+
pending,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this.pendings.delete(sessionId)
|
|
255
|
+
if (decision !== 'yes') {
|
|
256
|
+
return { ok: true, decision: 'no', message: '已放弃,这条命令不会被放行。' }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const expiresAt = now + this.confirmWindowMs
|
|
260
|
+
this.grants.set(DeliberateDesk.key(sessionId, pending.hash), {
|
|
261
|
+
ruleId: pending.rule.id,
|
|
262
|
+
approvedAt: now,
|
|
263
|
+
expiresAt,
|
|
264
|
+
used: false,
|
|
265
|
+
})
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
decision: 'yes',
|
|
269
|
+
expiresAt,
|
|
270
|
+
ruleId: pending.rule.id,
|
|
271
|
+
id: pending.id,
|
|
272
|
+
command: pending.command,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
package/lib/evidence.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// 三思窗口的「要丢什么」—— 规则里声明的只读探针,由守卫自己跑,结果打进窗口。
|
|
2
|
+
//
|
|
3
|
+
// 为什么必须有这一层(2026-10-10 事故征候「回退范围扩张」):
|
|
4
|
+
// `git checkout -- .` 这行字**看起来完全无害**,它甚至不含路径。
|
|
5
|
+
// 决定"要不要停"的信息不在命令里,在**工作区状态**里 ——
|
|
6
|
+
// 那次是 `?? src/lab/`:两个文件、644 行、**从未提交**,而 reflog 只记 HEAD 移动过的东西,
|
|
7
|
+
// 对"从未提交的文件"没有任何记录,丢了就没有第二处。
|
|
8
|
+
// 报告 §5.3/§6.2 的结论:**加多少条规则都拦不住那次,除非窗口里带上这个清单。**
|
|
9
|
+
//
|
|
10
|
+
// 三条硬约束:
|
|
11
|
+
// ① **只跑规则文件里写死的命令**,不是模型给的 —— 探针的命令文本不经过任何外部输入;
|
|
12
|
+
// ② 只读、带超时、失败就如实说(不许让探针把守卫拖死,也不许假装拿到数据);
|
|
13
|
+
// ③ 数字**由命令算出来**,守卫自己不估(报告原话:"不许守卫自己估")。
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
|
|
16
|
+
const MAX_LINES_PER_PROBE = 14
|
|
17
|
+
const DEFAULT_TIMEOUT_MS = 2500
|
|
18
|
+
|
|
19
|
+
function runOnce(command, cwd, timeoutMs) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
let child
|
|
22
|
+
try {
|
|
23
|
+
child = spawn(command, { cwd, shell: true, windowsHide: true })
|
|
24
|
+
} catch (error) {
|
|
25
|
+
resolve({ ok: false, text: `没能启动:${error.message}` })
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
let out = ''
|
|
29
|
+
let err = ''
|
|
30
|
+
let settled = false
|
|
31
|
+
const finish = (result) => {
|
|
32
|
+
if (settled) return
|
|
33
|
+
settled = true
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
resolve(result)
|
|
36
|
+
}
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
try { child.kill() } catch { /* 已经结束了 */ }
|
|
39
|
+
finish({ ok: false, text: `超过 ${timeoutMs} ms 没返回,已终止` })
|
|
40
|
+
}, timeoutMs)
|
|
41
|
+
child.stdout?.on('data', (chunk) => { out += chunk })
|
|
42
|
+
child.stderr?.on('data', (chunk) => { err += chunk })
|
|
43
|
+
child.on('error', (error) => finish({ ok: false, text: `没能启动:${error.message}` }))
|
|
44
|
+
child.on('close', (code) => {
|
|
45
|
+
const text = (out.trim() !== '' ? out : err).trim()
|
|
46
|
+
finish({ ok: code === 0, text: text === '' ? '(无输出)' : text })
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 把探针输出算成一句账 —— 数字全部来自命令本身,守卫不加不减。 */
|
|
52
|
+
function accountFor(byProbe) {
|
|
53
|
+
// ⚠️ 只有**真跑成功**的输出才算数:报错文本和"(无输出)"这种占位符不许混进账里 ——
|
|
54
|
+
// 否则它们会被当成文件名数进去(2026-10-10 实测:账上写着"1 个未跟踪文件:(无输出)")。
|
|
55
|
+
const raw = (probe) => {
|
|
56
|
+
const result = byProbe[probe]
|
|
57
|
+
return result !== undefined && result.ok === true ? result.text : ''
|
|
58
|
+
}
|
|
59
|
+
const porcelain = raw('git status --porcelain')
|
|
60
|
+
const others = raw('git ls-files --others --exclude-standard')
|
|
61
|
+
const shortstat = raw('git diff --shortstat HEAD').trim()
|
|
62
|
+
if (porcelain === '' && others === '' && shortstat === '') return []
|
|
63
|
+
|
|
64
|
+
const untracked = others.split('\n').map((line) => line.trim()).filter((line) => line !== '')
|
|
65
|
+
const tracked = porcelain
|
|
66
|
+
.split('\n')
|
|
67
|
+
.map((line) => line.trim())
|
|
68
|
+
.filter((line) => line !== '' && !line.startsWith('??'))
|
|
69
|
+
const account = []
|
|
70
|
+
if (tracked.length > 0) {
|
|
71
|
+
account.push(`将丢弃 ${tracked.length} 个已跟踪文件的改动(${shortstat === '' ? '行数见上' : shortstat})—— **可从 HEAD 恢复**`)
|
|
72
|
+
}
|
|
73
|
+
if (untracked.length > 0) {
|
|
74
|
+
const head = untracked.slice(0, 6).join(' / ')
|
|
75
|
+
account.push(`${untracked.length} 个未跟踪文件 —— **从未提交,reflog 救不回**:${head}${untracked.length > 6 ? ` …共 ${untracked.length} 个` : ''}`)
|
|
76
|
+
}
|
|
77
|
+
if (account.length === 0) account.push('工作区看起来是干净的:没有未提交内容会因这一下丢掉')
|
|
78
|
+
return ['', '▶ 按上面探针算出来的账:', ...account.map((line) => ` ${line}`)]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 跑规则里声明的探针,返回可直接塞进窗口的文本行。
|
|
83
|
+
* probes:规则里的 `evidence:` 列表;cwds:会话 cwd + 命令里 `-C <路径>` 指向的仓库。
|
|
84
|
+
*/
|
|
85
|
+
export async function collectEvidence({ probes, cwds, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
86
|
+
const lines = []
|
|
87
|
+
const list = Array.isArray(probes) ? probes.filter((item) => typeof item === 'string' && item.trim() !== '') : []
|
|
88
|
+
const dirs = [...new Set((cwds ?? []).filter((dir) => typeof dir === 'string' && dir.trim() !== ''))]
|
|
89
|
+
if (list.length === 0 || dirs.length === 0) return lines
|
|
90
|
+
|
|
91
|
+
for (const cwd of dirs) {
|
|
92
|
+
lines.push(`── 只读探针(守卫自己跑的,工作目录 ${cwd})`)
|
|
93
|
+
const byProbe = {}
|
|
94
|
+
for (const probe of list) {
|
|
95
|
+
const result = await runOnce(probe, cwd, timeoutMs)
|
|
96
|
+
byProbe[probe] = result
|
|
97
|
+
const all = result.text.split('\n')
|
|
98
|
+
for (const line of all.slice(0, MAX_LINES_PER_PROBE)) lines.push(` ${line}`)
|
|
99
|
+
// 硬截断:`git diff` 在非仓库目录里会打**整篇 usage**(几百行),不截会把窗口灌满。
|
|
100
|
+
if (all.length > MAX_LINES_PER_PROBE) lines.push(` …(这条输出还有 ${all.length - MAX_LINES_PER_PROBE} 行,为免灌满窗口已省略)`)
|
|
101
|
+
if (result.ok !== true) lines.push(' (这条没跑成,下面只按拿到的算)')
|
|
102
|
+
}
|
|
103
|
+
lines.push(...accountFor(byProbe))
|
|
104
|
+
}
|
|
105
|
+
return lines
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 探针在哪儿跑:命令里 `-C <路径>` 指向的仓库;没有才用会话 cwd。
|
|
110
|
+
* ⚠️ 有 `-C` 时**只跑那个仓库** —— 会话 cwd 多半不是 git 仓库,在那儿跑探针只会拿到
|
|
111
|
+
* git 的 usage 长文(2026-10-10 实测:那一坨把窗口灌满,而真正有用的三行在另一个目录里)。
|
|
112
|
+
*/
|
|
113
|
+
export function evidenceCwds(cwd, facts) {
|
|
114
|
+
const fromDashC = []
|
|
115
|
+
for (const command of facts?.commands ?? []) {
|
|
116
|
+
const args = Array.isArray(command.args) ? command.args.map((item) => String(item)) : []
|
|
117
|
+
const at = args.indexOf('-C')
|
|
118
|
+
if (at >= 0 && args[at + 1] !== undefined) fromDashC.push(args[at + 1].replace(/^["']|["']$/g, ''))
|
|
119
|
+
}
|
|
120
|
+
return fromDashC.length > 0 ? [...new Set(fromDashC)] : [cwd]
|
|
121
|
+
}
|