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/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
+ }