dsh-escalation-review 0.2.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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/NOTICE.md +17 -0
- package/README.md +186 -0
- package/README.zh.md +162 -0
- package/config.json +72 -0
- package/cordis.patch.yml +11 -0
- package/icon.svg +14 -0
- package/lib/breaker.js +76 -0
- package/lib/client.js +1672 -0
- package/lib/config-schema.js +162 -0
- package/lib/config.js +210 -0
- package/lib/decisions.js +67 -0
- package/lib/dsh-packages.js +128 -0
- package/lib/facts.js +277 -0
- package/lib/index.js +543 -0
- package/lib/policy.js +766 -0
- package/lib/probes.js +337 -0
- package/lib/projection.js +114 -0
- package/lib/reviewer.js +265 -0
- package/lib/selftest-loader.js +41 -0
- package/lib/session-key.js +31 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -0
package/lib/facts.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* facts.js —— 宿主侧确定性事实(评审的"只读核实"层,第一层)
|
|
3
|
+
*
|
|
4
|
+
* 为什么先做这一层,而不是让评审器自己跑命令:
|
|
5
|
+
* · Codex 的 guardian 能跑**只读**命令核实(例如批准删除前先看目标),但它的工具集是受限的,
|
|
6
|
+
* 不是任意 exec —— 让模型自己拼命令等于开一个注入面。
|
|
7
|
+
* · 而我们最需要的那些事实(目标是否存在、是文件还是目录、多大、是不是空目录、是否在工作区内、
|
|
8
|
+
* git 仓库边界)在进程内用 `fs` 就能拿到,比解析命令输出更可靠:无引号/编码/本地化差异。
|
|
9
|
+
*
|
|
10
|
+
* 边界(刻意的):
|
|
11
|
+
* · 只做 metadata,**不读文件内容**(内容属于证据,会进 prompt;先不做,避免把隐私塞进模型)
|
|
12
|
+
* · 全程 try(任何失败记 note,绝不抛);路径数量与目录扫描都有上限
|
|
13
|
+
* · 这些事实标注为"宿主核实的当前状态",与不可信的 transcript 明确分开
|
|
14
|
+
*/
|
|
15
|
+
import { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, statSync } from 'node:fs'
|
|
16
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
|
|
19
|
+
const MAX_PATHS = 12
|
|
20
|
+
const MAX_DIR_ENTRIES = 50
|
|
21
|
+
// 有界内容预览(对应 Codex「批准删除/写入前先看内容」):先看大小再读,只读前若干字节
|
|
22
|
+
const PREVIEW_MAX_FILE_BYTES = 4_096
|
|
23
|
+
const PREVIEW_BYTES = 512
|
|
24
|
+
const MAX_PREVIEWS = 2
|
|
25
|
+
|
|
26
|
+
/** 路径是否在某个根目录内(考虑 Windows 大小写与分隔符)。 */
|
|
27
|
+
function isInside(root, target) {
|
|
28
|
+
if (typeof root !== 'string' || root.length === 0) return undefined
|
|
29
|
+
const rootPath = resolve(root)
|
|
30
|
+
const targetPath = resolve(target)
|
|
31
|
+
if (rootPath === targetPath) return true
|
|
32
|
+
const rel = relative(rootPath, targetPath)
|
|
33
|
+
return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 像路径吗?(宽进严出:先归一化,再要求真实路径形状,并排除散文) */
|
|
37
|
+
const FULL_WIDTH_PUNCT = /[,。;:!?、()【】“”]/
|
|
38
|
+
const PATH_SHAPE = /^~?[\\/]|^[A-Za-z]:[\\/]|^\.{1,2}[\\/]|^\\\\[^\\\s]|^\/(?:[\w.$-]+\/)+[\w.$-]+$/
|
|
39
|
+
function looksLikePath(raw) {
|
|
40
|
+
const candidate = String(raw).trim().replace(/\\\\/g, '\\').replace(/[\\/]+$/, '')
|
|
41
|
+
if (candidate.length < 3 || candidate.length > 260) return undefined
|
|
42
|
+
if (candidate.includes('\u0000')) return undefined
|
|
43
|
+
if (FULL_WIDTH_PUNCT.test(candidate)) return undefined
|
|
44
|
+
if (!PATH_SHAPE.test(candidate)) return undefined
|
|
45
|
+
return candidate
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 从待审参数里挑出候选路径,**并标注来源与疑似碎片**(绝不丢弃)。
|
|
50
|
+
*
|
|
51
|
+
* 为什么不剪枝:`Copy-Item 'D:\proj' 'D:\proj\backup'` 里 `D:\proj` 是另一个候选的**前缀**,
|
|
52
|
+
* 但它自己是真实目标 —— 丢掉就是静默丢证据。所以保留全部候选,只打两个标:
|
|
53
|
+
* · source:`field:<键名>`(结构化字段,可信)或 `text`(命令/散文正文,可能是碎片)
|
|
54
|
+
* · likelyFragmentOf:它是某个更长候选的前缀,或是挂在 Windows 路径上的 POSIX 尾巴
|
|
55
|
+
*/
|
|
56
|
+
export function extractCandidates(args) {
|
|
57
|
+
const found = []
|
|
58
|
+
const seen = new Map()
|
|
59
|
+
const push = (raw, source) => {
|
|
60
|
+
const trimmed = String(raw).replace(/[\\/]+$/, '')
|
|
61
|
+
if (trimmed.length < 3) return
|
|
62
|
+
const key = trimmed.toLowerCase()
|
|
63
|
+
const existing = seen.get(key)
|
|
64
|
+
if (existing !== undefined) {
|
|
65
|
+
if (String(source).startsWith('field:')) existing.source = source
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
const record = { path: trimmed, source }
|
|
69
|
+
seen.set(key, record)
|
|
70
|
+
found.push(record)
|
|
71
|
+
}
|
|
72
|
+
const walk = (value, depth, source) => {
|
|
73
|
+
if (depth > 4 || found.length >= MAX_PATHS * 3) return
|
|
74
|
+
if (typeof value === 'string') {
|
|
75
|
+
// 先把 URL 整体剥掉:否则 `https://example.com/a/b` 会被当成路径 /example.com/a/b
|
|
76
|
+
const cleaned = value.replace(/[a-z][a-z0-9+.-]*:\/\/\S+/gi, ' ')
|
|
77
|
+
// ① 整个字符串就是路径(write/read 类工具的 file_path)——最常见、最可信
|
|
78
|
+
const whole = looksLikePath(value)
|
|
79
|
+
if (whole !== undefined) {
|
|
80
|
+
push(whole, source)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
// ② 引号里的整段路径(含空格时只能这样拿全,按空白切会截断成假路径)
|
|
84
|
+
for (const quoted of cleaned.matchAll(/"([^"]{3,260})"|'([^']{3,260})'/g)) {
|
|
85
|
+
const candidate = looksLikePath(quoted[1] ?? quoted[2])
|
|
86
|
+
if (candidate !== undefined) push(candidate, source)
|
|
87
|
+
if (found.length >= MAX_PATHS * 3) return
|
|
88
|
+
}
|
|
89
|
+
// ③ 裸路径。碎片要在**源头**不产生,而不是产生后再剪:
|
|
90
|
+
// · POSIX 形式必须落在**词边界**,否则会把 Windows 路径里的 `.../dsh/node_modules`
|
|
91
|
+
// 当成独立路径(碎片的主要来源)
|
|
92
|
+
// · Windows 形式允许"空格续写"(DeepSeek Harness\resources),但 40 字符内必须再出现分隔符
|
|
93
|
+
// · 字符集含 `~ @ % + . _ -`,不含 shell 元字符
|
|
94
|
+
const boundary = '(?:^|[\\s"\'=:(,])'
|
|
95
|
+
const barePath = new RegExp(
|
|
96
|
+
'[A-Za-z]:[\\\\/][^"\'\\s|;<>()]*[ ][^"\'\\s|;<>()]{1,40}[\\\\/][^"\'\\s|;<>()]+' +
|
|
97
|
+
'|[A-Za-z]:[\\\\/][^"\'\\s|;<>()]+' +
|
|
98
|
+
'|' + boundary + '~?/[\\w.@%+-]+(?:/[\\w.@%+-]+)+' +
|
|
99
|
+
'|' + boundary + '\\.{1,2}/[\\w./-]+',
|
|
100
|
+
'g',
|
|
101
|
+
)
|
|
102
|
+
for (const match of cleaned.matchAll(barePath)) {
|
|
103
|
+
const raw = match[0].replace(/^[\s"'=:(,]/, '')
|
|
104
|
+
const candidate = looksLikePath(raw)
|
|
105
|
+
if (candidate === undefined) continue
|
|
106
|
+
push(candidate, source)
|
|
107
|
+
if (found.length >= MAX_PATHS * 3) return
|
|
108
|
+
}
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(value)) {
|
|
112
|
+
for (const item of value) walk(item, depth + 1, source)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (value !== null && typeof value === 'object') {
|
|
116
|
+
// 只有**结构化路径字段**才算可信来源;command / description / justification 都是自由文本
|
|
117
|
+
const STRUCTURED = /^(file_path|filePath|path|paths|target|cwd|dir|directory|dest|destination|source|to|from)$/i
|
|
118
|
+
for (const [key, item] of Object.entries(value)) {
|
|
119
|
+
walk(item, depth + 1, STRUCTURED.test(key) ? 'field:' + key : 'text:' + key)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
walk(args, 0, 'text')
|
|
124
|
+
// 标注疑似碎片(**保留,不删**):比较的是完整候选集,所以与扫描顺序无关
|
|
125
|
+
const normalize = (p) => p.replace(/\//g, '\\').toLowerCase()
|
|
126
|
+
for (const record of found) {
|
|
127
|
+
const lower = record.path.toLowerCase()
|
|
128
|
+
const parent = found.find((other) => {
|
|
129
|
+
if (other === record) return false
|
|
130
|
+
const otherLower = other.path.toLowerCase()
|
|
131
|
+
if (otherLower.length > lower.length && otherLower.startsWith(lower)) return true
|
|
132
|
+
if (record.path.startsWith('/') && normalize(other.path).endsWith(normalize(record.path))) return true
|
|
133
|
+
return false
|
|
134
|
+
})
|
|
135
|
+
if (parent !== undefined) record.likelyFragmentOf = parent.path
|
|
136
|
+
}
|
|
137
|
+
return found.slice(0, MAX_PATHS)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 兼容旧调用:只取路径字符串。 */
|
|
141
|
+
export function extractCandidatePaths(args) {
|
|
142
|
+
return extractCandidates(args).map((record) => record.path)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 找到某个路径所属的 git 仓库根(向上找 .git,最多 6 层)。 */
|
|
146
|
+
function gitRootOf(target) {
|
|
147
|
+
let current = resolve(target)
|
|
148
|
+
for (let i = 0; i < 6 && current.length > 3; i += 1) {
|
|
149
|
+
try {
|
|
150
|
+
if (existsSync(join(current, '.git'))) return current
|
|
151
|
+
} catch {
|
|
152
|
+
return undefined
|
|
153
|
+
}
|
|
154
|
+
const parent = dirname(current)
|
|
155
|
+
if (parent === current) return undefined
|
|
156
|
+
current = parent
|
|
157
|
+
}
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 单个路径的事实(永不抛)。 */
|
|
162
|
+
export function factFor(rawPath, cwd) {
|
|
163
|
+
const fact = { path: rawPath }
|
|
164
|
+
try {
|
|
165
|
+
// `~` 要展开成 home:否则会解析到 `<cwd>/~/.dsh/...`,报出假的 exists=false
|
|
166
|
+
const expanded = rawPath === '~' || rawPath.startsWith('~/') || rawPath.startsWith('~\\')
|
|
167
|
+
? join(homedir(), rawPath.slice(2))
|
|
168
|
+
: rawPath
|
|
169
|
+
const absolute = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd ?? '.', expanded)
|
|
170
|
+
fact.resolved = absolute
|
|
171
|
+
const inside = isInside(cwd, absolute)
|
|
172
|
+
if (inside !== undefined) fact.insideWorkspace = inside
|
|
173
|
+
let stats
|
|
174
|
+
try {
|
|
175
|
+
stats = lstatSync(absolute)
|
|
176
|
+
} catch {
|
|
177
|
+
stats = undefined
|
|
178
|
+
}
|
|
179
|
+
if (stats === undefined) {
|
|
180
|
+
fact.exists = false
|
|
181
|
+
return fact
|
|
182
|
+
}
|
|
183
|
+
fact.exists = true
|
|
184
|
+
fact.kind = stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file'
|
|
185
|
+
fact.bytes = stats.size
|
|
186
|
+
fact.modifiedAt = new Date(stats.mtimeMs).toISOString()
|
|
187
|
+
if (fact.kind === 'directory') {
|
|
188
|
+
let entries
|
|
189
|
+
try {
|
|
190
|
+
entries = readdirSync(absolute)
|
|
191
|
+
} catch {
|
|
192
|
+
entries = undefined
|
|
193
|
+
}
|
|
194
|
+
if (entries !== undefined) {
|
|
195
|
+
fact.entries = entries.length
|
|
196
|
+
fact.empty = entries.length === 0
|
|
197
|
+
if (entries.length > 0 && entries.length <= MAX_DIR_ENTRIES) fact.sampleEntries = entries.slice(0, 8)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (fact.kind === 'file' && stats.size > 0 && stats.size <= PREVIEW_MAX_FILE_BYTES) {
|
|
201
|
+
// 只在小文件上读前 512 字节;出现 NUL 视为二进制,直接不给预览(不读、不猜)
|
|
202
|
+
try {
|
|
203
|
+
const fd = openSync(absolute, 'r')
|
|
204
|
+
try {
|
|
205
|
+
const buffer = Buffer.alloc(PREVIEW_BYTES)
|
|
206
|
+
const read = readSync(fd, buffer, 0, PREVIEW_BYTES, 0)
|
|
207
|
+
const slice = buffer.subarray(0, read)
|
|
208
|
+
if (!slice.includes(0)) fact.preview = slice.toString('utf8').slice(0, PREVIEW_BYTES)
|
|
209
|
+
} finally {
|
|
210
|
+
closeSync(fd)
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
/* 读不到就不给预览,绝不影响其它事实 */
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const gitRoot = gitRootOf(absolute)
|
|
217
|
+
if (gitRoot !== undefined) fact.insideGitRepo = gitRoot
|
|
218
|
+
} catch (error) {
|
|
219
|
+
fact.error = String(error?.message ?? error)
|
|
220
|
+
}
|
|
221
|
+
return fact
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 收集待审动作的本地事实。
|
|
226
|
+
* @param exec - 待审的工具调用(含 arguments 与 agent.session.header.cwd)
|
|
227
|
+
* @returns { cwd, facts, notes } —— 失败只记 notes,绝不抛
|
|
228
|
+
*/
|
|
229
|
+
export function collectLocalFacts(exec) {
|
|
230
|
+
const notes = []
|
|
231
|
+
let cwd
|
|
232
|
+
try {
|
|
233
|
+
cwd = exec?.agent?.session?.header?.cwd
|
|
234
|
+
} catch {
|
|
235
|
+
cwd = undefined
|
|
236
|
+
}
|
|
237
|
+
const facts = []
|
|
238
|
+
try {
|
|
239
|
+
// 用带标注的版本:每个候选带 source(字段可信 / 正文可能碎)与 likelyFragmentOf(疑似碎片,保留不删)
|
|
240
|
+
for (const candidate of extractCandidates(exec?.arguments)) {
|
|
241
|
+
const fact = factFor(candidate.path, cwd)
|
|
242
|
+
if (candidate.source !== undefined) fact.source = candidate.source
|
|
243
|
+
// 碎片标注只在"它自己不存在"时成立:`Copy-Item 'D:\proj' 'D:\proj\backup'` 里
|
|
244
|
+
// `D:\proj` 是另一个候选的前缀,但它是真实目标(存在),不能叫碎片
|
|
245
|
+
if (candidate.likelyFragmentOf !== undefined && fact.exists !== true) {
|
|
246
|
+
fact.likelyFragmentOf = candidate.likelyFragmentOf
|
|
247
|
+
}
|
|
248
|
+
facts.push(fact)
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
notes.push('fact collection failed: ' + String(error?.message ?? error))
|
|
252
|
+
}
|
|
253
|
+
// 预览最多保留 MAX_PREVIEWS 个(其余只留 metadata,避免把内容塞满证据)
|
|
254
|
+
let previews = 0
|
|
255
|
+
for (const fact of facts) {
|
|
256
|
+
if (fact.preview === undefined) continue
|
|
257
|
+
previews += 1
|
|
258
|
+
if (previews > MAX_PREVIEWS) delete fact.preview
|
|
259
|
+
}
|
|
260
|
+
if (facts.length === 0) notes.push('no path-like argument found; nothing to verify locally')
|
|
261
|
+
return { cwd: cwd ?? null, facts, notes }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 渲染成评审证据里的一段(与不可信 transcript 明确分开)。 */
|
|
265
|
+
export function renderLocalFacts(localFacts) {
|
|
266
|
+
return JSON.stringify(
|
|
267
|
+
{
|
|
268
|
+
note:
|
|
269
|
+
'Host-verified metadata about paths mentioned by the pending action, read-only, as of now. ' +
|
|
270
|
+
'This is deterministic fact, not agent prose. For at most a couple of small files a bounded preview of ' +
|
|
271
|
+
'the first bytes is included (binary files are never previewed); nothing was modified.',
|
|
272
|
+
...localFacts,
|
|
273
|
+
},
|
|
274
|
+
null,
|
|
275
|
+
2,
|
|
276
|
+
)
|
|
277
|
+
}
|