dsh-completion-guard 0.2.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/CHANGELOG.md +83 -0
- package/CHANGELOG.zh-CN.md +83 -0
- package/LICENSE +202 -0
- package/README.md +100 -0
- package/README.zh-CN.md +100 -0
- package/cordis.patch.yml +5 -0
- package/dist/domain/index.d.ts +2 -0
- package/dist/domain/index.js +3 -0
- package/dist/domain-BN3_AuUr.js +1997 -0
- package/dist/index-Dk4SkQ8H.d.ts +448 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1125 -0
- package/docs/ARCHITECTURE.md +42 -0
- package/docs/COMPATIBILITY.md +169 -0
- package/docs/LOCAL_ACCEPTANCE.md +337 -0
- package/docs/PORTING_NOTES.md +14 -0
- package/docs/PRIVACY.md +23 -0
- package/docs/UPSTREAM_BASE.md +22 -0
- package/package.json +70 -0
|
@@ -0,0 +1,1997 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
//#region src/domain/types.ts
|
|
5
|
+
function createProjection() {
|
|
6
|
+
return {
|
|
7
|
+
enabled: false,
|
|
8
|
+
epoch: 0,
|
|
9
|
+
contractRevision: 0,
|
|
10
|
+
items: /* @__PURE__ */ new Map(),
|
|
11
|
+
evidence: /* @__PURE__ */ new Map(),
|
|
12
|
+
checkpoints: [],
|
|
13
|
+
lastObservedSourceSeq: -1,
|
|
14
|
+
lastGuardEventSeq: -1,
|
|
15
|
+
continuationAttempts: /* @__PURE__ */ new Map(),
|
|
16
|
+
integrity: "valid"
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/domain/canonicalize.ts
|
|
22
|
+
function normalizeClause(text) {
|
|
23
|
+
return text.trim().replace(/\s+/g, " ");
|
|
24
|
+
}
|
|
25
|
+
function isWindowsStylePath(value) {
|
|
26
|
+
return /^[A-Za-z]:/.test(value) || value.startsWith("\\\\") || value.startsWith("//") || value.includes("\\");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Canonicalize a filesystem path for subject matching. Windows-style paths are
|
|
30
|
+
* normalized (drive letter, both separator kinds, `.`/`..`, duplicate
|
|
31
|
+
* separators) and case-folded, because Windows paths compare case-insensitively
|
|
32
|
+
* and treat `/` and `\` as equivalent. POSIX-style paths are normalized but
|
|
33
|
+
* keep their case, so a case-sensitive filesystem is never made insensitive.
|
|
34
|
+
* Exactly one canonicalizer is shared by contract capture and evidence
|
|
35
|
+
* extraction so a Windows contract subject and a Windows evidence subject match.
|
|
36
|
+
*/
|
|
37
|
+
function canonicalizePath(value) {
|
|
38
|
+
if (!value) return value;
|
|
39
|
+
return isWindowsStylePath(value) ? path.win32.normalize(value).toLowerCase() : path.posix.normalize(value);
|
|
40
|
+
}
|
|
41
|
+
function sha256(text) {
|
|
42
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
43
|
+
}
|
|
44
|
+
function digestStrings(values) {
|
|
45
|
+
return sha256(values.slice().sort().join("\n"));
|
|
46
|
+
}
|
|
47
|
+
const KEYS = "(?:authorization|proxy-authorization|api[-_]?key|token|cookie|set-cookie|password|secret|session[-_]?id)";
|
|
48
|
+
const SENSITIVE_KEYS = `("${KEYS}"|'${KEYS}'|${KEYS})`;
|
|
49
|
+
const HEADER_VALUE = /(authorization|proxy-authorization|cookie|set-cookie)\s*:\s*.+$/gi;
|
|
50
|
+
const DOUBLE_QUOTED = new RegExp(`${SENSITIVE_KEYS}\\s*[:=]\\s*"(?:\\\\.|[^"\\\\])*"`, "gi");
|
|
51
|
+
const SINGLE_QUOTED = new RegExp(`${SENSITIVE_KEYS}\\s*[:=]\\s*'(?:\\\\.|[^'\\\\])*'`, "gi");
|
|
52
|
+
const UNCLOSED_DOUBLE = new RegExp(`${SENSITIVE_KEYS}\\s*[:=]\\s*"(?:\\\\.|[^"\\\\])*\\\\?$`, "gi");
|
|
53
|
+
const UNCLOSED_SINGLE = new RegExp(`${SENSITIVE_KEYS}\\s*[:=]\\s*'(?:\\\\.|[^'\\\\])*\\\\?$`, "gi");
|
|
54
|
+
const BARE_VALUE = new RegExp(`${SENSITIVE_KEYS}\\s*[:=]\\s*[^\\s,;'"\`)\\]}]+`, "gi");
|
|
55
|
+
const BEARER_TOKEN = /\bbearer\s+[A-Za-z0-9._~+/=-]+/gi;
|
|
56
|
+
const PLAIN_KEY = /\b(?:sk|pk|ak)-[a-zA-Z0-9_-]{16,}\b/g;
|
|
57
|
+
function sanitizeClauseText(text) {
|
|
58
|
+
let value = text;
|
|
59
|
+
const label = (key) => `${key.replace(/^["']|["']$/g, "")}=<redacted>`;
|
|
60
|
+
value = value.replace(BEARER_TOKEN, "bearer <redacted>");
|
|
61
|
+
value = value.replace(HEADER_VALUE, (_match, key) => `${key}=<redacted>`);
|
|
62
|
+
value = value.replace(DOUBLE_QUOTED, (_match, key) => label(key));
|
|
63
|
+
value = value.replace(SINGLE_QUOTED, (_match, key) => label(key));
|
|
64
|
+
value = value.replace(UNCLOSED_DOUBLE, (_match, key) => label(key));
|
|
65
|
+
value = value.replace(UNCLOSED_SINGLE, (_match, key) => label(key));
|
|
66
|
+
value = value.replace(BARE_VALUE, (_match, key) => label(key));
|
|
67
|
+
value = value.replace(PLAIN_KEY, (_match) => `${_match.slice(0, 3)}-<redacted>`);
|
|
68
|
+
value = value.replace(/https?:\/\/[^\s'"`,。)]+[?#][^\s'"`,。)]*/g, (match) => {
|
|
69
|
+
const cut = Math.min(...["?", "#"].map((marker) => {
|
|
70
|
+
const index = match.indexOf(marker);
|
|
71
|
+
return index === -1 ? Infinity : index;
|
|
72
|
+
}));
|
|
73
|
+
return cut === Infinity ? match : `${match.slice(0, cut)}\u2026`;
|
|
74
|
+
});
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function sanitizeUrl(value) {
|
|
78
|
+
const cut = Math.min(...["?", "#"].map((marker) => {
|
|
79
|
+
const index = value.indexOf(marker);
|
|
80
|
+
return index === -1 ? Infinity : index;
|
|
81
|
+
}));
|
|
82
|
+
return cut === Infinity ? value : value.slice(0, cut);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/domain/manifest.ts
|
|
87
|
+
const COMMAND_SURFACE_MANIFEST = {
|
|
88
|
+
fileTools: [
|
|
89
|
+
"printf",
|
|
90
|
+
"echo",
|
|
91
|
+
"touch",
|
|
92
|
+
"cat"
|
|
93
|
+
],
|
|
94
|
+
readTools: [
|
|
95
|
+
"cat",
|
|
96
|
+
"grep",
|
|
97
|
+
"rg",
|
|
98
|
+
"head",
|
|
99
|
+
"tail",
|
|
100
|
+
"wc",
|
|
101
|
+
"sed"
|
|
102
|
+
],
|
|
103
|
+
runExecutables: [
|
|
104
|
+
"node",
|
|
105
|
+
"python",
|
|
106
|
+
"python3",
|
|
107
|
+
"pnpm",
|
|
108
|
+
"npm",
|
|
109
|
+
"yarn",
|
|
110
|
+
"bun",
|
|
111
|
+
"pytest",
|
|
112
|
+
"vitest",
|
|
113
|
+
"jest",
|
|
114
|
+
"tsc",
|
|
115
|
+
"eslint",
|
|
116
|
+
"mypy",
|
|
117
|
+
"ruff",
|
|
118
|
+
"prettier",
|
|
119
|
+
"go",
|
|
120
|
+
"cargo",
|
|
121
|
+
"make",
|
|
122
|
+
"cmake",
|
|
123
|
+
"git",
|
|
124
|
+
"mvn",
|
|
125
|
+
"gradle",
|
|
126
|
+
"tox",
|
|
127
|
+
"nox",
|
|
128
|
+
"dsh"
|
|
129
|
+
],
|
|
130
|
+
pwshExternalExecutables: [
|
|
131
|
+
"node",
|
|
132
|
+
"python",
|
|
133
|
+
"python3",
|
|
134
|
+
"pnpm",
|
|
135
|
+
"npm",
|
|
136
|
+
"yarn",
|
|
137
|
+
"bun",
|
|
138
|
+
"pytest",
|
|
139
|
+
"vitest",
|
|
140
|
+
"jest",
|
|
141
|
+
"tsc",
|
|
142
|
+
"eslint",
|
|
143
|
+
"mypy",
|
|
144
|
+
"ruff",
|
|
145
|
+
"prettier",
|
|
146
|
+
"go",
|
|
147
|
+
"cargo",
|
|
148
|
+
"make",
|
|
149
|
+
"cmake",
|
|
150
|
+
"git",
|
|
151
|
+
"mvn",
|
|
152
|
+
"gradle",
|
|
153
|
+
"tox",
|
|
154
|
+
"nox",
|
|
155
|
+
"dsh"
|
|
156
|
+
],
|
|
157
|
+
operationVerbs: [
|
|
158
|
+
{
|
|
159
|
+
op: "create",
|
|
160
|
+
pattern: "创建|生成|新建|touch|\\bcreates?\\b|\\bcreated\\b|\\bcreating\\b|\\bwrite\\b|写入"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
op: "modify",
|
|
164
|
+
pattern: "修改|编辑|更改|modif(?:y|ies|ied|ying)|\\bedit\\b|改"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
op: "read",
|
|
168
|
+
pattern: "读取|阅读|打开|读(?![A-Za-z0-9])|\\bread\\b"
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
op: "verify",
|
|
172
|
+
pattern: "验证|确认|确保|检查|verif(?:y|ies|ied|ying)|\\bconfirm\\b|\\bconfirms\\b|\\bconfirmed\\b|\\bensure\\b"
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
op: "run",
|
|
176
|
+
pattern: "运行|执行|拉取|获取|同步|更新|下载|安装|部署|上传|提交|推送|发布|升级|重启|重新启动|重载|\\brun\\b|execute(?:d)?|\\bpull\\b|\\bfetch\\b|\\bclone\\b|\\bsync\\b|\\bupdate\\b|\\binstall\\b|\\bdeploy\\b|\\bcommit\\b|\\bpush\\b|\\brelease\\b|\\bdownload\\b|\\bupload\\b|\\brestart\\b|\\breload\\b|\\breboot\\b"
|
|
177
|
+
}
|
|
178
|
+
]
|
|
179
|
+
};
|
|
180
|
+
const OPERATION_ORDER = [
|
|
181
|
+
"create",
|
|
182
|
+
"modify",
|
|
183
|
+
"read",
|
|
184
|
+
"verify",
|
|
185
|
+
"run"
|
|
186
|
+
];
|
|
187
|
+
/**
|
|
188
|
+
* Validate the manifest invariants the parsers and capture depend on:
|
|
189
|
+
* - every collection is non-empty, sorted-case-insensitively, and duplicate-free
|
|
190
|
+
* - external executables mirror the POSIX run set exactly
|
|
191
|
+
* - verb groups exist once, in the documented priority order, and compile
|
|
192
|
+
* (they compile by construction when validated, so a typo cannot silently
|
|
193
|
+
* widen or break the surface).
|
|
194
|
+
*/
|
|
195
|
+
function validateManifest(manifest = COMMAND_SURFACE_MANIFEST) {
|
|
196
|
+
const issues = [];
|
|
197
|
+
const sets = [
|
|
198
|
+
["fileTools", manifest.fileTools],
|
|
199
|
+
["readTools", manifest.readTools],
|
|
200
|
+
["runExecutables", manifest.runExecutables],
|
|
201
|
+
["pwshExternalExecutables", manifest.pwshExternalExecutables]
|
|
202
|
+
];
|
|
203
|
+
for (const [name, values] of sets) {
|
|
204
|
+
if (!values.length) issues.push({
|
|
205
|
+
path: name,
|
|
206
|
+
message: "must not be empty"
|
|
207
|
+
});
|
|
208
|
+
const sorted = [...values].map((value) => value.toLowerCase()).sort();
|
|
209
|
+
if (sorted.some((value, index) => index > 0 && value === sorted[index - 1])) issues.push({
|
|
210
|
+
path: name,
|
|
211
|
+
message: "contains duplicates"
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (manifest.runExecutables.length !== manifest.pwshExternalExecutables.length || [...manifest.runExecutables].map((value) => value.toLowerCase()).sort().join(",") !== [...manifest.pwshExternalExecutables].map((value) => value.toLowerCase()).sort().join(",")) issues.push({
|
|
215
|
+
path: "pwshExternalExecutables",
|
|
216
|
+
message: "must mirror runExecutables exactly"
|
|
217
|
+
});
|
|
218
|
+
const seenOps = /* @__PURE__ */ new Set();
|
|
219
|
+
for (const entry of manifest.operationVerbs) {
|
|
220
|
+
if (seenOps.has(entry.op)) issues.push({
|
|
221
|
+
path: `operationVerbs.${entry.op}`,
|
|
222
|
+
message: "duplicate operation group"
|
|
223
|
+
});
|
|
224
|
+
seenOps.add(entry.op);
|
|
225
|
+
try {
|
|
226
|
+
new RegExp(entry.pattern, "i");
|
|
227
|
+
} catch {
|
|
228
|
+
issues.push({
|
|
229
|
+
path: `operationVerbs.${entry.op}`,
|
|
230
|
+
message: `uncompilable pattern: ${entry.pattern}`
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const order = manifest.operationVerbs.map((entry) => entry.op);
|
|
235
|
+
const expected = OPERATION_ORDER.filter((operation) => seenOps.has(operation));
|
|
236
|
+
if (order.join(",") !== expected.join(",")) issues.push({
|
|
237
|
+
path: "operationVerbs",
|
|
238
|
+
message: `priority order must be ${expected.join(" → ")}`
|
|
239
|
+
});
|
|
240
|
+
return issues;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region src/domain/capture.ts
|
|
245
|
+
const CLAUSE_PATTERNS = [["prohibition", /^(?:(?:do not|don't|never)(?![A-Za-z0-9_./@\\-])|禁止|不要|不得)\s*(.+)$/i], ["acceptance", /^(?:verify|confirm|ensure|验收|确认|确保)\s*(.+)$/i]];
|
|
246
|
+
function classifyClause(text) {
|
|
247
|
+
const normalizedText = normalizeClause(text);
|
|
248
|
+
for (const [kind, pattern] of CLAUSE_PATTERNS) {
|
|
249
|
+
const match = normalizedText.match(pattern);
|
|
250
|
+
if (match) return {
|
|
251
|
+
kind,
|
|
252
|
+
body: normalizeClause(match[1].replace(/^[::,,\s]+/, ""))
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
kind: "requirement",
|
|
257
|
+
body: normalizedText
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const METHOD_TOOL = "(?:bash|shell|powershell|pwsh|git|read|write|edit|node|python|python3|npm|pnpm|tsc|vitest)";
|
|
261
|
+
const METHOD_ALIASES = {
|
|
262
|
+
powershell: "pwsh",
|
|
263
|
+
python3: "python"
|
|
264
|
+
};
|
|
265
|
+
const METHOD_PATTERNS = [
|
|
266
|
+
new RegExp(`(?:用|使用|通过|借助|利用|以)\\s*(${METHOD_TOOL})\\b`, "i"),
|
|
267
|
+
new RegExp(`\\b(?:via|using|use|with)\\s+(?:the\\s+)?(${METHOD_TOOL})\\b`, "i"),
|
|
268
|
+
new RegExp(`\\b(${METHOD_TOOL})\\s+(?:创建|写入|生成|修改|执行|运行|rename|create|write|modify)\\b`, "i")
|
|
269
|
+
];
|
|
270
|
+
/**
|
|
271
|
+
* Detect an explicitly named tool/method in a clause ("使用 bash 创建",
|
|
272
|
+
* "via bash", "bash to create"). Returns the canonical tool id (e.g. 'bash')
|
|
273
|
+
* or undefined when no explicit method is named.
|
|
274
|
+
*/
|
|
275
|
+
function extractMethod(text) {
|
|
276
|
+
for (const pattern of METHOD_PATTERNS) {
|
|
277
|
+
const match = text.match(pattern);
|
|
278
|
+
if (match) {
|
|
279
|
+
const raw = match[1].toLowerCase();
|
|
280
|
+
return METHOD_ALIASES[raw] ?? raw;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const OPERATION_PATTERNS = COMMAND_SURFACE_MANIFEST.operationVerbs.map((entry) => [entry.op, new RegExp(entry.pattern, "i")]);
|
|
285
|
+
/**
|
|
286
|
+
* Whether a whole user message reads as an informational report (acceptance
|
|
287
|
+
* receipt, progress summary, pasted log) rather than a task instruction.
|
|
288
|
+
* Evaluation is deliberately conservative: reports are detected only when the
|
|
289
|
+
* shape is clearly report-like (markdown headings, bold key/value lines, list
|
|
290
|
+
* or table rows, evidence terms) AND no sentence opens with an imperative, and
|
|
291
|
+
* any question mark keeps the message a task. False positives here would drop
|
|
292
|
+
* real instructions, so plain short sentences are never treated as reports.
|
|
293
|
+
*/
|
|
294
|
+
function isInformationalMessage(text) {
|
|
295
|
+
if (!text.trim()) return false;
|
|
296
|
+
if (/[??]|是否|是不是/.test(text)) return false;
|
|
297
|
+
const lines = text.split(/\r?\n/);
|
|
298
|
+
const titledLines = lines.filter((line) => /^\s{0,3}#{1,6}\s+/.test(line)).length;
|
|
299
|
+
const evidenceLines = lines.filter((line) => /^\s*(?:[-*|]\s{0,2}|\*\*.+?\*\*)/.test(line)).length;
|
|
300
|
+
const evidenceTerms = (text.match(/\b(?:commit|passed|failed|exit\s+code|checkpoint|verify|回执|汇总|状态|通过|全绿|验收|读回|回读)\b|✓|\b[0-9a-f]{40}\b/g) ?? []).length;
|
|
301
|
+
if (!(titledLines >= 1 && evidenceTerms >= 2 || evidenceLines >= 2 && evidenceTerms >= 2 || evidenceTerms >= 4)) return false;
|
|
302
|
+
const imperativeLead = /^(?:请|请你|麻烦|帮我|需要你|你看看|看一下|检查一下|分析|列出|回顾|修复|推送|安装|确认|验证|能否|能不能)/i;
|
|
303
|
+
return !text.split(/(?<=[。!?;\n])|(?<=[.!?])(?=\s|$)/).some((sentence) => imperativeLead.test(sentence.trim()));
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Detect an explicit operation/effect in a clause ("创建" → create,
|
|
307
|
+
* "读取" → read, "运行" → run). Returns the first operation named, or undefined
|
|
308
|
+
* when the clause requests no specific effect.
|
|
309
|
+
*/
|
|
310
|
+
function extractOperation(text) {
|
|
311
|
+
for (const [operation, pattern] of OPERATION_PATTERNS) if (pattern.test(text)) return operation;
|
|
312
|
+
}
|
|
313
|
+
const EXTENSION_TAIL = new RegExp(`\\.(?:ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|kt|c|cpp|h|hpp|cs|rb|php|vue|svelte|md|mdx|json|jsonc|yml|yaml|toml|ini|cfg|sh|bash|zsh|fish|ps1|html|css|scss|less|sql|txt|lock|mod|sum|env|patch|diff|pkl|tf|hcl|proto)(?:$|[^A-Za-z0-9])`, "i");
|
|
314
|
+
function isArtifactCandidate(value) {
|
|
315
|
+
return EXTENSION_TAIL.test(value);
|
|
316
|
+
}
|
|
317
|
+
/** Wrapped path spellings: backticks, double/single quotes, and parentheses. */
|
|
318
|
+
const WRAPPED_PATH = /`([^`]+)`|"([^"]+)"|'([^']+)'|\(([^()]+)\)/g;
|
|
319
|
+
function extractArtifactPaths(text) {
|
|
320
|
+
const found = /* @__PURE__ */ new Set();
|
|
321
|
+
const push = (candidate) => {
|
|
322
|
+
const trimmed = candidate.trim();
|
|
323
|
+
if (trimmed && isArtifactCandidate(trimmed)) found.add(trimmed);
|
|
324
|
+
};
|
|
325
|
+
for (const match of text.matchAll(WRAPPED_PATH)) push(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "");
|
|
326
|
+
for (const token of text.split(/[\s,;,;]+/)) {
|
|
327
|
+
const bare = token.replace(/^[('"]+|['")]+$/g, "").replace(/[。!?;.!?,,;::]+$/, "");
|
|
328
|
+
if (bare && !bare.includes("`") && isArtifactCandidate(bare)) found.add(bare);
|
|
329
|
+
}
|
|
330
|
+
return [...found];
|
|
331
|
+
}
|
|
332
|
+
function segmentClauses(text) {
|
|
333
|
+
const normalized = normalizeClause(text);
|
|
334
|
+
if (!normalized) return [];
|
|
335
|
+
const parts = normalized.split(/(?<=[。!?;])|(?<=[.!?])(?=\s|$)|(?<=(?:^|[\s。!?;.!?,,;:]))(?=(?:(?:do not|don't|never)(?![A-Za-z0-9_./@\\-])|禁止|不要|不得))/i).map((part) => part.trim()).filter(Boolean);
|
|
336
|
+
const segments = [];
|
|
337
|
+
for (const part of parts) {
|
|
338
|
+
const { kind, body } = classifyClause(part);
|
|
339
|
+
segments.push({
|
|
340
|
+
kind,
|
|
341
|
+
body,
|
|
342
|
+
paths: extractArtifactPaths(body)
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return segments;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Build a GuardItem from an already-classified clause body and a resolved
|
|
349
|
+
* verification subject/surface.
|
|
350
|
+
*/
|
|
351
|
+
function captureItem(kind, body, sourceMessageId, id, revision, subject, surface, method, operation) {
|
|
352
|
+
const sanitized = sanitizeClauseText(body);
|
|
353
|
+
return {
|
|
354
|
+
id,
|
|
355
|
+
revision,
|
|
356
|
+
kind,
|
|
357
|
+
sourceMessageId,
|
|
358
|
+
normalizedText: sanitized,
|
|
359
|
+
textSha256: sha256(sanitized),
|
|
360
|
+
status: "pending",
|
|
361
|
+
verification: kind === "prohibition" ? {
|
|
362
|
+
enforced: false,
|
|
363
|
+
surface,
|
|
364
|
+
subject
|
|
365
|
+
} : {
|
|
366
|
+
enforced: true,
|
|
367
|
+
surface,
|
|
368
|
+
subject,
|
|
369
|
+
method,
|
|
370
|
+
operation
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Capture one contract clause. Every captured item receives a concrete
|
|
376
|
+
* verification contract: a named artifact path (artifact surface) or the
|
|
377
|
+
* session scope (scope surface), so an unrelated file read can never close it.
|
|
378
|
+
*/
|
|
379
|
+
function captureClause(text, sourceMessageId, id, revision, scope = {}) {
|
|
380
|
+
const { kind, body } = classifyClause(text);
|
|
381
|
+
const path$1 = extractArtifactPaths(sanitizeClauseText(body))[0] ?? "";
|
|
382
|
+
const surface = path$1 ? "artifact" : "scope";
|
|
383
|
+
return captureItem(kind, body, sourceMessageId, id, revision, path$1 || scope.cwd || "scope", surface, extractMethod(body), extractOperation(body));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region src/domain/matching.ts
|
|
388
|
+
const STATE_VERIFICATION_CAPABILITIES = new Set([
|
|
389
|
+
"filesystem-read",
|
|
390
|
+
"web-fetch",
|
|
391
|
+
"deterministic-check"
|
|
392
|
+
]);
|
|
393
|
+
/** Capabilities that may close an explicit `verify` contract. */
|
|
394
|
+
const VERIFY_CAPABILITIES = new Set([
|
|
395
|
+
"filesystem-read",
|
|
396
|
+
"verify",
|
|
397
|
+
"web-fetch",
|
|
398
|
+
"deterministic-check"
|
|
399
|
+
]);
|
|
400
|
+
/**
|
|
401
|
+
* Whether this evidence closes the artifact/scope facet of the item: a success
|
|
402
|
+
* outcome, a verifying capability, and (when the contract names them) a match
|
|
403
|
+
* on the canonical subject and the surface. Both sides of the subject
|
|
404
|
+
* comparison run through the shared {@link canonicalizePath}, so Windows
|
|
405
|
+
* drive-letter case, separator kind, `.`/`..`, and duplicate separators are
|
|
406
|
+
* treated as equal while POSIX stays case-sensitive.
|
|
407
|
+
*/
|
|
408
|
+
function stateVerificationFacetCovered(item, evidence) {
|
|
409
|
+
if (evidence.outcome !== "success") return false;
|
|
410
|
+
if (!evidence.capabilities.some((capability) => STATE_VERIFICATION_CAPABILITIES.has(capability))) return false;
|
|
411
|
+
const { subject, surface, operation } = item.verification;
|
|
412
|
+
if (subject && !evidence.subjects.some((subjectValue) => canonicalizePath(subjectValue) === canonicalizePath(subject))) return false;
|
|
413
|
+
if (surface && !evidence.surfaces.includes(surface)) return false;
|
|
414
|
+
if (operation === "create" || operation === "write" || operation === "modify") {
|
|
415
|
+
if (!(evidence.operations ?? []).some((entry) => (entry.op === "read" || entry.op === "verify") && (!subject || entry.path !== void 0 && canonicalizePath(entry.path) === canonicalizePath(subject))) && !(evidence.capabilities.includes("deterministic-check") && (!subject || evidence.subjects.some((value) => canonicalizePath(value) === canonicalizePath(subject))))) return false;
|
|
416
|
+
}
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
function artifactFacetCovered(item, evidence) {
|
|
420
|
+
return stateVerificationFacetCovered(item, evidence);
|
|
421
|
+
}
|
|
422
|
+
function methodIdentityMatches(item, evidence) {
|
|
423
|
+
const method = item.verification.method;
|
|
424
|
+
if (!method || evidence.outcome !== "success") return false;
|
|
425
|
+
const toolMethod = DSH_TOOL_METHODS.has(method);
|
|
426
|
+
const toolMatch = toolMethod ? method === "bash" || method === "shell" ? evidence.toolName === "bash" || evidence.toolName === "shell" : evidence.toolName === method : false;
|
|
427
|
+
const executableMatch = !toolMethod && (evidence.executables?.some((value) => value.toLowerCase() === method) ?? false);
|
|
428
|
+
return toolMatch || executableMatch;
|
|
429
|
+
}
|
|
430
|
+
function isVerifyingCapability(evidence) {
|
|
431
|
+
return evidence.capabilities.some((capability) => STATE_VERIFICATION_CAPABILITIES.has(capability));
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Combined verification facet: success, capability, subject, surface and any
|
|
435
|
+
* required method identity must all come from this one evidence.
|
|
436
|
+
*/
|
|
437
|
+
function verifyFacetCovered(item, evidence) {
|
|
438
|
+
if (evidence.outcome !== "success") return false;
|
|
439
|
+
if (!evidence.capabilities.some((capability) => VERIFY_CAPABILITIES.has(capability))) return false;
|
|
440
|
+
const { subject, surface, method } = item.verification;
|
|
441
|
+
if (subject && !evidence.subjects.some((subjectValue) => canonicalizePath(subjectValue) === canonicalizePath(subject))) return false;
|
|
442
|
+
if (surface && !evidence.surfaces.includes(surface)) return false;
|
|
443
|
+
return !method || methodIdentityMatches(item, evidence);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* DSH tool ids that can appear as `evidence.toolName`. An explicit method that
|
|
447
|
+
* names one of these is a tool constraint; anything else (pnpm, git, node, …) is
|
|
448
|
+
* a shell executable that runs inside a command tool.
|
|
449
|
+
*/
|
|
450
|
+
const DSH_TOOL_METHODS = new Set([
|
|
451
|
+
"bash",
|
|
452
|
+
"shell",
|
|
453
|
+
"pwsh",
|
|
454
|
+
"read",
|
|
455
|
+
"write",
|
|
456
|
+
"edit",
|
|
457
|
+
"read_file",
|
|
458
|
+
"write_file",
|
|
459
|
+
"edit_file",
|
|
460
|
+
"web_search",
|
|
461
|
+
"web_fetch",
|
|
462
|
+
"web_fetch_url"
|
|
463
|
+
]);
|
|
464
|
+
/**
|
|
465
|
+
* Operation compatibility: a contract operation is closed by the evidence
|
|
466
|
+
* operations that produce the same effect (create/write are the same artifact
|
|
467
|
+
* production family; verify is closed by a read, run, or verify check).
|
|
468
|
+
*/
|
|
469
|
+
const OPERATION_COMPATIBLE = {
|
|
470
|
+
create: ["create", "write"],
|
|
471
|
+
write: ["create", "write"],
|
|
472
|
+
modify: [
|
|
473
|
+
"modify",
|
|
474
|
+
"write",
|
|
475
|
+
"create"
|
|
476
|
+
],
|
|
477
|
+
read: ["read"],
|
|
478
|
+
run: ["run"],
|
|
479
|
+
verify: [
|
|
480
|
+
"read",
|
|
481
|
+
"verify",
|
|
482
|
+
"run"
|
|
483
|
+
]
|
|
484
|
+
};
|
|
485
|
+
/**
|
|
486
|
+
* Whether this evidence satisfies an explicitly required tool/method facet:
|
|
487
|
+
* a success outcome, the right identity (DSH tool name for tool constraints,
|
|
488
|
+
* the invoked executable for executable constraints), and — when the contract
|
|
489
|
+
* names a subject and/or operation — an operation performed on the same
|
|
490
|
+
* canonical subject. Mentioning a file in a command (`echo guard-demo.txt`) is
|
|
491
|
+
* not an operation and cannot satisfy a create requirement.
|
|
492
|
+
*/
|
|
493
|
+
/** The effect facet proves what the evidence actually did, not merely who ran it. */
|
|
494
|
+
function effectFacetCovered(item, evidence) {
|
|
495
|
+
if (evidence.outcome !== "success") return false;
|
|
496
|
+
const { subject, surface, operation, method } = item.verification;
|
|
497
|
+
if (!operation) return false;
|
|
498
|
+
if (method && !methodIdentityMatches(item, evidence)) return false;
|
|
499
|
+
const compatible = OPERATION_COMPATIBLE[operation] ?? [];
|
|
500
|
+
const effects = evidence.operations ?? [];
|
|
501
|
+
if (surface === "artifact" && subject) {
|
|
502
|
+
const target = canonicalizePath(subject);
|
|
503
|
+
return effects.some((entry) => compatible.includes(entry.op) && entry.path !== void 0 && canonicalizePath(entry.path) === target);
|
|
504
|
+
}
|
|
505
|
+
if (surface === "scope") return effects.some((entry) => compatible.includes(entry.op));
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
/** The method facet proves only the required tool or executable identity. */
|
|
509
|
+
function methodFacetCovered(item, evidence) {
|
|
510
|
+
if (item.verification.operation === void 0 && item.verification.method) return false;
|
|
511
|
+
return methodIdentityMatches(item, evidence);
|
|
512
|
+
}
|
|
513
|
+
/** Whether this evidence performed the run operation on the contract subject. */
|
|
514
|
+
function runFacetCovered(item, evidence) {
|
|
515
|
+
if (evidence.outcome !== "success") return false;
|
|
516
|
+
const operations = evidence.operations ?? [];
|
|
517
|
+
const { subject } = item.verification;
|
|
518
|
+
if (subject) {
|
|
519
|
+
const target = canonicalizePath(subject);
|
|
520
|
+
return operations.some((entry) => entry.op === "run" && entry.path !== void 0 && canonicalizePath(entry.path) === target);
|
|
521
|
+
}
|
|
522
|
+
return operations.some((entry) => entry.op === "run");
|
|
523
|
+
}
|
|
524
|
+
function evidenceCoverage(item, evidence) {
|
|
525
|
+
return {
|
|
526
|
+
artifact: artifactFacetCovered(item, evidence),
|
|
527
|
+
effect: effectFacetCovered(item, evidence),
|
|
528
|
+
method: methodFacetCovered(item, evidence),
|
|
529
|
+
verify: verifyFacetCovered(item, evidence),
|
|
530
|
+
run: runFacetCovered(item, evidence)
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Whether a single evidence can close an enforced item on its own. This is the
|
|
535
|
+
* conservative per-evidence check; the certifier additionally verifies that the
|
|
536
|
+
* whole binding satisfies every required facet.
|
|
537
|
+
*/
|
|
538
|
+
function evidenceMatchesItem(item, evidence) {
|
|
539
|
+
if (evidence.outcome !== "success") return false;
|
|
540
|
+
if (!item.verification.enforced) return true;
|
|
541
|
+
const coverage = evidenceCoverage(item, evidence);
|
|
542
|
+
return coverage.artifact || coverage.effect || coverage.method || coverage.verify || coverage.run;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Whether a whole binding (a set of evidence ids) satisfies the fixed v0.1
|
|
546
|
+
* binding invariants:
|
|
547
|
+
*
|
|
548
|
+
* - run: the method (or run) evidence alone closes the contract — no extra
|
|
549
|
+
* read or unrelated deterministic-check is required.
|
|
550
|
+
* - create/write/modify: BOTH a method evidence (method + operation + subject)
|
|
551
|
+
* and a state-verification evidence on the same subject are required.
|
|
552
|
+
* - read: a successful read evidence matching method, read operation and
|
|
553
|
+
* subject satisfies the method side and the object side at once.
|
|
554
|
+
* - verify: only explicit read/verify/deterministic-check evidence on the
|
|
555
|
+
* subject closes; unrelated scope calls cannot be spliced in.
|
|
556
|
+
* - explicit method without a parsable operation fails closed.
|
|
557
|
+
* - a non-enforced item (prohibition) is acknowledged by any valid success
|
|
558
|
+
* evidence.
|
|
559
|
+
*/
|
|
560
|
+
function bindingSatisfies(projection, item, evidenceIds) {
|
|
561
|
+
if (!item.verification.enforced) return evidenceIds.every((id) => {
|
|
562
|
+
const value = projection.evidence.get(id);
|
|
563
|
+
return !!value && value.epoch === projection.epoch && value.outcome === "success";
|
|
564
|
+
});
|
|
565
|
+
const { method, operation } = item.verification;
|
|
566
|
+
if (method && operation === void 0) return false;
|
|
567
|
+
let artifact = false;
|
|
568
|
+
let effect = false;
|
|
569
|
+
let verify = false;
|
|
570
|
+
let run = false;
|
|
571
|
+
const stateEvidenceIds = /* @__PURE__ */ new Set();
|
|
572
|
+
const effectEvidenceIds = /* @__PURE__ */ new Set();
|
|
573
|
+
for (const id of evidenceIds) {
|
|
574
|
+
const value = projection.evidence.get(id);
|
|
575
|
+
if (!value || value.epoch !== projection.epoch) return false;
|
|
576
|
+
const coverage = evidenceCoverage(item, value);
|
|
577
|
+
if (!coverage.artifact && !coverage.effect && !coverage.method && !coverage.verify && !coverage.run) return false;
|
|
578
|
+
artifact = artifact || coverage.artifact;
|
|
579
|
+
effect = effect || coverage.effect;
|
|
580
|
+
verify = verify || coverage.verify;
|
|
581
|
+
run = run || coverage.run;
|
|
582
|
+
if (coverage.artifact) stateEvidenceIds.add(id);
|
|
583
|
+
if (coverage.effect) effectEvidenceIds.add(id);
|
|
584
|
+
}
|
|
585
|
+
switch (operation) {
|
|
586
|
+
case "run": return effect;
|
|
587
|
+
case "read": return effect;
|
|
588
|
+
case "create":
|
|
589
|
+
case "write":
|
|
590
|
+
case "modify": {
|
|
591
|
+
const independentState = [...stateEvidenceIds].some((id) => !effectEvidenceIds.has(id));
|
|
592
|
+
const independentEffect = [...effectEvidenceIds].some((id) => !stateEvidenceIds.has(id));
|
|
593
|
+
return effect && independentEffect && independentState;
|
|
594
|
+
}
|
|
595
|
+
case "verify": return verify;
|
|
596
|
+
default: return artifact;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
//#endregion
|
|
601
|
+
//#region src/domain/recovery.ts
|
|
602
|
+
const DEFAULT_RECOVERY_CHAR_BUDGET = 4e3;
|
|
603
|
+
const MAX_RECOVERY_ITEMS = 8;
|
|
604
|
+
const MAX_RECOVERY_EVIDENCE = 20;
|
|
605
|
+
const MORE_ITEMS_RULE = (remaining) => `…(${remaining} more open items; the full list is in the checkpoint tool response)`;
|
|
606
|
+
const MORE_EVIDENCE_RULE = (remaining) => `…(${remaining} more evidence rows)`;
|
|
607
|
+
const COMPLETION_RULE = "Obtain a Context Guard checkpoint from matching durable evidence before claiming completion.";
|
|
608
|
+
/**
|
|
609
|
+
* An actionable one-line hint for how an open item's verification contract can
|
|
610
|
+
* be closed. It never weakens the contract; it only names the missing facet so
|
|
611
|
+
* the agent can produce the right evidence shape instead of reverse-engineering
|
|
612
|
+
* the guard. When `evidenceIds` is given, the hint accounts for what those
|
|
613
|
+
* evidence already cover.
|
|
614
|
+
*/
|
|
615
|
+
function closingHint(projection, item, evidenceIds) {
|
|
616
|
+
const verification = item.verification;
|
|
617
|
+
const parts = [];
|
|
618
|
+
if (evidenceIds?.length) {
|
|
619
|
+
if (!evidenceIds.map((id) => projection.evidence.get(id)).filter((value) => value !== void 0).map((value) => evidenceCoverage(item, value)).some((facet) => facet.artifact || facet.effect || facet.method || facet.verify || facet.run)) parts.push("cited evidence matches no facet");
|
|
620
|
+
}
|
|
621
|
+
if (verification.method) parts.push(`method '${verification.method}'`);
|
|
622
|
+
if (verification.subject && verification.surface === "artifact") parts.push(`subject '${verification.subject}'`);
|
|
623
|
+
if (verification.subject && verification.surface === "scope") parts.push("in the scope directory");
|
|
624
|
+
const operation = verification.operation;
|
|
625
|
+
if (operation === "run") parts.push("needs a scope run effect: a whitelisted executable (git/pnpm/python/dsh/...) without pipes, `;` or `&&`, e.g. `python -m unittest`");
|
|
626
|
+
else if (operation === "create" || operation === "write" || operation === "modify") parts.push("needs an effect evidence AND an independent same-subject state verification (read tool or a deterministic check)");
|
|
627
|
+
else if (operation === "verify") parts.push("needs a read or deterministic-check evidence on the contract subject");
|
|
628
|
+
else if (operation === "read") parts.push("needs a read evidence on the contract subject");
|
|
629
|
+
else parts.push("needs a state-verification evidence (read tool, or a deterministic check run in scope) matching the subject");
|
|
630
|
+
return parts.join("; ");
|
|
631
|
+
}
|
|
632
|
+
function openItems$1(projection) {
|
|
633
|
+
return [...projection.items.values()].filter((item) => item.status === "pending").sort((a, b) => a.revision - b.revision || (a.id < b.id ? -1 : 1));
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Content identity of a rendered recovery packet, bound to the contract
|
|
637
|
+
* revision and epoch it was rendered from. The runtime compares digests before
|
|
638
|
+
* re-injecting, so a repeatedly re-armed recovery with unchanged content is
|
|
639
|
+
* injected once instead of looping (v0.2.1).
|
|
640
|
+
*/
|
|
641
|
+
function recoveryDigest(packet, projection) {
|
|
642
|
+
return sha256(JSON.stringify({
|
|
643
|
+
packet,
|
|
644
|
+
revision: projection.contractRevision,
|
|
645
|
+
epoch: projection.epoch
|
|
646
|
+
}));
|
|
647
|
+
}
|
|
648
|
+
function renderRecoveryPacket(projection, options = {}) {
|
|
649
|
+
const budget = options.charBudget ?? DEFAULT_RECOVERY_CHAR_BUDGET;
|
|
650
|
+
const lines = [];
|
|
651
|
+
let used = 0;
|
|
652
|
+
const push = (line) => {
|
|
653
|
+
if (used + line.length + 1 > budget) return false;
|
|
654
|
+
lines.push(line);
|
|
655
|
+
used += line.length + 1;
|
|
656
|
+
return true;
|
|
657
|
+
};
|
|
658
|
+
const items = openItems$1(projection);
|
|
659
|
+
const listedIds = /* @__PURE__ */ new Set();
|
|
660
|
+
const pushItems = (list, render) => {
|
|
661
|
+
let count = 0;
|
|
662
|
+
for (const item of list) {
|
|
663
|
+
if (count >= MAX_RECOVERY_ITEMS) {
|
|
664
|
+
push(MORE_ITEMS_RULE(list.length - count));
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
if (!push(render(item))) return false;
|
|
668
|
+
listedIds.add(item.id);
|
|
669
|
+
count += 1;
|
|
670
|
+
}
|
|
671
|
+
return true;
|
|
672
|
+
};
|
|
673
|
+
if (!pushItems(items.filter((item) => item.kind === "requirement"), (item) => `[${item.id}] ${item.normalizedText}`)) return finalize();
|
|
674
|
+
if (!pushItems(items.filter((item) => item.kind === "prohibition"), (item) => `[${item.id}] DO NOT ${item.normalizedText}`)) return finalize();
|
|
675
|
+
if (!pushItems(items.filter((item) => item.kind === "acceptance"), (item) => `[${item.id}] VERIFY ${item.normalizedText}`)) return finalize();
|
|
676
|
+
const citableEvidence = [...projection.evidence.values()].filter((evidence) => evidence.epoch === projection.epoch && evidence.outcome === "success").sort((a, b) => a.id < b.id ? -1 : 1);
|
|
677
|
+
let evidenceCount = 0;
|
|
678
|
+
for (const evidence of citableEvidence) {
|
|
679
|
+
if (evidenceCount >= MAX_RECOVERY_EVIDENCE) {
|
|
680
|
+
push(MORE_EVIDENCE_RULE(citableEvidence.length - evidenceCount));
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
if (!push(`evidence ${evidence.id} ${evidence.toolName} ${evidence.subjects.join(",") || "-"} ${evidence.surfaces.join(",")}`)) return finalize();
|
|
684
|
+
evidenceCount += 1;
|
|
685
|
+
}
|
|
686
|
+
for (const item of [...projection.items.values()].filter((item$1) => item$1.status === "superseded")) if (item.supersededBy && !push(`[${item.id} -> ${item.supersededBy}]`)) return finalize();
|
|
687
|
+
for (const binding of options.rejectedBindings ?? []) if (!push(`rejected ${binding.itemId}: ${binding.reason}`)) return finalize();
|
|
688
|
+
for (const item of items) {
|
|
689
|
+
if (item.kind === "prohibition" || !listedIds.has(item.id)) continue;
|
|
690
|
+
push(`closing hint [${item.id}]: ${closingHint(projection, item)}`);
|
|
691
|
+
}
|
|
692
|
+
push(COMPLETION_RULE);
|
|
693
|
+
return finalize();
|
|
694
|
+
function finalize() {
|
|
695
|
+
return lines.join("\n");
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
//#endregion
|
|
700
|
+
//#region src/domain/checkpoint.ts
|
|
701
|
+
function certifyCheckpoint(projection, bindings, id) {
|
|
702
|
+
if (projection.integrity !== "valid") return {
|
|
703
|
+
status: "unknown",
|
|
704
|
+
contractRevision: projection.contractRevision,
|
|
705
|
+
openItems: openItems(projection),
|
|
706
|
+
rejectedBindings: []
|
|
707
|
+
};
|
|
708
|
+
const rejectedBindings = [];
|
|
709
|
+
for (const binding of bindings) {
|
|
710
|
+
const item = projection.items.get(binding.itemId);
|
|
711
|
+
if (!item || item.status === "superseded") {
|
|
712
|
+
rejectedBindings.push({
|
|
713
|
+
itemId: binding.itemId,
|
|
714
|
+
reason: "item is missing or superseded"
|
|
715
|
+
});
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (!binding.evidenceIds.length) {
|
|
719
|
+
rejectedBindings.push({
|
|
720
|
+
itemId: binding.itemId,
|
|
721
|
+
reason: "no evidence cited",
|
|
722
|
+
hint: closingHint(projection, item)
|
|
723
|
+
});
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (!bindingSatisfies(projection, item, binding.evidenceIds)) {
|
|
727
|
+
rejectedBindings.push({
|
|
728
|
+
itemId: binding.itemId,
|
|
729
|
+
reason: "evidence does not match the current verification contract",
|
|
730
|
+
hint: closingHint(projection, item, binding.evidenceIds)
|
|
731
|
+
});
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const open = openItems(projection).filter((itemId) => !bindings.some((binding) => binding.itemId === itemId));
|
|
736
|
+
if (rejectedBindings.length || open.length) return {
|
|
737
|
+
status: "incomplete",
|
|
738
|
+
contractRevision: projection.contractRevision,
|
|
739
|
+
openItems: openItems(projection),
|
|
740
|
+
rejectedBindings
|
|
741
|
+
};
|
|
742
|
+
const openDigest = digestStrings(openItems(projection));
|
|
743
|
+
const bindingDigest = sha256(JSON.stringify(bindings));
|
|
744
|
+
const checkpoint = {
|
|
745
|
+
id,
|
|
746
|
+
epoch: projection.epoch,
|
|
747
|
+
contractRevision: projection.contractRevision,
|
|
748
|
+
openDigest,
|
|
749
|
+
bindingDigest,
|
|
750
|
+
bindings,
|
|
751
|
+
result: "certified"
|
|
752
|
+
};
|
|
753
|
+
projection.checkpoints.push(checkpoint);
|
|
754
|
+
for (const binding of bindings) projection.items.get(binding.itemId).status = "passed";
|
|
755
|
+
return {
|
|
756
|
+
status: "certified",
|
|
757
|
+
contractRevision: projection.contractRevision,
|
|
758
|
+
openItems: [],
|
|
759
|
+
rejectedBindings: [],
|
|
760
|
+
checkpoint
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
function openItems(projection) {
|
|
764
|
+
return [...projection.items.values()].filter((item) => item.status === "pending" && item.kind !== "prohibition").map((item) => item.id);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
//#endregion
|
|
768
|
+
//#region src/domain/conversation.ts
|
|
769
|
+
/**
|
|
770
|
+
* Punctuation and whitespace that may surround a bare progression phrase
|
|
771
|
+
* without turning it into sentence content.
|
|
772
|
+
*/
|
|
773
|
+
const PUNCT = String.raw`[\s。,、;:!?.,;:!?\-*"'“”‘’()().…~~]`;
|
|
774
|
+
/**
|
|
775
|
+
* Session-layer phrases that acknowledge or advance the conversation without
|
|
776
|
+
* stating a task. Longer forms come first so the alternation consumes them
|
|
777
|
+
* before their prefixes.
|
|
778
|
+
*/
|
|
779
|
+
const PROGRESSION_SOURCE = String.raw`(?:继续执行|继续吧|请继续|继续|接着做|接着|下一步|没问题|知道了|明白了|了解|好的?|是的?|对的?|收到|可以|行|嗯+|continue|go on|go ahead|keep going|proceed|okay|ok|yes|sure|right|next)`;
|
|
780
|
+
const PROGRESSION_WHOLE = new RegExp(`^${PUNCT}*${PROGRESSION_SOURCE}${PUNCT}*$`, "i");
|
|
781
|
+
const PROGRESSION_LEAD = new RegExp(`^${PROGRESSION_SOURCE}${PUNCT}+`, "i");
|
|
782
|
+
const PROGRESSION_ANYWHERE = new RegExp(PROGRESSION_SOURCE, "gi");
|
|
783
|
+
/**
|
|
784
|
+
* Clause-leading prohibition keywords. A message that opens with one is a
|
|
785
|
+
* captured prohibition, never a meta comment.
|
|
786
|
+
*/
|
|
787
|
+
const PROHIBITION_LEAD = /^(?:(?:do not|don't|never)(?![A-Za-z0-9_./@\\-])|禁止|不要|不得)/i;
|
|
788
|
+
/**
|
|
789
|
+
* Question markers: a question mark, an interrogative pronoun/particle, or an
|
|
790
|
+
* explicit request-for-answer phrase.
|
|
791
|
+
*/
|
|
792
|
+
const QUESTION_TERMS = /[??]|什么|为什么|怎么|如何|是否|是不是|哪|谁|啥|吗|呢|对不对|正常吗|bug吗|有问题吗|有必要|合理吗|可否|能否|能不能|请问|问一下/;
|
|
793
|
+
/**
|
|
794
|
+
* Meta-comment/objection leads (no question mark required). `不是` requires
|
|
795
|
+
* trailing punctuation so negated statements ("不是都要推送") stay fail-closed.
|
|
796
|
+
*/
|
|
797
|
+
const META_COMMENT_LEAD = /^(?:不是[,,。;;::\s]|你(?:这|光|啥|怎么|什么|到底|就)|我(?:只是|就是|想|问|建议|认为|觉得)|这(?:有|什么)意义|有什么用|有什么意义)/;
|
|
798
|
+
/** Diagnostic/inspection verbs: mentioning them alone is never a task feature. */
|
|
799
|
+
const META_VERBS = /确认下|看看|看一下|想问|确认|验证|检查|查看|分析|解释|说明|排查|定位|诊断|评估|考虑|建议|讨论|复查|核对|盘点|复盘|问|看/g;
|
|
800
|
+
/**
|
|
801
|
+
* Operation verbs that indicate a real task effect. English verbs are
|
|
802
|
+
* word-bounded so "latest" does not contain "test". The classifier vocabulary
|
|
803
|
+
* is intentionally independent from the command-surface manifest.
|
|
804
|
+
*/
|
|
805
|
+
const OPERATION_VERBS = /创建|生成|新建|写入|修改|编辑|运行|执行|编写|撰写|起草|整理|总结|记录|更新|修复|改进|解决|处理|推送|发布|安装|升级|提交|下载|上传|拉取|同步|部署|重启|测试|写|\b(?:build|create|write|modify|run|fix|update|install|push|publish|test)\b/gi;
|
|
806
|
+
const NEGATIONS = /没有|并无|不存在|无需|不用|不需要|尚未|还未|没|未|不是/;
|
|
807
|
+
function excludedRanges(text) {
|
|
808
|
+
const ranges = [];
|
|
809
|
+
for (const pattern of [PROGRESSION_ANYWHERE, META_VERBS]) {
|
|
810
|
+
pattern.lastIndex = 0;
|
|
811
|
+
for (const match of text.matchAll(pattern)) {
|
|
812
|
+
const start = match.index;
|
|
813
|
+
ranges.push([start, start + match[0].length]);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return ranges;
|
|
817
|
+
}
|
|
818
|
+
/** The negation filter is scoped to the clause (sentence or comma segment). */
|
|
819
|
+
function isNegatedInClause(text, verbStart) {
|
|
820
|
+
const clause = text.slice(0, verbStart).split(/[。!?;.!?;,,\r\n]/).pop() ?? "";
|
|
821
|
+
return NEGATIONS.test(clause);
|
|
822
|
+
}
|
|
823
|
+
function hasOperationVerb(text) {
|
|
824
|
+
const excluded = excludedRanges(text);
|
|
825
|
+
for (const match of text.matchAll(OPERATION_VERBS)) {
|
|
826
|
+
const start = match.index;
|
|
827
|
+
if (excluded.some(([from, to]) => start >= from && start < to)) continue;
|
|
828
|
+
if (isNegatedInClause(text, start)) continue;
|
|
829
|
+
return true;
|
|
830
|
+
}
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
function hasStrongTaskFeature(text) {
|
|
834
|
+
if (extractArtifactPaths(text).length > 0) return true;
|
|
835
|
+
if (extractMethod(text) !== void 0) return true;
|
|
836
|
+
return hasOperationVerb(text);
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Classify a direct user message (or one clause of it) as an actionable
|
|
840
|
+
* `instruction` or a session-layer `conversational` utterance. Only
|
|
841
|
+
* conversational results drop capture, so the classifier fails closed:
|
|
842
|
+
* everything it cannot confidently recognize as session-layer talk stays an
|
|
843
|
+
* instruction and is captured exactly as before.
|
|
844
|
+
*
|
|
845
|
+
* Order matters: progression and prohibition leads first, then strong task
|
|
846
|
+
* features (artifact path, explicit method, or a non-negated operation verb
|
|
847
|
+
* outside progression/meta spans), then the meta-question and meta-comment
|
|
848
|
+
* forms, and finally a progression lead over a featureless remainder.
|
|
849
|
+
*/
|
|
850
|
+
function classifyUserInteraction(text) {
|
|
851
|
+
const normalized = normalizeClause(text);
|
|
852
|
+
if (!normalized) return "instruction";
|
|
853
|
+
if (PROGRESSION_WHOLE.test(normalized)) return "conversational";
|
|
854
|
+
if (PROHIBITION_LEAD.test(normalized)) return "instruction";
|
|
855
|
+
if (hasStrongTaskFeature(normalized)) return "instruction";
|
|
856
|
+
if (QUESTION_TERMS.test(normalized)) return "conversational";
|
|
857
|
+
if (META_COMMENT_LEAD.test(normalized)) return "conversational";
|
|
858
|
+
if (PROGRESSION_LEAD.test(normalized)) return "conversational";
|
|
859
|
+
return "instruction";
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
//#endregion
|
|
863
|
+
//#region src/domain/shell-parse.ts
|
|
864
|
+
const TWO_CHAR_OPS = new Set([
|
|
865
|
+
"&&",
|
|
866
|
+
"||",
|
|
867
|
+
">>",
|
|
868
|
+
"<<",
|
|
869
|
+
"<&",
|
|
870
|
+
">&",
|
|
871
|
+
"|&"
|
|
872
|
+
]);
|
|
873
|
+
const STATEMENT_OPS = new Set([
|
|
874
|
+
"&&",
|
|
875
|
+
"||",
|
|
876
|
+
"|",
|
|
877
|
+
"|&",
|
|
878
|
+
"&",
|
|
879
|
+
";",
|
|
880
|
+
"\n",
|
|
881
|
+
"(",
|
|
882
|
+
")"
|
|
883
|
+
]);
|
|
884
|
+
/**
|
|
885
|
+
* Quote-aware shell tokenizer. Single quotes are literal, double quotes allow
|
|
886
|
+
* `\` escapes, and backslash escapes are honored outside quotes. Unterminated
|
|
887
|
+
* quotes mark the input as malformed.
|
|
888
|
+
*/
|
|
889
|
+
function tokenizeShell(command) {
|
|
890
|
+
const tokens = [];
|
|
891
|
+
let index = 0;
|
|
892
|
+
let malformed = false;
|
|
893
|
+
const length = command.length;
|
|
894
|
+
while (index < length) {
|
|
895
|
+
const char = command[index];
|
|
896
|
+
if (char === "\n" || char === "\r") {
|
|
897
|
+
tokens.push({
|
|
898
|
+
kind: "op",
|
|
899
|
+
value: "\n",
|
|
900
|
+
quoted: false
|
|
901
|
+
});
|
|
902
|
+
index += char === "\r" && command[index + 1] === "\n" ? 2 : 1;
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
if (char === " " || char === " ") {
|
|
906
|
+
index += 1;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
const two = command.slice(index, index + 2);
|
|
910
|
+
if (TWO_CHAR_OPS.has(two)) {
|
|
911
|
+
tokens.push({
|
|
912
|
+
kind: "op",
|
|
913
|
+
value: two,
|
|
914
|
+
quoted: false
|
|
915
|
+
});
|
|
916
|
+
index += 2;
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
if (char === ";" || char === "|" || char === "&" || char === "(" || char === ")" || char === "<" || char === ">") {
|
|
920
|
+
tokens.push({
|
|
921
|
+
kind: "op",
|
|
922
|
+
value: char,
|
|
923
|
+
quoted: false
|
|
924
|
+
});
|
|
925
|
+
index += 1;
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
let word = "";
|
|
929
|
+
let quoted = false;
|
|
930
|
+
let quote = null;
|
|
931
|
+
while (index < length) {
|
|
932
|
+
const current = command[index];
|
|
933
|
+
if (quote === "'") {
|
|
934
|
+
if (current === "'") {
|
|
935
|
+
quote = null;
|
|
936
|
+
index += 1;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
quoted = true;
|
|
940
|
+
word += current;
|
|
941
|
+
index += 1;
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
if (quote === "\"") {
|
|
945
|
+
if (current === "\"") {
|
|
946
|
+
quote = null;
|
|
947
|
+
index += 1;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
quoted = true;
|
|
951
|
+
if (current === "\\" && index + 1 < length) {
|
|
952
|
+
word += command[index + 1];
|
|
953
|
+
index += 2;
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
word += current;
|
|
957
|
+
index += 1;
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
if (current === "'") {
|
|
961
|
+
quote = "'";
|
|
962
|
+
index += 1;
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
if (current === "\"") {
|
|
966
|
+
quote = "\"";
|
|
967
|
+
index += 1;
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
if (current === "\\" && index + 1 < length) {
|
|
971
|
+
word += command[index + 1];
|
|
972
|
+
index += 2;
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
if (current === " " || current === " " || current === "\n" || current === "\r") break;
|
|
976
|
+
if (current === ";" || current === "|" || current === "&" || current === "(" || current === ")" || current === "<" || current === ">") break;
|
|
977
|
+
if (TWO_CHAR_OPS.has(command.slice(index, index + 2))) break;
|
|
978
|
+
word += current;
|
|
979
|
+
index += 1;
|
|
980
|
+
}
|
|
981
|
+
if (quote !== null) {
|
|
982
|
+
malformed = true;
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
if (word) tokens.push({
|
|
986
|
+
kind: "word",
|
|
987
|
+
value: word,
|
|
988
|
+
quoted
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
return {
|
|
992
|
+
tokens,
|
|
993
|
+
malformed
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
/** Characters that indicate non-literal paths (variables, expansion, globs). */
|
|
997
|
+
const DYNAMIC_PATH = /[$`~*?[\]{}]/u;
|
|
998
|
+
function isLiteralPath(value) {
|
|
999
|
+
return value.length > 0 && !DYNAMIC_PATH.test(value);
|
|
1000
|
+
}
|
|
1001
|
+
/** v0.1 whitelist: single foreground simple commands only (manifest-driven). */
|
|
1002
|
+
const SHELL_FILE_TOOLS = new Set(COMMAND_SURFACE_MANIFEST.fileTools);
|
|
1003
|
+
/** Read-only inspection tools: every pathish argument counts as a read effect. */
|
|
1004
|
+
const SHELL_READ_TOOLS = new Set(COMMAND_SURFACE_MANIFEST.readTools);
|
|
1005
|
+
const SHELL_RUN_EXECUTABLES = new Set(COMMAND_SURFACE_MANIFEST.runExecutables);
|
|
1006
|
+
/**
|
|
1007
|
+
* Whether an executable carries run semantics (as opposed to the tiny
|
|
1008
|
+
* file/read tool subset). Used for scope-subject attribution of a pathless
|
|
1009
|
+
* run operation; `echo` or `cat` never becomes a subject-carrying run.
|
|
1010
|
+
*/
|
|
1011
|
+
function isRunExecutable(executable) {
|
|
1012
|
+
return SHELL_RUN_EXECUTABLES.has(executable.toLowerCase());
|
|
1013
|
+
}
|
|
1014
|
+
/** Looks like a filesystem path: contains a separator, or a file extension. */
|
|
1015
|
+
function isPathish(value) {
|
|
1016
|
+
return /[\\/]/.test(value) || /^\.\.?(\/|$)/.test(value) || /\.(?:[A-Za-z0-9][A-Za-z0-9_-]{0,15})$/.test(value);
|
|
1017
|
+
}
|
|
1018
|
+
function unsupported(reason) {
|
|
1019
|
+
return {
|
|
1020
|
+
status: "unsupported",
|
|
1021
|
+
reason,
|
|
1022
|
+
executables: [],
|
|
1023
|
+
operations: [],
|
|
1024
|
+
malformed: false
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Parse one POSIX shell command against the v0.1 supported surface: a single
|
|
1029
|
+
* foreground simple command made of an env-assignment prefix, one whitelisted
|
|
1030
|
+
* executable and literal arguments, with at most one `>`/`>>` redirect to a
|
|
1031
|
+
* literal path. Compound syntax (`;`, `&&`, `||`, pipes, background, subshells,
|
|
1032
|
+
* command substitution, heredocs, unclosed quotes, dynamic eval/source,
|
|
1033
|
+
* variable/glob paths) makes the WHOLE command unsupported with no partial
|
|
1034
|
+
* results.
|
|
1035
|
+
*/
|
|
1036
|
+
function parseShellCommand(command) {
|
|
1037
|
+
const { tokens, malformed } = tokenizeShell(command);
|
|
1038
|
+
if (malformed) return {
|
|
1039
|
+
status: "malformed",
|
|
1040
|
+
reason: "unterminated quote",
|
|
1041
|
+
executables: [],
|
|
1042
|
+
operations: [],
|
|
1043
|
+
malformed: true
|
|
1044
|
+
};
|
|
1045
|
+
if (tokens.length === 0) return {
|
|
1046
|
+
status: "supported",
|
|
1047
|
+
executables: [],
|
|
1048
|
+
operations: [],
|
|
1049
|
+
malformed: false
|
|
1050
|
+
};
|
|
1051
|
+
const writePaths = [];
|
|
1052
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
1053
|
+
const token = tokens[index];
|
|
1054
|
+
if (token.kind === "word" && /^\d+$/.test(token.value) && tokens[index + 1]?.kind === "op" && tokens[index + 1]?.value === ">&" && tokens[index + 2]?.kind === "word" && /^\d+$/.test(tokens[index + 2].value)) {
|
|
1055
|
+
index += 2;
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
if (token.kind === "word" && /^\d+$/.test(token.value) && tokens[index + 1]?.kind === "op" && (tokens[index + 1]?.value === ">" || tokens[index + 1]?.value === ">>")) return unsupported("file-descriptor-prefixed file redirect is not in the v0.1 subset");
|
|
1059
|
+
if (token.kind === "op") {
|
|
1060
|
+
if (token.value === ">") {
|
|
1061
|
+
const next = tokens[index + 1];
|
|
1062
|
+
if (!next || next.kind !== "word") return unsupported("redirect target is not a literal word");
|
|
1063
|
+
if (!isLiteralPath(next.value)) return unsupported("non-literal redirect path");
|
|
1064
|
+
writePaths.push(next.value);
|
|
1065
|
+
index += 1;
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
if (token.value === ">>" || token.value === "<" || token.value === "<<" || token.value === "<&" || token.value === ">&") return unsupported(`redirect '${token.value}' is not in the v0.1 subset`);
|
|
1069
|
+
if (STATEMENT_OPS.has(token.value)) return unsupported(`statement operator '${token.value}' is not in the v0.1 subset`);
|
|
1070
|
+
return unsupported(`operator '${token.value}' is not in the v0.1 subset`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
if (writePaths.length > 1) return unsupported("multiple write redirects are not in the v0.1 subset");
|
|
1074
|
+
const wordTokens = tokens.filter((token) => token.kind === "word");
|
|
1075
|
+
let executableIndex = 0;
|
|
1076
|
+
while (executableIndex < wordTokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(wordTokens[executableIndex].value)) executableIndex += 1;
|
|
1077
|
+
const executableToken = wordTokens[executableIndex];
|
|
1078
|
+
const executable = executableToken?.value ?? "";
|
|
1079
|
+
if (!executable) return unsupported("no executable");
|
|
1080
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(executable)) return unsupported("executable is not a plain literal name");
|
|
1081
|
+
if (executableToken.quoted) return unsupported("quoted executable is not in the v0.1 subset");
|
|
1082
|
+
if (wordTokens.slice(0, executableIndex).map((token) => token.value).some((word) => !isLiteralPath(word))) return unsupported("dynamic environment assignment");
|
|
1083
|
+
const exe = executable.toLowerCase();
|
|
1084
|
+
if (!SHELL_FILE_TOOLS.has(exe) && !SHELL_READ_TOOLS.has(exe) && !SHELL_RUN_EXECUTABLES.has(exe)) return unsupported(`executable '${executable}' is not in the v0.1 whitelist`);
|
|
1085
|
+
const args = wordTokens.slice(executableIndex + 1).map((token) => token.value);
|
|
1086
|
+
if (args.some((arg) => !isLiteralPath(arg))) return unsupported("non-literal argument");
|
|
1087
|
+
const pathishArgs = args.filter((arg) => isPathish(arg));
|
|
1088
|
+
const operations = [];
|
|
1089
|
+
for (const path$1 of writePaths) operations.push({
|
|
1090
|
+
op: "create",
|
|
1091
|
+
path: path$1
|
|
1092
|
+
});
|
|
1093
|
+
if (exe === "touch") for (const path$1 of pathishArgs) operations.push({
|
|
1094
|
+
op: "create",
|
|
1095
|
+
path: path$1
|
|
1096
|
+
});
|
|
1097
|
+
else if (SHELL_READ_TOOLS.has(exe)) {
|
|
1098
|
+
if (exe === "sed" && args.some((arg) => /^-i($|[A-Za-z0-9])|^--in-place/.test(arg))) return unsupported("in-place sed editing is not in the v0.1 subset");
|
|
1099
|
+
for (const path$1 of pathishArgs) operations.push({
|
|
1100
|
+
op: "read",
|
|
1101
|
+
path: path$1
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
operations.push({
|
|
1105
|
+
op: "run",
|
|
1106
|
+
...pathishArgs[0] !== void 0 ? { path: pathishArgs[0] } : {}
|
|
1107
|
+
});
|
|
1108
|
+
return {
|
|
1109
|
+
status: "supported",
|
|
1110
|
+
executables: [executable],
|
|
1111
|
+
operations,
|
|
1112
|
+
malformed: false
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
const PWSH_CMDLETS = {
|
|
1116
|
+
"set-content": {
|
|
1117
|
+
op: "create",
|
|
1118
|
+
pathParams: ["-path", "-literalpath"],
|
|
1119
|
+
valueParams: ["-value", "-encoding"],
|
|
1120
|
+
switchParams: ["-nonewline"]
|
|
1121
|
+
},
|
|
1122
|
+
"add-content": {
|
|
1123
|
+
op: "create",
|
|
1124
|
+
pathParams: ["-path", "-literalpath"],
|
|
1125
|
+
valueParams: ["-value", "-encoding"],
|
|
1126
|
+
switchParams: ["-nonewline"]
|
|
1127
|
+
},
|
|
1128
|
+
"new-item": {
|
|
1129
|
+
op: "create",
|
|
1130
|
+
pathParams: ["-path"],
|
|
1131
|
+
valueParams: ["-value", "-itemtype"],
|
|
1132
|
+
switchParams: []
|
|
1133
|
+
},
|
|
1134
|
+
"out-file": {
|
|
1135
|
+
op: "create",
|
|
1136
|
+
pathParams: ["-filepath", "-literalpath"],
|
|
1137
|
+
valueParams: ["-encoding"],
|
|
1138
|
+
switchParams: ["-nonewline"]
|
|
1139
|
+
},
|
|
1140
|
+
"get-content": {
|
|
1141
|
+
op: "read",
|
|
1142
|
+
pathParams: ["-path", "-literalpath"],
|
|
1143
|
+
valueParams: ["-encoding"],
|
|
1144
|
+
switchParams: ["-raw"]
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
/** PowerShell tokenizer: quoted strings (backtick-escaped) are one word. */
|
|
1148
|
+
function tokenizePwsh(command) {
|
|
1149
|
+
const words = [];
|
|
1150
|
+
let index = 0;
|
|
1151
|
+
let malformed = false;
|
|
1152
|
+
const length = command.length;
|
|
1153
|
+
while (index < length) {
|
|
1154
|
+
const char = command[index];
|
|
1155
|
+
if (char === " " || char === " " || char === "\n" || char === "\r") {
|
|
1156
|
+
index += 1;
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
if (char === "'" || char === "\"") {
|
|
1160
|
+
const quote = char;
|
|
1161
|
+
let word$1 = "";
|
|
1162
|
+
let closed = false;
|
|
1163
|
+
index += 1;
|
|
1164
|
+
while (index < length) {
|
|
1165
|
+
const current = command[index];
|
|
1166
|
+
if (current === "`") {
|
|
1167
|
+
malformed = true;
|
|
1168
|
+
break;
|
|
1169
|
+
}
|
|
1170
|
+
if (current === quote) {
|
|
1171
|
+
closed = true;
|
|
1172
|
+
index += 1;
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
word$1 += current;
|
|
1176
|
+
index += 1;
|
|
1177
|
+
}
|
|
1178
|
+
if (!closed) malformed = true;
|
|
1179
|
+
words.push({
|
|
1180
|
+
value: word$1,
|
|
1181
|
+
quoted: true
|
|
1182
|
+
});
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
let word = "";
|
|
1186
|
+
while (index < length) {
|
|
1187
|
+
const current = command[index];
|
|
1188
|
+
if (current === " " || current === " " || current === "\n" || current === "\r") break;
|
|
1189
|
+
if (current === "`") malformed = true;
|
|
1190
|
+
word += current;
|
|
1191
|
+
index += 1;
|
|
1192
|
+
if (malformed) break;
|
|
1193
|
+
}
|
|
1194
|
+
words.push({
|
|
1195
|
+
value: word,
|
|
1196
|
+
quoted: false
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
return {
|
|
1200
|
+
words,
|
|
1201
|
+
malformed
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
/** Unsupported PowerShell structure outside quoted strings. */
|
|
1205
|
+
function readPwshUnsupported(command) {
|
|
1206
|
+
let inSingle = false;
|
|
1207
|
+
let inDouble = false;
|
|
1208
|
+
let index = 0;
|
|
1209
|
+
while (index < command.length) {
|
|
1210
|
+
const char = command[index];
|
|
1211
|
+
if (inSingle) {
|
|
1212
|
+
if (char === "'") inSingle = false;
|
|
1213
|
+
index += 1;
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (inDouble) {
|
|
1217
|
+
if (char === "`") return {
|
|
1218
|
+
unsupported: true,
|
|
1219
|
+
reason: "backtick escape"
|
|
1220
|
+
};
|
|
1221
|
+
if (char === "$") return {
|
|
1222
|
+
unsupported: true,
|
|
1223
|
+
reason: "variable or subexpression"
|
|
1224
|
+
};
|
|
1225
|
+
if (char === "\"") inDouble = false;
|
|
1226
|
+
index += 1;
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
if (char === "'") {
|
|
1230
|
+
inSingle = true;
|
|
1231
|
+
index += 1;
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
if (char === "\"") {
|
|
1235
|
+
inDouble = true;
|
|
1236
|
+
index += 1;
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
if (char === "`") return {
|
|
1240
|
+
unsupported: true,
|
|
1241
|
+
reason: "backtick escape"
|
|
1242
|
+
};
|
|
1243
|
+
if (char === "$") return {
|
|
1244
|
+
unsupported: true,
|
|
1245
|
+
reason: "variable or subexpression"
|
|
1246
|
+
};
|
|
1247
|
+
if (char === "\n" || char === "\r") return {
|
|
1248
|
+
unsupported: true,
|
|
1249
|
+
reason: "unquoted newline"
|
|
1250
|
+
};
|
|
1251
|
+
if (char === "&") {
|
|
1252
|
+
const previous = command[index - 1] ?? "";
|
|
1253
|
+
const next = command[index + 1] ?? "";
|
|
1254
|
+
if (previous === ">" && /[0-9]/.test(next)) {
|
|
1255
|
+
index += 1;
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
return {
|
|
1259
|
+
unsupported: true,
|
|
1260
|
+
reason: "structure character &"
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
if (char === ";" || char === "|" || char === "{" || char === "}" || char === "(" || char === ")" || char === "[" || char === "]" || char === ",") return {
|
|
1264
|
+
unsupported: true,
|
|
1265
|
+
reason: `structure character '${char}'`
|
|
1266
|
+
};
|
|
1267
|
+
index += 1;
|
|
1268
|
+
}
|
|
1269
|
+
if (/^\s*\./.test(command)) return {
|
|
1270
|
+
unsupported: true,
|
|
1271
|
+
reason: "dot sourcing"
|
|
1272
|
+
};
|
|
1273
|
+
return { unsupported: false };
|
|
1274
|
+
}
|
|
1275
|
+
/** PowerShell v0.2 subset: external executables with literal arguments. */
|
|
1276
|
+
const PWSH_EXTERNAL_EXECUTABLES = new Set(COMMAND_SURFACE_MANIFEST.pwshExternalExecutables);
|
|
1277
|
+
/**
|
|
1278
|
+
* Parse one PowerShell command against the v0.2 subset: a single, directly
|
|
1279
|
+
* invoked whitelisted cmdlet (Set-Content / Add-Content / New-Item /
|
|
1280
|
+
* Out-File / Get-Content) whose path comes from an explicit named path
|
|
1281
|
+
* parameter, or a whitelisted external executable (git, pnpm, node, …) with
|
|
1282
|
+
* all-literal arguments. Unquoted `N>&M` diagnostic stream duplication is
|
|
1283
|
+
* stripped. Multi-statements (`;`), pipelines (`|`), the call operator (`&`),
|
|
1284
|
+
* script blocks, dot sourcing, .NET/dynamic invocation,
|
|
1285
|
+
* variable/expression/subexpression paths, positional paths, and unknown
|
|
1286
|
+
* parameters make the WHOLE command unsupported.
|
|
1287
|
+
*/
|
|
1288
|
+
function parsePwshCommand(command) {
|
|
1289
|
+
const dynamic = readPwshUnsupported(command);
|
|
1290
|
+
if (dynamic.unsupported) return unsupported(`dynamic or compound PowerShell syntax (${dynamic.reason ?? "unknown"})`);
|
|
1291
|
+
const { words: rawWords, malformed } = tokenizePwsh(command);
|
|
1292
|
+
if (malformed) return {
|
|
1293
|
+
status: "malformed",
|
|
1294
|
+
reason: "unterminated quote or escape",
|
|
1295
|
+
executables: [],
|
|
1296
|
+
operations: [],
|
|
1297
|
+
malformed: true
|
|
1298
|
+
};
|
|
1299
|
+
const words = rawWords.filter((word) => !(word.quoted === false && /^[0-9]*>&[0-9]+$/.test(word.value)));
|
|
1300
|
+
if (words.length === 0) return {
|
|
1301
|
+
status: "supported",
|
|
1302
|
+
executables: [],
|
|
1303
|
+
operations: [],
|
|
1304
|
+
malformed: false
|
|
1305
|
+
};
|
|
1306
|
+
const cmdletRaw = words[0].value;
|
|
1307
|
+
const spec = PWSH_CMDLETS[cmdletRaw.toLowerCase()];
|
|
1308
|
+
const external = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(cmdletRaw) && PWSH_EXTERNAL_EXECUTABLES.has(cmdletRaw.toLowerCase());
|
|
1309
|
+
if (!spec && !external) return unsupported(`command '${cmdletRaw}' is not in the v0.1 whitelist`);
|
|
1310
|
+
if (words[0].quoted) return unsupported("quoted command name is not in the v0.1 subset");
|
|
1311
|
+
if (!/^[A-Za-z][A-Za-z0-9-]*$/.test(cmdletRaw)) return unsupported("dynamic or .NET invocation is not in the v0.1 subset");
|
|
1312
|
+
if (external) {
|
|
1313
|
+
const args = words.slice(1).map((token) => token.value);
|
|
1314
|
+
if (args.some((arg) => !isLiteralPath(arg))) return unsupported("non-literal argument");
|
|
1315
|
+
const pathishArgs = args.filter((arg) => isPathish(arg));
|
|
1316
|
+
return {
|
|
1317
|
+
status: "supported",
|
|
1318
|
+
executables: [cmdletRaw],
|
|
1319
|
+
operations: [{
|
|
1320
|
+
op: "run",
|
|
1321
|
+
...pathishArgs[0] !== void 0 ? { path: pathishArgs[0] } : {}
|
|
1322
|
+
}],
|
|
1323
|
+
malformed: false
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
const paths = [];
|
|
1327
|
+
let expected = null;
|
|
1328
|
+
for (let index = 1; index < words.length; index += 1) {
|
|
1329
|
+
const token = words[index];
|
|
1330
|
+
const low = token.value.toLowerCase();
|
|
1331
|
+
if (token.value.startsWith("-")) {
|
|
1332
|
+
if (spec.pathParams.includes(low)) {
|
|
1333
|
+
expected = "path";
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
if (spec.valueParams.includes(low)) {
|
|
1337
|
+
expected = "value";
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
if (spec.switchParams.includes(low)) {
|
|
1341
|
+
expected = null;
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
return unsupported(`parameter '${token.value}' is not in the v0.1 whitelist`);
|
|
1345
|
+
}
|
|
1346
|
+
if (expected === "path") {
|
|
1347
|
+
if (!isLiteralPath(token.value)) return unsupported("non-literal path");
|
|
1348
|
+
paths.push(token.value);
|
|
1349
|
+
expected = null;
|
|
1350
|
+
continue;
|
|
1351
|
+
}
|
|
1352
|
+
if (expected === "value") {
|
|
1353
|
+
expected = null;
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
return unsupported("positional argument is not in the v0.1 subset");
|
|
1357
|
+
}
|
|
1358
|
+
if (expected !== null) return unsupported("missing parameter value");
|
|
1359
|
+
const operations = [];
|
|
1360
|
+
for (const path$1 of paths) operations.push({
|
|
1361
|
+
op: spec.op,
|
|
1362
|
+
path: path$1
|
|
1363
|
+
});
|
|
1364
|
+
return {
|
|
1365
|
+
status: "supported",
|
|
1366
|
+
executables: [cmdletRaw],
|
|
1367
|
+
operations,
|
|
1368
|
+
malformed: false
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
//#endregion
|
|
1373
|
+
//#region src/domain/evidence.ts
|
|
1374
|
+
function boundedSummary(value) {
|
|
1375
|
+
return value.length > 240 ? `${value.slice(0, 237)}...` : value;
|
|
1376
|
+
}
|
|
1377
|
+
function parseArguments$1(raw) {
|
|
1378
|
+
if (!raw) return {};
|
|
1379
|
+
try {
|
|
1380
|
+
const parsed = JSON.parse(raw);
|
|
1381
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
1382
|
+
} catch {
|
|
1383
|
+
return {};
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
function asRecord$1(value) {
|
|
1387
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
1388
|
+
}
|
|
1389
|
+
function extractTextContent(content) {
|
|
1390
|
+
const parts = [];
|
|
1391
|
+
for (const block of content) {
|
|
1392
|
+
const record = asRecord$1(block);
|
|
1393
|
+
if (!record) continue;
|
|
1394
|
+
if (record.type === "text" && typeof record.text === "string") parts.push(record.text);
|
|
1395
|
+
if (record.type === "tool-result" && Array.isArray(record.content)) parts.push(extractTextContent(record.content));
|
|
1396
|
+
}
|
|
1397
|
+
return parts.join("\n");
|
|
1398
|
+
}
|
|
1399
|
+
function metaPaths(meta) {
|
|
1400
|
+
const record = asRecord$1(meta);
|
|
1401
|
+
if (!record) return [];
|
|
1402
|
+
if (typeof record.path === "string") return [record.path];
|
|
1403
|
+
if (Array.isArray(record.diffs)) return record.diffs.map((diff) => asRecord$1(diff)?.path).filter((path$1) => typeof path$1 === "string");
|
|
1404
|
+
return [];
|
|
1405
|
+
}
|
|
1406
|
+
function argsPaths(args) {
|
|
1407
|
+
const filePath = args.file_path;
|
|
1408
|
+
if (typeof filePath === "string") return [filePath];
|
|
1409
|
+
return [];
|
|
1410
|
+
}
|
|
1411
|
+
/** Resolve a relative command path reference against the command workdir. */
|
|
1412
|
+
function resolveCommandPath(reference, cwd) {
|
|
1413
|
+
if (!cwd) return reference;
|
|
1414
|
+
if (/^[A-Za-z]:[\\/]/.test(reference) || reference.startsWith("//") || reference.startsWith("\\\\") || reference.startsWith("/") || reference.startsWith("\\")) return reference;
|
|
1415
|
+
return `${cwd.replace(/[\\/]+$/, "")}/${reference}`;
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Analyze a shell/pwsh command against the v0.1 supported surface. Only a
|
|
1419
|
+
* fully supported command produces executables/operations; unsupported or
|
|
1420
|
+
* malformed syntax yields EMPTY executables and operations (fail-closed), so a
|
|
1421
|
+
* partially understood command can never certify an operation.
|
|
1422
|
+
*/
|
|
1423
|
+
function analyzeCommand(command, workdir, toolName) {
|
|
1424
|
+
const cwd = typeof workdir === "string" ? workdir : void 0;
|
|
1425
|
+
const parsed = toolName === "pwsh" ? parsePwshCommand(command) : parseShellCommand(command);
|
|
1426
|
+
if (parsed.status !== "supported") return {
|
|
1427
|
+
status: parsed.status,
|
|
1428
|
+
reason: parsed.reason,
|
|
1429
|
+
executables: [],
|
|
1430
|
+
operations: [],
|
|
1431
|
+
subjects: cwd ? [cwd] : []
|
|
1432
|
+
};
|
|
1433
|
+
const operations = parsed.operations.map((entry) => {
|
|
1434
|
+
let path$1 = entry.path !== void 0 ? resolveCommandPath(entry.path, cwd) : void 0;
|
|
1435
|
+
if (path$1 === void 0 && entry.op === "run" && cwd !== void 0 && parsed.executables.some((executable) => isRunExecutable(executable))) path$1 = cwd;
|
|
1436
|
+
return {
|
|
1437
|
+
op: entry.op,
|
|
1438
|
+
...path$1 !== void 0 ? { path: path$1 } : {}
|
|
1439
|
+
};
|
|
1440
|
+
});
|
|
1441
|
+
const subjects = unique([...cwd ? [cwd] : [], ...operations.map((entry) => entry.path).filter((path$1) => path$1 !== void 0)]);
|
|
1442
|
+
return {
|
|
1443
|
+
status: parsed.status,
|
|
1444
|
+
reason: parsed.reason,
|
|
1445
|
+
executables: parsed.executables,
|
|
1446
|
+
operations,
|
|
1447
|
+
subjects
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
const PERSISTENT_RESET_LINE = /^The persistent (?:bash|pwsh) shell was reset;/;
|
|
1451
|
+
const PERSISTENT_TIMEOUT_INTRO = /^Your command timed out after \d+ seconds or experienced an OOM error\. Below is partial output:$/;
|
|
1452
|
+
/**
|
|
1453
|
+
* Structured terminal facts from the tool/result meta (defensive): the pinned
|
|
1454
|
+
* shell renderers currently emit text markers only, but the underlying run
|
|
1455
|
+
* result carries exitCode/signal, so a future harness that surfaces them in
|
|
1456
|
+
* `meta` is trusted directly. Absent structured facts, text scanning remains
|
|
1457
|
+
* the fallback.
|
|
1458
|
+
*/
|
|
1459
|
+
function structuredTerminalFacts(meta) {
|
|
1460
|
+
const record = asRecord$1(meta);
|
|
1461
|
+
if (!record) return void 0;
|
|
1462
|
+
const rawExit = record.exitCode ?? record.exit_code;
|
|
1463
|
+
const rawSignal = record.signal;
|
|
1464
|
+
if (rawSignal !== void 0 && rawSignal !== null) return {
|
|
1465
|
+
exitCode: typeof rawExit === "number" ? rawExit : void 0,
|
|
1466
|
+
negative: true
|
|
1467
|
+
};
|
|
1468
|
+
if (typeof rawExit === "number") return {
|
|
1469
|
+
exitCode: rawExit,
|
|
1470
|
+
negative: false
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
function extractTerminalFacts(textContent) {
|
|
1474
|
+
const lines = textContent.split(/\r?\n/);
|
|
1475
|
+
let index = lines.length - 1;
|
|
1476
|
+
while (index >= 0 && lines[index].trim() === "") index -= 1;
|
|
1477
|
+
const resetStripped = index >= 0 && PERSISTENT_RESET_LINE.test(lines[index].trim());
|
|
1478
|
+
if (resetStripped) {
|
|
1479
|
+
index -= 1;
|
|
1480
|
+
while (index >= 0 && lines[index].trim() === "") index -= 1;
|
|
1481
|
+
}
|
|
1482
|
+
const timeoutIntroAtHead = resetStripped && lines.length > 0 && PERSISTENT_TIMEOUT_INTRO.test(lines[0].trim());
|
|
1483
|
+
let exitCode;
|
|
1484
|
+
let negative = timeoutIntroAtHead;
|
|
1485
|
+
while (index >= 0) {
|
|
1486
|
+
const line = lines[index].trim();
|
|
1487
|
+
const exitMatch = line.match(/^\[(?:exit code|shell exited: code)\s*:?\s*(\d+)\]$/);
|
|
1488
|
+
const negativeLine = /^\[(?:timed out|sandbox[^\]]*|killed by signal[^\]]*|shell killed by signal[^\]]*|shell exited|interrupted[^\]]*)[^\]]*\]$/i.test(line);
|
|
1489
|
+
if (exitMatch) {
|
|
1490
|
+
if (exitCode === void 0) exitCode = Number(exitMatch[1]);
|
|
1491
|
+
} else if (negativeLine) negative = true;
|
|
1492
|
+
else break;
|
|
1493
|
+
index -= 1;
|
|
1494
|
+
}
|
|
1495
|
+
return {
|
|
1496
|
+
exitCode,
|
|
1497
|
+
negative
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
function metaUrls(meta) {
|
|
1501
|
+
const record = asRecord$1(meta);
|
|
1502
|
+
if (!record) return [];
|
|
1503
|
+
if (typeof record.url === "string") return [sanitizeUrl(record.url)];
|
|
1504
|
+
if (Array.isArray(record.sources)) return record.sources.map((source) => asRecord$1(source)?.url).filter((url) => typeof url === "string").map((url) => sanitizeUrl(url));
|
|
1505
|
+
return [];
|
|
1506
|
+
}
|
|
1507
|
+
const DETERMINISTIC_CHECK_PATTERNS = [
|
|
1508
|
+
/\b(?:pnpm|npm|yarn|bun)\s+(?:test|tst|lint|check|typecheck|build)\b/,
|
|
1509
|
+
/\b(?:cargo|go|make|cmake|pytest|vitest|jest|eslint|tsc|mypy|ruff|prettier)\b/,
|
|
1510
|
+
/\b(?:mvn|gradle)\s+(?:test|check)\b/,
|
|
1511
|
+
/\bpython(?:3)?\s+-m\s+(?:unittest|doctest|pytest)\b/
|
|
1512
|
+
];
|
|
1513
|
+
/** Prefixes that only quote or print a command without running a check. */
|
|
1514
|
+
const NON_RUNNING_PREFIXES = [/^\s*(?:echo|printf|echo\s+-e|cat|tee|true|false|:|#)\b/, /\b(?:echo|printf)\s+[^|;&]*["'][^"']*(?:test|lint|build|check)[^"']*["'][^]|;&]*$/i];
|
|
1515
|
+
/** Discovery/version/inspection commands, not verification runs. */
|
|
1516
|
+
const INSPECTION_COMMANDS = /\b(?:which|where|whereis|type|command\s+-v|grep|rg|cat|less|head|tail|find|ls|dir)\b|\s(?:--version|-V|-v|--help|-h)\s*$|\s(?:--version|--help)\b/i;
|
|
1517
|
+
/** Shell constructs that mask the real exit status or detach the check. */
|
|
1518
|
+
const MASKING_CONSTRUCTS = [
|
|
1519
|
+
/\|\|/,
|
|
1520
|
+
/;/,
|
|
1521
|
+
/\|/,
|
|
1522
|
+
/(?:^|\s)&(?!&)\s*$/,
|
|
1523
|
+
/(?:^|\s)&(?!&)\s*(?:disown)?/,
|
|
1524
|
+
/\((?:.*\s&(?!&)\s*)\)\s*$/,
|
|
1525
|
+
/\b(?:nohup|setsid)\b/,
|
|
1526
|
+
/\|\s*(?:true|:)\s*$/
|
|
1527
|
+
];
|
|
1528
|
+
function isDeterministicCheck(command) {
|
|
1529
|
+
const normalized = command.trim().replace(/\s+/g, " ");
|
|
1530
|
+
if (!normalized || normalized.startsWith("#")) return false;
|
|
1531
|
+
if (/(?:^|[\s&|;(])\s*!(?=\s*[A-Za-z0-9/_.-])/.test(normalized)) return false;
|
|
1532
|
+
if (NON_RUNNING_PREFIXES.some((pattern) => pattern.test(normalized))) return false;
|
|
1533
|
+
if (INSPECTION_COMMANDS.test(normalized)) return false;
|
|
1534
|
+
if (MASKING_CONSTRUCTS.some((pattern) => pattern.test(normalized))) return false;
|
|
1535
|
+
const withoutCd = normalized.replace(/^cd\s+[^;&|]+\s*(?:&&|;)\s*/, "");
|
|
1536
|
+
return DETERMINISTIC_CHECK_PATTERNS.some((pattern) => pattern.test(withoutCd));
|
|
1537
|
+
}
|
|
1538
|
+
function unique(values) {
|
|
1539
|
+
return [...new Set(values)];
|
|
1540
|
+
}
|
|
1541
|
+
/** Resolve relative artifact subjects against the session scope cwd. */
|
|
1542
|
+
function resolveSubjectPaths(values, cwd) {
|
|
1543
|
+
return cwd ? values.map((value) => resolveCommandPath(value, cwd)) : values;
|
|
1544
|
+
}
|
|
1545
|
+
function extractToolSubject(call, result, defaultCwd) {
|
|
1546
|
+
const args = parseArguments$1(call.arguments);
|
|
1547
|
+
switch (call.name) {
|
|
1548
|
+
case "read":
|
|
1549
|
+
case "read_file": {
|
|
1550
|
+
const subjects = unique(resolveSubjectPaths([...metaPaths(result.meta), ...argsPaths(args)], defaultCwd));
|
|
1551
|
+
return {
|
|
1552
|
+
capabilities: ["filesystem-read"],
|
|
1553
|
+
subjects,
|
|
1554
|
+
surfaces: ["artifact"],
|
|
1555
|
+
operations: subjects.map((path$1) => ({
|
|
1556
|
+
op: "read",
|
|
1557
|
+
path: path$1
|
|
1558
|
+
}))
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
case "write":
|
|
1562
|
+
case "write_file": {
|
|
1563
|
+
const subjects = unique(resolveSubjectPaths([...metaPaths(result.meta), ...argsPaths(args)], defaultCwd));
|
|
1564
|
+
return {
|
|
1565
|
+
capabilities: ["filesystem-write"],
|
|
1566
|
+
subjects,
|
|
1567
|
+
surfaces: ["artifact"],
|
|
1568
|
+
operations: subjects.map((path$1) => ({
|
|
1569
|
+
op: "create",
|
|
1570
|
+
path: path$1
|
|
1571
|
+
}))
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
case "edit":
|
|
1575
|
+
case "edit_file": {
|
|
1576
|
+
const subjects = unique(resolveSubjectPaths([...metaPaths(result.meta), ...argsPaths(args)], defaultCwd));
|
|
1577
|
+
return {
|
|
1578
|
+
capabilities: ["filesystem-edit"],
|
|
1579
|
+
subjects,
|
|
1580
|
+
surfaces: ["artifact"],
|
|
1581
|
+
operations: subjects.map((path$1) => ({
|
|
1582
|
+
op: "modify",
|
|
1583
|
+
path: path$1
|
|
1584
|
+
}))
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
case "bash":
|
|
1588
|
+
case "shell":
|
|
1589
|
+
case "pwsh": {
|
|
1590
|
+
const command = typeof args.command === "string" ? args.command : "";
|
|
1591
|
+
const terminal = structuredTerminalFacts(result.meta) ?? extractTerminalFacts(result.textContent);
|
|
1592
|
+
const backgrounded = args.run_in_background === true;
|
|
1593
|
+
const commandDetails = analyzeCommand(command, typeof args.workdir === "string" ? args.workdir : defaultCwd, call.name);
|
|
1594
|
+
const deterministic = commandDetails.status === "supported" && !backgrounded && isDeterministicCheck(command);
|
|
1595
|
+
const outcome = backgrounded ? "unknown" : result.error || terminal.negative ? "failure" : terminal.exitCode === void 0 ? call.name === "bash" || call.name === "pwsh" ? "success" : "unknown" : terminal.exitCode === 0 ? "success" : "failure";
|
|
1596
|
+
return {
|
|
1597
|
+
capabilities: ["shell", ...deterministic ? ["deterministic-check"] : []],
|
|
1598
|
+
subjects: unique(commandDetails.subjects),
|
|
1599
|
+
surfaces: ["scope"],
|
|
1600
|
+
outcome,
|
|
1601
|
+
executables: commandDetails.executables,
|
|
1602
|
+
operations: commandDetails.operations
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
case "web_search":
|
|
1606
|
+
case "web_fetch":
|
|
1607
|
+
case "web_fetch_url": return {
|
|
1608
|
+
capabilities: ["web-fetch"],
|
|
1609
|
+
subjects: unique([...metaUrls(result.meta), ...typeof args.url === "string" ? [sanitizeUrl(args.url)] : []]),
|
|
1610
|
+
surfaces: ["ui"]
|
|
1611
|
+
};
|
|
1612
|
+
default: return {
|
|
1613
|
+
capabilities: ["generic"],
|
|
1614
|
+
subjects: [],
|
|
1615
|
+
surfaces: []
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
function evidenceFromPersistedToolResult(call, result, epoch, evidenceId, defaultCwd) {
|
|
1620
|
+
const subject = extractToolSubject(call, result, defaultCwd);
|
|
1621
|
+
const outcome = result.error ? "failure" : subject.outcome ?? "success";
|
|
1622
|
+
return {
|
|
1623
|
+
id: evidenceId,
|
|
1624
|
+
epoch,
|
|
1625
|
+
callId: call.callId,
|
|
1626
|
+
rootCallId: call.rootCallId ?? call.callId,
|
|
1627
|
+
toolName: call.name,
|
|
1628
|
+
toolResultSeq: result.seq,
|
|
1629
|
+
outcome,
|
|
1630
|
+
capabilities: subject.capabilities,
|
|
1631
|
+
subjects: subject.subjects,
|
|
1632
|
+
surfaces: subject.surfaces,
|
|
1633
|
+
boundedSummarySha256: sha256(boundedSummary(result.textContent)),
|
|
1634
|
+
...subject.executables?.length ? { executables: subject.executables } : {},
|
|
1635
|
+
...subject.operations?.length ? { operations: subject.operations } : {}
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
function withDurability(evidence, confirmed) {
|
|
1639
|
+
if (confirmed) return evidence;
|
|
1640
|
+
return {
|
|
1641
|
+
...evidence,
|
|
1642
|
+
outcome: "unknown"
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
//#endregion
|
|
1647
|
+
//#region src/domain/supersession.ts
|
|
1648
|
+
function supersedeItem(items, oldId, replacement) {
|
|
1649
|
+
const old = items.get(oldId);
|
|
1650
|
+
if (!old || old.status === "superseded") return false;
|
|
1651
|
+
old.status = "superseded";
|
|
1652
|
+
old.supersededBy = replacement.id;
|
|
1653
|
+
items.set(replacement.id, replacement);
|
|
1654
|
+
return true;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
//#endregion
|
|
1658
|
+
//#region src/domain/derive.ts
|
|
1659
|
+
function parseArguments(raw) {
|
|
1660
|
+
if (!raw) return {};
|
|
1661
|
+
try {
|
|
1662
|
+
const parsed = JSON.parse(raw);
|
|
1663
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
1664
|
+
} catch {
|
|
1665
|
+
return {};
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
function asRecord(value) {
|
|
1669
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
1670
|
+
}
|
|
1671
|
+
function nextId(items, kind) {
|
|
1672
|
+
const prefix = kind === "requirement" ? "R" : kind === "acceptance" ? "A" : "P";
|
|
1673
|
+
let max = 0;
|
|
1674
|
+
for (const item of items.values()) {
|
|
1675
|
+
if (item.kind !== kind) continue;
|
|
1676
|
+
const num = Number(item.id.slice(prefix.length));
|
|
1677
|
+
if (Number.isInteger(num) && num > max) max = num;
|
|
1678
|
+
}
|
|
1679
|
+
return `${prefix}${String(max + 1).padStart(3, "0")}`;
|
|
1680
|
+
}
|
|
1681
|
+
/** Framing-only instruction clauses carry no task substance and never close. */
|
|
1682
|
+
const FRAMING_ZH = /^(?:请)?(?:完成|执行|按|按照|遵循|满足)?(?:以下|如下|下面|下列)?(?:完整|全部)?(?:任务|要求|事项|需求|指令|说明)$/;
|
|
1683
|
+
const FRAMING_EN = /^(?:please\s+)?(?:complete|do|perform|follow|satisfy)?\s*(?:the\s+)?(?:following|below)?\s*(?:full\s+|whole\s+)?(?:task|tasks|requirement|requirements|instruction|instructions)$/i;
|
|
1684
|
+
function isInstructionFraming(body) {
|
|
1685
|
+
return FRAMING_ZH.test(body) || FRAMING_EN.test(body);
|
|
1686
|
+
}
|
|
1687
|
+
/** Resolve a contract artifact path against the session working directory. */
|
|
1688
|
+
function resolveArtifact(path$1, scope) {
|
|
1689
|
+
if (!scope.cwd) return path$1;
|
|
1690
|
+
if (/^[A-Za-z]:[\\/]/.test(path$1) || path$1.startsWith("/") || path$1.startsWith("\\")) return path$1;
|
|
1691
|
+
return `${scope.cwd.replace(/[\\/]+$/, "")}/${path$1}`;
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Insert every independently tracked clause from one user message. Compound
|
|
1695
|
+
* instructions are segmented and each distinct artifact path becomes its own
|
|
1696
|
+
* item, so evidence for one file cannot close a message that also covers other
|
|
1697
|
+
* files or embeds prohibitions.
|
|
1698
|
+
*/
|
|
1699
|
+
function insertItems(projection, text, sourceMessageId, scope) {
|
|
1700
|
+
for (const segment of segmentClauses(text)) {
|
|
1701
|
+
if (classifyUserInteraction(segment.body) === "conversational") continue;
|
|
1702
|
+
if (segment.kind === "requirement" && segment.paths.length === 0 && isInstructionFraming(segment.body)) continue;
|
|
1703
|
+
if (segment.kind === "prohibition" || segment.paths.length === 0) {
|
|
1704
|
+
insert(projection, segment.kind, segment.body, sourceMessageId, scope.cwd || "scope", "scope");
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
for (const path$1 of segment.paths) insert(projection, segment.kind, segment.body, sourceMessageId, resolveArtifact(path$1, scope), "artifact");
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
function insert(projection, kind, body, sourceMessageId, subject, surface) {
|
|
1711
|
+
const revision = projection.contractRevision + 1;
|
|
1712
|
+
const id = nextId(projection.items, kind);
|
|
1713
|
+
const item = captureItem(kind, body, sourceMessageId, id, revision, subject, surface, extractMethod(body), extractOperation(body));
|
|
1714
|
+
const duplicate = [...projection.items.values()].find((existing) => existing.kind === kind && existing.status === "pending" && existing.textSha256 === item.textSha256 && existing.verification.subject === subject);
|
|
1715
|
+
if (duplicate) supersedeItem(projection.items, duplicate.id, item);
|
|
1716
|
+
else projection.items.set(id, item);
|
|
1717
|
+
projection.contractRevision = item.revision;
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Pure, deterministic re-derivation of the guard projection from the DSH
|
|
1721
|
+
* native event log. Context Guard never writes custom session events, so every
|
|
1722
|
+
* piece of state is derived from `command/run`, `user/message`, `tool/call`,
|
|
1723
|
+
* `tool/result`, `tool/code-dispatch-start`, `tool/code-dispatch`, and
|
|
1724
|
+
* `compaction/summary`.
|
|
1725
|
+
*/
|
|
1726
|
+
function deriveProjection(sourceEvents, config, scope, durableConfirmed) {
|
|
1727
|
+
const projection = createProjection();
|
|
1728
|
+
let enabled = config.activation === "always";
|
|
1729
|
+
let epoch = 0;
|
|
1730
|
+
let evidenceCounter = 0;
|
|
1731
|
+
let compacted = false;
|
|
1732
|
+
let enablementTransitioned = false;
|
|
1733
|
+
let lastCompactionSeq = -1;
|
|
1734
|
+
const pendingCalls = /* @__PURE__ */ new Map();
|
|
1735
|
+
for (const event of sourceEvents) {
|
|
1736
|
+
projection.lastObservedSourceSeq = Math.max(projection.lastObservedSourceSeq, event.seq);
|
|
1737
|
+
switch (event.type) {
|
|
1738
|
+
case "command/run": {
|
|
1739
|
+
const data = asRecord(event.data);
|
|
1740
|
+
if (data?.name !== "context-guard") break;
|
|
1741
|
+
const subcommand = typeof data.args === "string" ? data.args.trim().split(/\s+/, 1)[0] : "";
|
|
1742
|
+
if (subcommand === "on" && !enabled) {
|
|
1743
|
+
enabled = true;
|
|
1744
|
+
epoch += 1;
|
|
1745
|
+
enablementTransitioned = true;
|
|
1746
|
+
projection.epoch = epoch;
|
|
1747
|
+
} else if (subcommand === "off") enabled = false;
|
|
1748
|
+
else if (subcommand === "clear") {
|
|
1749
|
+
const revision = projection.contractRevision + 1;
|
|
1750
|
+
for (const item of projection.items.values()) {
|
|
1751
|
+
if (item.kind === "prohibition" || item.status !== "pending") continue;
|
|
1752
|
+
item.status = "superseded";
|
|
1753
|
+
item.supersededBy = `CLEAR:${revision}`;
|
|
1754
|
+
}
|
|
1755
|
+
projection.contractRevision = revision;
|
|
1756
|
+
}
|
|
1757
|
+
break;
|
|
1758
|
+
}
|
|
1759
|
+
case "compaction/summary":
|
|
1760
|
+
compacted = true;
|
|
1761
|
+
lastCompactionSeq = event.seq;
|
|
1762
|
+
break;
|
|
1763
|
+
case "user/message": {
|
|
1764
|
+
if (!enabled) break;
|
|
1765
|
+
const data = asRecord(event.data);
|
|
1766
|
+
if (asRecord(data?.source)?.kind !== "user") break;
|
|
1767
|
+
const text = extractTextContent(data?.content ?? []);
|
|
1768
|
+
if (!text.trim()) break;
|
|
1769
|
+
if (isInformationalMessage(text)) break;
|
|
1770
|
+
if (classifyUserInteraction(text) === "conversational") break;
|
|
1771
|
+
insertItems(projection, text, `m${event.seq}`, scope);
|
|
1772
|
+
break;
|
|
1773
|
+
}
|
|
1774
|
+
case "tool/call": {
|
|
1775
|
+
if (!enabled) break;
|
|
1776
|
+
const data = asRecord(event.data);
|
|
1777
|
+
const callId = String(data?.callId ?? "");
|
|
1778
|
+
const call = {
|
|
1779
|
+
name: String(data?.name ?? ""),
|
|
1780
|
+
arguments: String(data?.arguments ?? ""),
|
|
1781
|
+
rootCallId: typeof data?.rootCallId === "string" ? data.rootCallId : void 0
|
|
1782
|
+
};
|
|
1783
|
+
if (call.name === "context_guard_checkpoint") {
|
|
1784
|
+
const args = parseArguments(call.arguments);
|
|
1785
|
+
call.bindings = Array.isArray(args.bindings) ? args.bindings.map((binding) => {
|
|
1786
|
+
const record = asRecord(binding);
|
|
1787
|
+
return {
|
|
1788
|
+
itemId: String(record?.item_id ?? ""),
|
|
1789
|
+
evidenceIds: Array.isArray(record?.evidence_ids) ? record.evidence_ids.map(String) : []
|
|
1790
|
+
};
|
|
1791
|
+
}) : [];
|
|
1792
|
+
}
|
|
1793
|
+
pendingCalls.set(callId, call);
|
|
1794
|
+
break;
|
|
1795
|
+
}
|
|
1796
|
+
case "tool/code-dispatch-start": {
|
|
1797
|
+
if (!enabled) break;
|
|
1798
|
+
const data = asRecord(event.data);
|
|
1799
|
+
const subCallId = String(data?.subCallId ?? "");
|
|
1800
|
+
const rawArguments = data?.arguments;
|
|
1801
|
+
pendingCalls.set(subCallId, {
|
|
1802
|
+
name: String(data?.name ?? ""),
|
|
1803
|
+
arguments: typeof rawArguments === "string" ? rawArguments : JSON.stringify(rawArguments ?? ""),
|
|
1804
|
+
rootCallId: typeof data?.rootCallId === "string" ? data.rootCallId : void 0
|
|
1805
|
+
});
|
|
1806
|
+
break;
|
|
1807
|
+
}
|
|
1808
|
+
case "tool/result":
|
|
1809
|
+
case "tool/code-dispatch": {
|
|
1810
|
+
if (!enabled) break;
|
|
1811
|
+
const data = asRecord(event.data);
|
|
1812
|
+
const isDispatch = event.type === "tool/code-dispatch";
|
|
1813
|
+
const message = asRecord(data?.message);
|
|
1814
|
+
const source = asRecord(message?.source);
|
|
1815
|
+
const callId = String(source?.callId ?? (isDispatch ? data?.subCallId : "") ?? "");
|
|
1816
|
+
const call = pendingCalls.get(callId);
|
|
1817
|
+
if (!call) break;
|
|
1818
|
+
pendingCalls.delete(callId);
|
|
1819
|
+
const textContent = extractTextContent((isDispatch ? data?.content : void 0) ?? message?.content ?? []);
|
|
1820
|
+
if (call.name === "context_guard_checkpoint") {
|
|
1821
|
+
if (parseArguments(textContent).status !== "certified") break;
|
|
1822
|
+
if (certifyCheckpoint(projection, call.bindings ?? [], `C${projection.checkpoints.length + 1}`).status !== "certified") projection.integrity = "corrupt";
|
|
1823
|
+
break;
|
|
1824
|
+
}
|
|
1825
|
+
evidenceCounter += 1;
|
|
1826
|
+
const evidence = withDurability(evidenceFromPersistedToolResult({
|
|
1827
|
+
callId,
|
|
1828
|
+
name: call.name,
|
|
1829
|
+
arguments: call.arguments,
|
|
1830
|
+
rootCallId: call.rootCallId
|
|
1831
|
+
}, {
|
|
1832
|
+
seq: event.seq,
|
|
1833
|
+
error: data?.error ?? (isDispatch && data?.isError ? {
|
|
1834
|
+
name: "code",
|
|
1835
|
+
code: "DISPATCH_ERROR"
|
|
1836
|
+
} : void 0),
|
|
1837
|
+
meta: data?.meta,
|
|
1838
|
+
textContent
|
|
1839
|
+
}, epoch, `E${String(evidenceCounter).padStart(4, "0")}`, scope.cwd || void 0), durableConfirmed);
|
|
1840
|
+
projection.evidence.set(evidence.id, evidence);
|
|
1841
|
+
break;
|
|
1842
|
+
}
|
|
1843
|
+
default: break;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
projection.enabled = enabled;
|
|
1847
|
+
projection.epoch = epoch;
|
|
1848
|
+
return {
|
|
1849
|
+
projection,
|
|
1850
|
+
compacted,
|
|
1851
|
+
enablementTransitioned,
|
|
1852
|
+
lastCompactionSeq
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
//#endregion
|
|
1857
|
+
//#region src/domain/goal-gate.ts
|
|
1858
|
+
function hasCurrentCertificate(projection) {
|
|
1859
|
+
const checkpoint = projection.checkpoints.at(-1);
|
|
1860
|
+
return projection.integrity === "valid" && checkpoint?.result === "certified" && checkpoint.epoch === projection.epoch && checkpoint.contractRevision === projection.contractRevision;
|
|
1861
|
+
}
|
|
1862
|
+
/**
|
|
1863
|
+
* Denies `update_goal(action=complete)` while the guard is enabled and no
|
|
1864
|
+
* current completion certificate exists. The gate itself has no bypass; a
|
|
1865
|
+
* workflow that genuinely finished but cannot certify (for example a contract
|
|
1866
|
+
* polluted by session-layer talk, or evidence that lives in another session)
|
|
1867
|
+
* has three explicit remediation routes:
|
|
1868
|
+
*
|
|
1869
|
+
* 1. `/context-guard off` disables the guard, so completion is no longer
|
|
1870
|
+
* gated. Use only after the user confirms the work is actually done.
|
|
1871
|
+
* 2. `/context-guard clear` supersedes every pending requirement and
|
|
1872
|
+
* acceptance under a `CLEAR:<revision>` sentinel (prohibitions are
|
|
1873
|
+
* retained) and bumps the contract revision; an empty-binding checkpoint
|
|
1874
|
+
* can then certify while the guard stays enabled.
|
|
1875
|
+
* 3. `update_goal(action=blocked)` records the blocker truthfully, which is
|
|
1876
|
+
* never denied by this gate.
|
|
1877
|
+
*/
|
|
1878
|
+
function goalCompletionDenial(projection, toolName, argumentsValue, configuredToolName = "update_goal") {
|
|
1879
|
+
if (toolName !== configuredToolName || typeof argumentsValue !== "object" || argumentsValue === null) return void 0;
|
|
1880
|
+
if (argumentsValue.action !== "complete") return void 0;
|
|
1881
|
+
if (!projection.enabled) return void 0;
|
|
1882
|
+
if (hasCurrentCertificate(projection)) return void 0;
|
|
1883
|
+
return projection.integrity === "valid" ? "Context Guard requires a current completion certificate before Goal completion." : "Context Guard integrity is unknown or corrupt; Goal completion is denied.";
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
//#endregion
|
|
1887
|
+
//#region src/domain/stop-policy.ts
|
|
1888
|
+
const QUOTED = /["'“”‘’`].*?(?:complete|done|finished|完成|做完|搞定).*?["'“”‘’`]/i;
|
|
1889
|
+
const EXAMPLE = /\b(?:for example|e\.g\.|such as|like saying|例如|比如|举例|作为一个例子)\b/i;
|
|
1890
|
+
const QUESTION = /\?[ \t]*$|\b(?:should|could|would|can|will|what|how|whether)\b.*\?/i;
|
|
1891
|
+
const TRAILING_NEGATION = /\b(?:not (?:yet |quite |fully )?(?:complete|done|finished)|isn'?t (?:complete|done|finished)|hasn'?t (?:been )?(?:completed|finished)|尚未完成|还没完成|未完成|没有完成|还未完成)\b/i;
|
|
1892
|
+
const CONDITIONAL = /\b(?:if|unless|once|when|whenever|provided that|只要|如果|假如|一旦|除非)\b/i;
|
|
1893
|
+
const PARTIAL_ONLY = /\b(?:step|phase|stage|milestone)\s+\d+\b|第[一二三四五六七八九十\d]+\s*(?:步|阶段|环节)|(?:第一步|第二步|第三步)/i;
|
|
1894
|
+
const WHOLE_COMPLETION_EN = /\b(?:the )?(?:task|work|job|everything|all tasks?|all work) (?:is|are) (?:now )?(?:complete|done|finished|completed)\b|\b(?:task|work) (?:has been )?(?:completed|finished)\b|\ball (?:tasks|work|requirements) (?:have been )?(?:completed|done|met)\b/i;
|
|
1895
|
+
const WHOLE_COMPLETION_ZH = /(?:任务|工作|所有任务|全部工作|整体)(?:已经|已)?(?:全部)?(?:完成|搞定|做完)|(?:已|已经)(?:全部|所有)?(?:完成|搞定)(?:了)?(?:全部|所有)?(?:任务|工作)?/i;
|
|
1896
|
+
/** Bare completion confirmations, e.g. "Done." or "搞定了。" */
|
|
1897
|
+
const BARE_COMPLETION = /^(?:done|finished|completed|all\s+done)[.!]?$|^(?:已完成|完成了|搞定了|搞定|完成|done)[。..!!]?$/i;
|
|
1898
|
+
/** Continuation intent following a claim makes it partial, not whole-task. */
|
|
1899
|
+
const CONTINUATION = /接下来|下一步|然后|接着|继续|再去|最后再|还差|剩下|剩余|第二步|第三步|,\s*(?:next|then|after that|moving on)\b/i;
|
|
1900
|
+
function looksQuotedOrExemplary(text) {
|
|
1901
|
+
return QUOTED.test(text) || EXAMPLE.test(text);
|
|
1902
|
+
}
|
|
1903
|
+
function isWholeTaskCompletionClaim(text) {
|
|
1904
|
+
const normalized = normalizeClause(text);
|
|
1905
|
+
if (!normalized) return false;
|
|
1906
|
+
if (QUESTION.test(normalized)) return false;
|
|
1907
|
+
if (TRAILING_NEGATION.test(normalized)) return false;
|
|
1908
|
+
if (CONDITIONAL.test(normalized)) return false;
|
|
1909
|
+
if (CONTINUATION.test(normalized)) return false;
|
|
1910
|
+
if (looksQuotedOrExemplary(normalized)) return false;
|
|
1911
|
+
if (PARTIAL_ONLY.test(normalized) && !WHOLE_COMPLETION_EN.test(normalized) && !WHOLE_COMPLETION_ZH.test(normalized)) return false;
|
|
1912
|
+
const firstLine = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0] ?? "";
|
|
1913
|
+
if (BARE_COMPLETION.test(normalizeTitleLine(firstLine))) return leadingBareCompletionClaim(text);
|
|
1914
|
+
return BARE_COMPLETION.test(normalized) || WHOLE_COMPLETION_EN.test(normalized) || WHOLE_COMPLETION_ZH.test(normalized);
|
|
1915
|
+
}
|
|
1916
|
+
const DECORATION_LEAD = /^\s*(?:[\p{Extended_Pictographic}\u2764\u2705\u2714\u2716\u2728\u274C\u26A0\u2611\u2612\u2713\u2717\u274E\u2B50\u2B55\u2022\u00B7\u25E6\u25AA\u25AB\u25CF\u25CB\u25A0\u25A1\u2013\u2014-]|\uFE0F|\uFE0E|\u200D)+/u;
|
|
1917
|
+
/** Strip a leading run of decorative glyphs from a title line. */
|
|
1918
|
+
function stripDecorationPrefix(text) {
|
|
1919
|
+
let value = text;
|
|
1920
|
+
let previous = "";
|
|
1921
|
+
while (value !== previous) {
|
|
1922
|
+
previous = value;
|
|
1923
|
+
value = value.replace(DECORATION_LEAD, "");
|
|
1924
|
+
}
|
|
1925
|
+
return value.replace(/^\s+/, "");
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* Normalize a title line for the bare-completion test. Markdown heading markers,
|
|
1929
|
+
* fully-wrapping emphasis (`**…**`, `__…__`, `*…*`, `_…_`), and a leading run of
|
|
1930
|
+
* decorative glyphs are removed ITERATIVELY until stable, because stripping one
|
|
1931
|
+
* layer may expose another (`## ✅ **完成。**`). Blockquotes (`>`), quoted
|
|
1932
|
+
* titles, and examples are left untouched so they still fail closed.
|
|
1933
|
+
*/
|
|
1934
|
+
function normalizeTitleLine(line) {
|
|
1935
|
+
let value = line.trim();
|
|
1936
|
+
if (value.startsWith(">")) return value;
|
|
1937
|
+
let previous = "";
|
|
1938
|
+
while (value !== previous) {
|
|
1939
|
+
previous = value;
|
|
1940
|
+
value = value.replace(/^#{1,6}\s+/, "").replace(/^\*\*(.+?)\*\*$/, "$1").replace(/^__(.+?)__$/, "$1").replace(/^\*(.+?)\*$/, "$1").replace(/^_(.+?)_$/, "$1");
|
|
1941
|
+
value = stripDecorationPrefix(value);
|
|
1942
|
+
}
|
|
1943
|
+
return value;
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* A reply whose first non-empty line is a standalone bare completion ("完成。"
|
|
1947
|
+
* or "Done.") followed by a results summary. The whole text no longer matches
|
|
1948
|
+
* the single-line BARE_COMPLETION anchor, but the summary must still be treated
|
|
1949
|
+
* as a whole-task completion claim.
|
|
1950
|
+
*/
|
|
1951
|
+
function leadingBareCompletionClaim(text) {
|
|
1952
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1953
|
+
const first = lines[0];
|
|
1954
|
+
if (!first || !BARE_COMPLETION.test(normalizeTitleLine(first))) return false;
|
|
1955
|
+
const rest = normalizeClause(lines.slice(1).join("\n"));
|
|
1956
|
+
if (!rest) return true;
|
|
1957
|
+
if (CONTINUATION.test(rest)) return false;
|
|
1958
|
+
if (TRAILING_NEGATION.test(rest)) return false;
|
|
1959
|
+
if (CONDITIONAL.test(rest)) return false;
|
|
1960
|
+
if (looksQuotedOrExemplary(rest)) return false;
|
|
1961
|
+
if (PARTIAL_ONLY.test(rest)) return false;
|
|
1962
|
+
return true;
|
|
1963
|
+
}
|
|
1964
|
+
function classifyCompletionClaim(text) {
|
|
1965
|
+
const normalized = normalizeClause(text);
|
|
1966
|
+
if (/waiting for (?:you|the user|input|your)|please (?:review|confirm|approve)|等待(?:您|你|用户)|请(?:确认|审阅|批准)/i.test(normalized)) return "user_wait";
|
|
1967
|
+
if (/waiting for (?:the )?(?:result|output|response|build|test|deployment)|等待(?:结果|输出|构建|测试|部署|响应)/i.test(normalized)) return "external_wait";
|
|
1968
|
+
if (isWholeTaskCompletionClaim(normalized)) return "complete";
|
|
1969
|
+
return "report";
|
|
1970
|
+
}
|
|
1971
|
+
function decideTurnStopping(projection, assistantText, turn, maxAttempts) {
|
|
1972
|
+
if (!projection.enabled) return { action: "stop" };
|
|
1973
|
+
if (!isWholeTaskCompletionClaim(assistantText)) return { action: "stop" };
|
|
1974
|
+
if (hasCurrentCertificate(projection)) return { action: "stop" };
|
|
1975
|
+
const attempts = projection.continuationAttempts.get(turn) ?? 0;
|
|
1976
|
+
if (attempts >= maxAttempts) return {
|
|
1977
|
+
action: "stop",
|
|
1978
|
+
reason: "continuation attempt limit reached"
|
|
1979
|
+
};
|
|
1980
|
+
projection.continuationAttempts.set(turn, attempts + 1);
|
|
1981
|
+
return {
|
|
1982
|
+
action: "continue",
|
|
1983
|
+
reason: "whole-task completion claimed without a current certificate"
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
function latestAssistantText(events) {
|
|
1987
|
+
for (let index = events.length - 1; index >= 0; index--) {
|
|
1988
|
+
const event = events[index];
|
|
1989
|
+
if (event.type !== "assistant/message") continue;
|
|
1990
|
+
const text = event.data.message?.content?.filter((block) => block.type === "text").map((block) => block.text ?? "").join("\n") ?? "";
|
|
1991
|
+
if (text.trim()) return text;
|
|
1992
|
+
}
|
|
1993
|
+
return "";
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
//#endregion
|
|
1997
|
+
export { classifyClause as A, normalizeClause as B, renderRecoveryPacket as C, isVerifyingCapability as D, evidenceMatchesItem as E, segmentClauses as F, sanitizeUrl as H, COMMAND_SURFACE_MANIFEST as I, validateManifest as L, extractMethod as M, extractOperation as N, captureClause as O, isInformationalMessage as P, canonicalizePath as R, recoveryDigest as S, evidenceCoverage as T, sha256 as U, sanitizeClauseText as V, createProjection as W, classifyUserInteraction as _, goalCompletionDenial as a, closingHint as b, supersedeItem as c, extractToolSubject as d, isDeterministicCheck as f, parseShellCommand as g, parsePwshCommand as h, latestAssistantText as i, extractArtifactPaths as j, captureItem as k, evidenceFromPersistedToolResult as l, isRunExecutable as m, decideTurnStopping as n, hasCurrentCertificate as o, withDurability as p, isWholeTaskCompletionClaim as r, deriveProjection as s, classifyCompletionClaim as t, extractTextContent as u, certifyCheckpoint as v, bindingSatisfies as w, openItems$1 as x, DEFAULT_RECOVERY_CHAR_BUDGET as y, digestStrings as z };
|