oh-my-knowledge 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/README.zh.md +5 -3
- package/dist/assets/agent-skills/omk/SKILL.md +4 -2
- package/dist/assets/agent-skills/omk/references/commands.md +86 -3
- package/dist/cli/commands/doctor.js +19 -4
- package/dist/cli/commands/evolve.d.ts +11 -0
- package/dist/cli/commands/evolve.js +105 -5
- package/dist/cli/commands/list.d.ts +5 -26
- package/dist/cli/commands/list.js +17 -142
- package/dist/cli/commands/promote.d.ts +24 -0
- package/dist/cli/commands/promote.js +158 -0
- package/dist/cli/commands/rollback.d.ts +20 -0
- package/dist/cli/commands/rollback.js +95 -0
- package/dist/cli/commands/sample.d.ts +1 -0
- package/dist/cli/commands/sample.js +10 -4
- package/dist/cli/lib/cell-format.d.ts +17 -0
- package/dist/cli/lib/cell-format.js +19 -0
- package/dist/cli/lib/cmd-flags.d.ts +1 -0
- package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
- package/dist/cli/lib/i18n-dict/common.js +8 -0
- package/dist/cli/lib/i18n-dict/list.d.ts +1 -1
- package/dist/cli/lib/i18n-dict/list.js +4 -0
- package/dist/cli/lib/i18n-dict/promote.d.ts +3 -0
- package/dist/cli/lib/i18n-dict/promote.js +54 -0
- package/dist/cli/lib/i18n-dict/rollback.d.ts +3 -0
- package/dist/cli/lib/i18n-dict/rollback.js +22 -0
- package/dist/cli/lib/i18n-dict.d.ts +3 -1
- package/dist/cli/lib/i18n-dict.js +4 -0
- package/dist/cli/lib/progress.d.ts +3 -0
- package/dist/cli/lib/progress.js +22 -0
- package/dist/cli/lib/source-probe.d.ts +15 -0
- package/dist/cli/lib/source-probe.js +129 -0
- package/dist/doctor/endpoint-rule.d.ts +60 -0
- package/dist/doctor/endpoint-rule.js +381 -0
- package/dist/doctor/health/load-custom-dimensions.d.ts +6 -0
- package/dist/doctor/health/load-custom-dimensions.js +43 -3
- package/dist/doctor/index.js +8 -1
- package/dist/executors/codex-cli.js +5 -1
- package/dist/managed/index.d.ts +1 -0
- package/dist/managed/index.js +1 -0
- package/dist/managed/list-view.d.ts +3 -0
- package/dist/managed/list-view.js +8 -4
- package/dist/managed/promote-gate.d.ts +40 -0
- package/dist/managed/promote-gate.js +37 -0
- package/dist/managed/store.d.ts +25 -2
- package/dist/managed/store.js +92 -3
- package/dist/renderer/doctor-detail-renderer.d.ts +9 -0
- package/dist/renderer/doctor-detail-renderer.js +114 -0
- package/dist/renderer/html-renderer.d.ts +3 -2
- package/dist/renderer/html-renderer.js +99 -99
- package/dist/renderer/icons.d.ts +29 -0
- package/dist/renderer/icons.js +66 -0
- package/dist/renderer/layout.js +81 -68
- package/dist/renderer/observation-inbox/styles.d.ts +1 -1
- package/dist/renderer/observation-inbox/styles.js +53 -53
- package/dist/renderer/report-shell.d.ts +77 -0
- package/dist/renderer/report-shell.js +223 -0
- package/dist/renderer/skill-detail-renderer.js +147 -352
- package/dist/renderer/skill-health-renderer.js +50 -73
- package/dist/renderer/skill-list-renderer.js +358 -306
- package/dist/renderer/summary.js +272 -162
- package/dist/renderer/test-view.d.ts +4 -3
- package/dist/renderer/test-view.js +386 -135
- package/dist/server/report-server.js +142 -28
- package/dist/types/doctor.d.ts +22 -0
- package/dist/types/managed.d.ts +17 -3
- package/package.json +1 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* endpoint-rule — 接口驱动的自定义检查维度。
|
|
3
|
+
*
|
|
4
|
+
* 普通自定义维度走 LLM health composer(promptSection 喂 prompt);带 `endpoint`
|
|
5
|
+
* 的维度则注册成独立 DoctorRule:doctor 运行时把 skill 完整快照 POST 给用户的
|
|
6
|
+
* 接口,接口按协议返回判定结果。适合"调外部服务做深度审查"(eg. 安全风险审查)
|
|
7
|
+
* 这类靠 prompt 表达不了、需要真实逻辑/模型的场景。
|
|
8
|
+
*
|
|
9
|
+
* 请求协议(doctor → endpoint):
|
|
10
|
+
* POST <endpoint> Content-Type: application/json
|
|
11
|
+
* {
|
|
12
|
+
* "dimensionId": "deep-security-audit",
|
|
13
|
+
* "params": { ... }, // YAML 里用户透传的自定义参数
|
|
14
|
+
* "skill": {
|
|
15
|
+
* "name": "my-skill",
|
|
16
|
+
* "content": "SKILL.md 全文", // file-skill / directory-skill 主文件
|
|
17
|
+
* "skillRoot": "/abs/path" | null, // git / inline 来源可能为 null
|
|
18
|
+
* "ref": "abc1234" | null, // git 来源的 commit
|
|
19
|
+
* "files": { "references/x.md": "...", "scripts/y.sh": "..." } // 子文件快照
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* 响应协议(endpoint → doctor):
|
|
24
|
+
* { "status": "pass" | "warn" | "fail", "message": "...", "hint"?: "...", "detail"?: {...} }
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readFileSync, readdirSync, lstatSync, openSync, readSync, closeSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
const DEFAULT_MAX_FILE_BYTES = 200 * 1024;
|
|
29
|
+
const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
|
|
30
|
+
/** 二进制 / 大文件不可读时跳过;只收文本。简单按扩展名 + 内容嗅探。 */
|
|
31
|
+
const SKIP_EXTENSIONS = new Set([
|
|
32
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf',
|
|
33
|
+
'.zip', '.gz', '.tar', '.tgz', '.exe', '.bin', '.so', '.dylib',
|
|
34
|
+
'.woff', '.woff2', '.ttf', '.mp4', '.mov', '.mp3', '.wav',
|
|
35
|
+
]);
|
|
36
|
+
function extOf(name) {
|
|
37
|
+
const i = name.lastIndexOf('.');
|
|
38
|
+
return i >= 0 ? name.slice(i).toLowerCase() : '';
|
|
39
|
+
}
|
|
40
|
+
/** 收集 skillRoot 下的子文件内容(排除主 SKILL.md / 隐藏文件 / node_modules)。
|
|
41
|
+
* 受 maxFileBytes / maxTotalBytes 双重限制,避免 payload 过大。 */
|
|
42
|
+
function collectFiles(skillRoot, maxFileBytes, maxTotalBytes) {
|
|
43
|
+
const files = {};
|
|
44
|
+
if (!skillRoot || !existsSync(skillRoot))
|
|
45
|
+
return files;
|
|
46
|
+
let total = 0;
|
|
47
|
+
const walk = (dir, rel, depth) => {
|
|
48
|
+
if (depth > 3 || total >= maxTotalBytes)
|
|
49
|
+
return;
|
|
50
|
+
let entries = [];
|
|
51
|
+
try {
|
|
52
|
+
entries = readdirSync(dir);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (total >= maxTotalBytes)
|
|
59
|
+
return;
|
|
60
|
+
if (entry.startsWith('.'))
|
|
61
|
+
continue;
|
|
62
|
+
if (entry === 'node_modules')
|
|
63
|
+
continue;
|
|
64
|
+
if (entry === 'SKILL.md' && rel === '')
|
|
65
|
+
continue;
|
|
66
|
+
const full = join(dir, entry);
|
|
67
|
+
const relPath = rel ? `${rel}/${entry}` : entry;
|
|
68
|
+
let st;
|
|
69
|
+
// lstat(不跟随 symlink):跳过符号链接,防止 skillRoot 下的 link 指向 root 外
|
|
70
|
+
// (如 /etc/passwd)被读取并随 payload 外发。
|
|
71
|
+
try {
|
|
72
|
+
st = lstatSync(full);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (st.isSymbolicLink())
|
|
78
|
+
continue;
|
|
79
|
+
if (st.isDirectory()) {
|
|
80
|
+
walk(full, relPath, depth + 1);
|
|
81
|
+
}
|
|
82
|
+
else if (st.isFile()) {
|
|
83
|
+
if (SKIP_EXTENSIONS.has(extOf(entry)))
|
|
84
|
+
continue;
|
|
85
|
+
let buf;
|
|
86
|
+
if (st.size > maxFileBytes) {
|
|
87
|
+
// 大文件守卫:不整文件读入(超大文件会撑内存,超 Buffer 上限的 throw
|
|
88
|
+
// 还会被 catch 静默跳过),只读前 maxFileBytes + 1 字节,NUL 嗅探与
|
|
89
|
+
// 截断都在这个前缀上做。
|
|
90
|
+
let fd;
|
|
91
|
+
try {
|
|
92
|
+
fd = openSync(full, 'r');
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const head = Buffer.allocUnsafe(maxFileBytes + 1);
|
|
99
|
+
const n = readSync(fd, head, 0, head.length, 0);
|
|
100
|
+
buf = head.subarray(0, n);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
closeSync(fd);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
try {
|
|
111
|
+
buf = readFileSync(full);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// 内容嗅探:含 NUL 字节 → 二进制,跳过(扩展名漏网的 binary 兜底)。
|
|
118
|
+
if (buf.includes(0))
|
|
119
|
+
continue;
|
|
120
|
+
// 按字节截断(中文等多字节内容也不会冲穿上限);单文件受 maxFileBytes 与
|
|
121
|
+
// 剩余总预算双重收口,超限只收前缀,toString 会把尾部可能被切断的半个
|
|
122
|
+
// UTF-8 字符替换成 U+FFFD。total 按实际写入的字节数累计(含截断标记与
|
|
123
|
+
// U+FFFD 展开),所以 maxTotalBytes 至多被末文件冲破十几个字节(常数级)。
|
|
124
|
+
const cap = Math.min(maxFileBytes, maxTotalBytes - total);
|
|
125
|
+
const over = buf.length > cap;
|
|
126
|
+
const slice = over ? buf.subarray(0, cap) : buf;
|
|
127
|
+
const text = slice.toString('utf-8') + (over ? '\n…[truncated]' : '');
|
|
128
|
+
files[relPath] = text;
|
|
129
|
+
total += Buffer.byteLength(text, 'utf-8');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
walk(skillRoot, '', 0);
|
|
134
|
+
return files;
|
|
135
|
+
}
|
|
136
|
+
function isValidStatus(s) {
|
|
137
|
+
return s === 'pass' || s === 'warn' || s === 'fail';
|
|
138
|
+
}
|
|
139
|
+
/** ::ffff:a.b.c.d / 规范化后的 ::ffff:hhhh:hhhh 还原成点分 IPv4;无法解析返回 null。
|
|
140
|
+
* WHATWG URL 会把 ::ffff:127.0.0.1 规范成 hex 形态 ::ffff:7f00:1。 */
|
|
141
|
+
function mappedToIPv4(suffix) {
|
|
142
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(suffix))
|
|
143
|
+
return suffix;
|
|
144
|
+
const hx = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(suffix);
|
|
145
|
+
if (!hx)
|
|
146
|
+
return null;
|
|
147
|
+
const hi = parseInt(hx[1], 16);
|
|
148
|
+
const lo = parseInt(hx[2], 16);
|
|
149
|
+
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
150
|
+
}
|
|
151
|
+
/** 私网/本机 hostname 判定(SSRF 防护用)。只做字面 hostname 检查的
|
|
152
|
+
* defense-in-depth:不做 DNS 解析,公网域名解析到内网(DNS rebinding)不在
|
|
153
|
+
* 防护范围。WHATWG URL 会把 0x7f.0.0.1 / 0 之类写法规整成点分十进制,所以
|
|
154
|
+
* IPv4 直接按规范化后的 hostname 判断即可。 */
|
|
155
|
+
function isPrivateHostname(hostname) {
|
|
156
|
+
// 转小写、去 IPv6 字面量方括号([::1] → ::1)、去 FQDN 尾点(localhost. / foo.local.)。
|
|
157
|
+
let h = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
158
|
+
if (h === 'localhost' || h.endsWith('.local'))
|
|
159
|
+
return true;
|
|
160
|
+
if (h.includes(':')) {
|
|
161
|
+
// IPv6 字面量。
|
|
162
|
+
if (h === '::1' || h === '::')
|
|
163
|
+
return true; // loopback / unspecified
|
|
164
|
+
if (/^f[cd]/.test(h))
|
|
165
|
+
return true; // fc00::/7 ULA
|
|
166
|
+
if (/^fe[89ab]/.test(h))
|
|
167
|
+
return true; // fe80::/10 link-local
|
|
168
|
+
// IPv4-mapped(::ffff:…)归并到点分 IPv4 再判;无法解析的保守拒绝。
|
|
169
|
+
const mapped = /^::ffff:(.+)$/.exec(h);
|
|
170
|
+
if (!mapped)
|
|
171
|
+
return false;
|
|
172
|
+
const dotted = mappedToIPv4(mapped[1]);
|
|
173
|
+
if (dotted == null)
|
|
174
|
+
return true;
|
|
175
|
+
h = dotted;
|
|
176
|
+
}
|
|
177
|
+
const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(h);
|
|
178
|
+
if (!m)
|
|
179
|
+
return false;
|
|
180
|
+
const a = Number(m[1]);
|
|
181
|
+
const b = Number(m[2]);
|
|
182
|
+
if (a === 0)
|
|
183
|
+
return true; // 0.0.0.0/8(含 0.0.0.0、http://0/)
|
|
184
|
+
if (a === 127)
|
|
185
|
+
return true; // 127.0.0.0/8 loopback
|
|
186
|
+
if (a === 10)
|
|
187
|
+
return true; // 10.0.0.0/8
|
|
188
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
189
|
+
return true; // 172.16.0.0/12
|
|
190
|
+
if (a === 192 && b === 168)
|
|
191
|
+
return true; // 192.168.0.0/16
|
|
192
|
+
if (a === 169 && b === 254)
|
|
193
|
+
return true; // 169.254.0.0/16(含云 metadata 169.254.169.254)
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
/** outcome 自由文本/JSON 字段的统一上限(字符),与协议违规路径的 received 截断对齐。 */
|
|
197
|
+
const MAX_OUTCOME_CHARS = 2000;
|
|
198
|
+
/** 响应体声明长度(Content-Length)上限(字节):超过即拒读,避免超大 body 撑爆内存。
|
|
199
|
+
* 注意只挡声明了 Content-Length 的情况;分块且不声明长度的响应仍只受 timeoutMs 墙钟约束。 */
|
|
200
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
201
|
+
function clampText(s) {
|
|
202
|
+
return s.length > MAX_OUTCOME_CHARS ? `${s.slice(0, MAX_OUTCOME_CHARS)}…[truncated]` : s;
|
|
203
|
+
}
|
|
204
|
+
/** detail 先序列化判长:超限替换成 { truncated: true, preview } 结构,
|
|
205
|
+
* 保证仍是合法 JSON 值且不会无界塞进 report。 */
|
|
206
|
+
function clampDetail(detail) {
|
|
207
|
+
const json = JSON.stringify(detail) ?? '';
|
|
208
|
+
if (json.length <= MAX_OUTCOME_CHARS)
|
|
209
|
+
return detail;
|
|
210
|
+
return { truncated: true, preview: json.slice(0, MAX_OUTCOME_CHARS) };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* 把一个 endpoint spec 编译成 DoctorRule。check() 内组装 skill 快照、POST、
|
|
214
|
+
* 校验响应协议并映射成 DoctorRuleCheckOutcome。
|
|
215
|
+
*
|
|
216
|
+
* 所有失败(网络错误 / 非 2xx / 非法 JSON / 协议字段缺失)都返回 status='fail',
|
|
217
|
+
* 让用户立刻看到接口侧的问题,而不是静默放行。
|
|
218
|
+
*/
|
|
219
|
+
export function makeEndpointRule(spec, fetchFn = fetch) {
|
|
220
|
+
return {
|
|
221
|
+
id: spec.id,
|
|
222
|
+
severity: spec.severity,
|
|
223
|
+
// 自定义 key 不在 DOCTOR_MESSAGES 字典里,renderer 会 fallback 到 ruleId(=spec.id)。
|
|
224
|
+
labelKey: `cli.doctor.endpoint.${spec.id}`,
|
|
225
|
+
// 网络检查,与 health composer 同档:默认跑,--static-only 跳过。
|
|
226
|
+
external: true,
|
|
227
|
+
async check(ctx) {
|
|
228
|
+
// SSRF 防护(defense-in-depth):endpoint 会收到 skill 完整快照,且响应
|
|
229
|
+
// 原样回填进报告(response oracle),所以组装请求前先校验 scheme 与 host,
|
|
230
|
+
// 私网/本机地址默认拒绝(云 metadata 169.254.169.254、内网主机等)。
|
|
231
|
+
// 只做字面校验、不做 DNS 解析:公网域名解析到内网(DNS rebinding)不在
|
|
232
|
+
// 防护范围。
|
|
233
|
+
let endpointUrl;
|
|
234
|
+
try {
|
|
235
|
+
endpointUrl = new URL(spec.endpoint);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return {
|
|
239
|
+
status: 'fail',
|
|
240
|
+
message: failMsg(ctx, spec, `endpoint 不是合法 URL:${spec.endpoint}`, `endpoint is not a valid URL: ${spec.endpoint}`),
|
|
241
|
+
detail: { endpoint: spec.endpoint },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (endpointUrl.protocol !== 'http:' && endpointUrl.protocol !== 'https:') {
|
|
245
|
+
return {
|
|
246
|
+
status: 'fail',
|
|
247
|
+
message: failMsg(ctx, spec, `endpoint 协议必须是 http/https,实际是 ${endpointUrl.protocol}`, `endpoint protocol must be http/https, got ${endpointUrl.protocol}`),
|
|
248
|
+
detail: { endpoint: spec.endpoint, protocol: endpointUrl.protocol },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (!spec.allowPrivateHost && isPrivateHostname(endpointUrl.hostname)) {
|
|
252
|
+
return {
|
|
253
|
+
status: 'fail',
|
|
254
|
+
message: failMsg(ctx, spec, `endpoint 指向私网/本机地址(${endpointUrl.hostname}),默认拒绝以防 SSRF(skill 快照会被外发、响应会回填进报告);确认该内网服务可信后,可在维度配置加 allowPrivateHost: true 放行`, `endpoint points to a private/loopback host (${endpointUrl.hostname}); refused by default to prevent SSRF (the skill snapshot is sent out and the response is echoed into the report). Set allowPrivateHost: true in the dimension config if this internal service is trusted`),
|
|
255
|
+
detail: { endpoint: spec.endpoint, hostname: endpointUrl.hostname },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const artifact = ctx.artifact;
|
|
259
|
+
const content = artifact.content ?? '';
|
|
260
|
+
const skillName = artifact.name.replace(/\.md$/, '').split('/').pop() ?? artifact.name;
|
|
261
|
+
const skillRoot = artifact.skillRoot ?? null;
|
|
262
|
+
const includeFiles = spec.includeFiles !== false;
|
|
263
|
+
const files = includeFiles
|
|
264
|
+
? collectFiles(skillRoot, spec.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES, spec.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES)
|
|
265
|
+
: {};
|
|
266
|
+
const body = {
|
|
267
|
+
dimensionId: spec.id,
|
|
268
|
+
params: spec.params ?? {},
|
|
269
|
+
skill: {
|
|
270
|
+
name: skillName,
|
|
271
|
+
content,
|
|
272
|
+
skillRoot,
|
|
273
|
+
ref: artifact.ref ?? null,
|
|
274
|
+
files,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
const controller = new AbortController();
|
|
278
|
+
const timer = setTimeout(() => controller.abort(), ctx.timeoutMs);
|
|
279
|
+
let parsed;
|
|
280
|
+
try {
|
|
281
|
+
let res;
|
|
282
|
+
try {
|
|
283
|
+
res = await fetchFn(spec.endpoint, {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: { 'Content-Type': 'application/json', ...spec.headers },
|
|
286
|
+
body: JSON.stringify(body),
|
|
287
|
+
signal: controller.signal,
|
|
288
|
+
// redirect:'manual' —— 不跟随重定向。否则一个可信公网 endpoint 返回
|
|
289
|
+
// 302 Location: http://169.254.169.254/… 即可让 fetch 透明跳到私网,
|
|
290
|
+
// 绕过上面的 host 校验(请求前只校验一次原始 URL)。3xx 一律拒绝。
|
|
291
|
+
redirect: 'manual',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
296
|
+
return {
|
|
297
|
+
status: 'fail',
|
|
298
|
+
message: failMsg(ctx, spec, `请求失败:${msg}`, `request failed: ${msg}`),
|
|
299
|
+
hint: hintNet(ctx, spec.endpoint),
|
|
300
|
+
detail: { endpoint: spec.endpoint, error: msg },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (res.status >= 300 && res.status < 400) {
|
|
304
|
+
const location = res.headers?.get?.('location') ?? null;
|
|
305
|
+
return {
|
|
306
|
+
status: 'fail',
|
|
307
|
+
message: failMsg(ctx, spec, `endpoint 返回重定向(HTTP ${res.status}),默认拒绝以防 SSRF(重定向目标可能指向私网)`, `endpoint returned a redirect (HTTP ${res.status}); refused by default to prevent SSRF (the redirect target may point to a private host)`),
|
|
308
|
+
hint: hintNet(ctx, spec.endpoint),
|
|
309
|
+
detail: { endpoint: spec.endpoint, httpStatus: res.status, location },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const declaredLen = Number(res.headers?.get?.('content-length') ?? '');
|
|
313
|
+
if (Number.isFinite(declaredLen) && declaredLen > MAX_RESPONSE_BYTES) {
|
|
314
|
+
return {
|
|
315
|
+
status: 'fail',
|
|
316
|
+
message: failMsg(ctx, spec, `响应体过大(Content-Length ${declaredLen} 字节,上限 ${MAX_RESPONSE_BYTES})`, `response body too large (Content-Length ${declaredLen} bytes, limit ${MAX_RESPONSE_BYTES})`),
|
|
317
|
+
hint: hintProto(ctx),
|
|
318
|
+
detail: { endpoint: spec.endpoint, contentLength: declaredLen },
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (!res.ok) {
|
|
322
|
+
return {
|
|
323
|
+
status: 'fail',
|
|
324
|
+
message: failMsg(ctx, spec, `接口返回 HTTP ${res.status}`, `endpoint returned HTTP ${res.status}`),
|
|
325
|
+
hint: hintNet(ctx, spec.endpoint),
|
|
326
|
+
detail: { endpoint: spec.endpoint, httpStatus: res.status },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
parsed = await res.json();
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
334
|
+
return {
|
|
335
|
+
status: 'fail',
|
|
336
|
+
message: failMsg(ctx, spec, `响应不是合法 JSON:${msg}`, `response is not valid JSON: ${msg}`),
|
|
337
|
+
hint: hintProto(ctx),
|
|
338
|
+
detail: { endpoint: spec.endpoint, error: msg },
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
// timer 覆盖 fetch + body 读取(res.json()):两者都完成或任一出错后才清,
|
|
344
|
+
// 防止慢速/滴流响应体绕过 ctx.timeoutMs 无限挂起。
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
}
|
|
347
|
+
const resp = parsed;
|
|
348
|
+
if (!isValidStatus(resp.status) || typeof resp.message !== 'string') {
|
|
349
|
+
return {
|
|
350
|
+
status: 'fail',
|
|
351
|
+
message: failMsg(ctx, spec, '响应缺少必填字段 status(pass/warn/fail) 或 message', 'response missing required field status (pass/warn/fail) or message'),
|
|
352
|
+
hint: hintProto(ctx),
|
|
353
|
+
// received 截断:接口可能返回超大 body,避免无界塞进 report JSON。
|
|
354
|
+
detail: { endpoint: spec.endpoint, received: JSON.stringify(parsed).slice(0, MAX_OUTCOME_CHARS) },
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
// 合法响应同样截断:message / hint / detail 都来自外部接口,不加界会被
|
|
358
|
+
// 无界写入 outcome 落盘(与上面 received 的处理风格对齐)。
|
|
359
|
+
return {
|
|
360
|
+
status: resp.status,
|
|
361
|
+
message: `${spec.displayName}: ${clampText(resp.message)}`,
|
|
362
|
+
hint: typeof resp.hint === 'string' ? clampText(resp.hint) : resp.hint,
|
|
363
|
+
detail: resp.detail == null ? { endpoint: spec.endpoint } : clampDetail(resp.detail),
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function failMsg(ctx, spec, zh, en) {
|
|
369
|
+
const head = `${spec.displayName}`;
|
|
370
|
+
return ctx.lang === 'zh' ? `${head}: ${zh}` : `${head}: ${en}`;
|
|
371
|
+
}
|
|
372
|
+
function hintNet(ctx, endpoint) {
|
|
373
|
+
return ctx.lang === 'zh'
|
|
374
|
+
? `确认接口 ${endpoint} 可达、鉴权 header 正确,或调大 --timeout`
|
|
375
|
+
: `Verify endpoint ${endpoint} is reachable, auth headers are correct, or raise --timeout`;
|
|
376
|
+
}
|
|
377
|
+
function hintProto(ctx) {
|
|
378
|
+
return ctx.lang === 'zh'
|
|
379
|
+
? '接口需返回 JSON: { status: "pass"|"warn"|"fail", message: string, hint?, detail? }'
|
|
380
|
+
: 'Endpoint must return JSON: { status: "pass"|"warn"|"fail", message: string, hint?, detail? }';
|
|
381
|
+
}
|
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import yaml from 'js-yaml';
|
|
3
3
|
import { registerHealthDimension } from './dimension-registry.js';
|
|
4
|
+
import { registerRule } from '../rules.js';
|
|
5
|
+
import { makeEndpointRule } from '../endpoint-rule.js';
|
|
4
6
|
const VALID_SEVERITIES = new Set(['fatal', 'warn', 'info']);
|
|
7
|
+
/** 缺省 → warn;非法值直接 throw(与 id 缺失同等对待),避免用户写了
|
|
8
|
+
* `severity: critical` 想要 fatal 却被静默降成 warn、endpoint fail 不再 gate。 */
|
|
9
|
+
function resolveSeverity(dimId, raw) {
|
|
10
|
+
if (raw == null)
|
|
11
|
+
return 'warn'; // 缺省/空值 → 默认 warn
|
|
12
|
+
if (!VALID_SEVERITIES.has(raw)) {
|
|
13
|
+
throw new Error(`自定义维度 ${dimId} 的 severity 非法:${raw}(合法值:fatal / warn / info)`);
|
|
14
|
+
}
|
|
15
|
+
return raw;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 加载自定义维度 YAML 并注册。两类维度按字段自动分流:
|
|
19
|
+
* - 带 `endpoint` → 接口维度,注册成独立 DoctorRule(运行时 POST skill 快照)。
|
|
20
|
+
* - 带 `promptSection` → LLM 维度,注册进 health composer(原逻辑)。
|
|
21
|
+
* 返回成功注册的维度数量。
|
|
22
|
+
*/
|
|
5
23
|
export function loadAndRegisterCustomDimensions(filePath) {
|
|
6
24
|
if (!existsSync(filePath)) {
|
|
7
25
|
throw new Error(`自定义维度配置文件不存在: ${filePath}`);
|
|
@@ -12,10 +30,32 @@ export function loadAndRegisterCustomDimensions(filePath) {
|
|
|
12
30
|
return 0;
|
|
13
31
|
let count = 0;
|
|
14
32
|
for (const dim of doc.dimensions) {
|
|
15
|
-
if (!dim.id || !dim.displayName
|
|
16
|
-
throw new Error(`自定义维度缺少必填字段(id/displayName
|
|
33
|
+
if (!dim.id || !dim.displayName) {
|
|
34
|
+
throw new Error(`自定义维度缺少必填字段(id/displayName): ${JSON.stringify(dim).slice(0, 100)}`);
|
|
35
|
+
}
|
|
36
|
+
if (dim.endpoint && dim.promptSection) {
|
|
37
|
+
throw new Error(`自定义维度 ${dim.id} 不能同时配置 endpoint 与 promptSection(二选一)`);
|
|
38
|
+
}
|
|
39
|
+
const severity = resolveSeverity(dim.id, dim.severity);
|
|
40
|
+
if (dim.endpoint) {
|
|
41
|
+
registerRule(makeEndpointRule({
|
|
42
|
+
id: dim.id,
|
|
43
|
+
displayName: dim.displayName,
|
|
44
|
+
severity,
|
|
45
|
+
endpoint: dim.endpoint,
|
|
46
|
+
params: dim.params,
|
|
47
|
+
includeFiles: dim.includeFiles,
|
|
48
|
+
headers: dim.headers,
|
|
49
|
+
maxFileBytes: dim.maxFileBytes,
|
|
50
|
+
maxTotalBytes: dim.maxTotalBytes,
|
|
51
|
+
allowPrivateHost: dim.allowPrivateHost,
|
|
52
|
+
}));
|
|
53
|
+
count++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!dim.promptSection) {
|
|
57
|
+
throw new Error(`自定义维度 ${dim.id} 必须配置 endpoint 或 promptSection 之一`);
|
|
17
58
|
}
|
|
18
|
-
const severity = VALID_SEVERITIES.has(dim.severity ?? '') ? dim.severity : 'warn';
|
|
19
59
|
const spec = {
|
|
20
60
|
id: dim.id,
|
|
21
61
|
displayName: dim.displayName,
|
package/dist/doctor/index.js
CHANGED
|
@@ -206,11 +206,17 @@ export async function runDoctor(opts) {
|
|
|
206
206
|
const skillReports = [];
|
|
207
207
|
const totals = { pass: 0, warn: 0, fail: 0 };
|
|
208
208
|
const ruleStats = { pass: 0, warn: 0, fail: 0, skipped: 0, total: 0 };
|
|
209
|
+
const total = artifacts.length;
|
|
210
|
+
let index = 0;
|
|
209
211
|
for (const artifact of artifacts) {
|
|
212
|
+
index += 1;
|
|
213
|
+
const skillName = basename(artifact.name).replace(/\.md$/, '');
|
|
214
|
+
opts.onProgress?.({ phase: 'skill_start', index, total, skillName });
|
|
215
|
+
const startedAt = Date.now();
|
|
210
216
|
const results = await runRulesOnArtifact(artifact, effectiveRules, ctxBase);
|
|
211
217
|
const status = classifySkillStatus(results);
|
|
212
218
|
skillReports.push({
|
|
213
|
-
skillName
|
|
219
|
+
skillName,
|
|
214
220
|
skillPath: inferSkillPath(artifact, opts.cwd),
|
|
215
221
|
results,
|
|
216
222
|
status,
|
|
@@ -220,6 +226,7 @@ export async function runDoctor(opts) {
|
|
|
220
226
|
ruleStats[r.status] += 1;
|
|
221
227
|
ruleStats.total += 1;
|
|
222
228
|
}
|
|
229
|
+
opts.onProgress?.({ phase: 'skill_done', index, total, skillName, status, durationMs: Date.now() - startedAt });
|
|
223
230
|
}
|
|
224
231
|
// Single-enum verdict for CI / agent code. fatal-fail dominates;
|
|
225
232
|
// warnings_only when no fatal-fail but at least one warn; otherwise passed.
|
|
@@ -113,7 +113,11 @@ export function buildCodexArgs({ model, cwd, prompt }) {
|
|
|
113
113
|
args.push('--model', model);
|
|
114
114
|
if (cwd)
|
|
115
115
|
args.push('-C', cwd);
|
|
116
|
-
|
|
116
|
+
// `--` end-of-options 分隔符:prompt 必须放在 `--` 之后。system prompt 被 prepend 时,
|
|
117
|
+
// skill 内容常以 YAML frontmatter `---` 开头,codex(clap)会把以 `-`/`--` 开头的位置参数
|
|
118
|
+
// 当未知 flag → exit 2(unexpected argument),72ms 秒退、不跑 agent。`--` 之后一律当
|
|
119
|
+
// positional,不再按 flag 解析(codex 自身的报错 tip 即建议此法)。bug:整个 skill eval 全失败。
|
|
120
|
+
args.push('--', prompt);
|
|
117
121
|
return args;
|
|
118
122
|
}
|
|
119
123
|
// codex 看到 stdin 是 pipe 就当作 `<stdin>` 块读,会卡到 timeout。spawn 后立刻
|
package/dist/managed/index.d.ts
CHANGED
package/dist/managed/index.js
CHANGED
|
@@ -43,6 +43,9 @@ export interface ManagedListRow {
|
|
|
43
43
|
totalEvidenceCount: number;
|
|
44
44
|
distributionCount: number;
|
|
45
45
|
}
|
|
46
|
+
/** 当前有效证据(contentHash == record.contentHash)里 recordedAt 最新那条;无则 undefined。
|
|
47
|
+
* list(展示最新 verdict)与 promote(门禁取证)共用同一口径——旧内容的证据不冒充当前。 */
|
|
48
|
+
export declare function latestCurrentEvidence(record: ManagedArtifactRecord): import("../types/managed.js").ManagedEvidenceRef | undefined;
|
|
46
49
|
export declare function buildManagedListRow(record: ManagedArtifactRecord, probe: SourceProbe): ManagedListRow;
|
|
47
50
|
/**
|
|
48
51
|
* 组装全部行。`probeOf` 给每条记录探测当前源(三态,见文件头)。
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { deriveManagedState } from './store.js';
|
|
2
|
-
|
|
1
|
+
import { deriveManagedState, isCurrentlyPromoted } from './store.js';
|
|
2
|
+
/** 当前有效证据(contentHash == record.contentHash)里 recordedAt 最新那条;无则 undefined。
|
|
3
|
+
* list(展示最新 verdict)与 promote(门禁取证)共用同一口径——旧内容的证据不冒充当前。 */
|
|
4
|
+
export function latestCurrentEvidence(record) {
|
|
3
5
|
const current = record.evidence.filter((e) => e.contentHash === record.contentHash);
|
|
4
6
|
if (current.length === 0)
|
|
5
7
|
return undefined;
|
|
@@ -26,8 +28,10 @@ export function buildManagedListRow(record, probe) {
|
|
|
26
28
|
drifted = d.drifted;
|
|
27
29
|
}
|
|
28
30
|
else {
|
|
29
|
-
// 不可达:不据此判 stale
|
|
30
|
-
|
|
31
|
+
// 不可达:不据此判 stale。promote 决定是**内容锚定**的(只看 record.decisions + record.contentHash,
|
|
32
|
+
// 不依赖源能否被 probe),故当前内容仍处于 promoted 态时保留 promoted —— 它比 evidence 更不该因源不可达
|
|
33
|
+
// 丢失(deriveManagedState 的 promoted 分支同理)。否则按证据给 measurable/installed,drift 标未核。
|
|
34
|
+
state = isCurrentlyPromoted(record) ? 'promoted' : currentEvidenceCount > 0 ? 'measurable' : 'installed';
|
|
31
35
|
drifted = false;
|
|
32
36
|
}
|
|
33
37
|
const latest = latestCurrentEvidence(record);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promote 证据门禁(纯函数,无 IO)——`omk list` 读侧判据的写侧对应。
|
|
3
|
+
*
|
|
4
|
+
* 把「这份当前版本是否值得 ship」收成一组可拦截原因(spec evidence-gated-management.md §5 mandatory four
|
|
5
|
+
* + §7 default gate)。默认只放行 verdict=PROGRESS(omk default-strict:影响「值得 ship」判定的默认必须严格);
|
|
6
|
+
* CAUTIOUS 需显式 `acceptCautious`;其余 verdict 一律拦。可比性核对(judge prompt 是否还是当前评委)用
|
|
7
|
+
* **注入**的当前 judge hash 集合,故本模块不依赖 grading 层、保持叶子可测。
|
|
8
|
+
*
|
|
9
|
+
* force(越门)不在此处:门禁只产「客观判定」。CLI 拿到结果后决定是否 `--force` 越过——但**无当前证据时
|
|
10
|
+
* 无 evidence 可锚定,force 也越不过**(返回的 evidence 为 undefined,CLI 据此拒绝空证据 promote)。
|
|
11
|
+
*/
|
|
12
|
+
import type { ManagedArtifactRecord, ManagedEvidenceRef } from '../types/index.js';
|
|
13
|
+
export type PromoteBlockKind = 'drifted' | 'no_current_evidence' | 'incomparable' | 'verdict_blocked';
|
|
14
|
+
export interface PromoteBlock {
|
|
15
|
+
/** 限定判别字(裸 kind 留给 ArtifactKind,见 terminology-spec §5.4);这是「拦截原因」分类、非持久。 */
|
|
16
|
+
blockKind: PromoteBlockKind;
|
|
17
|
+
/** 供 CLI 拼 i18n 参数的可读细节(被拦的 verdict / judge hash 等)。 */
|
|
18
|
+
detail?: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
export interface PromoteGateInput {
|
|
21
|
+
record: ManagedArtifactRecord;
|
|
22
|
+
/** 当前源内容 hash;undefined = 源不可达 / 解析失败(probeSourceState reachable:false)。 */
|
|
23
|
+
currentContentHash?: string;
|
|
24
|
+
/** 接受 CAUTIOUS(默认只接受 PROGRESS)。 */
|
|
25
|
+
acceptCautious?: boolean;
|
|
26
|
+
/** 当前 omk 的 judge prompt hash 集合(debias on/off 两变体)。CLI 用 getJudgePromptHash 算好注入,
|
|
27
|
+
* 使本模块不依赖 grading 层。空集 / 省略 = 跳过可比性核对(只在无法取 judge hash 时)。 */
|
|
28
|
+
currentJudgeHashes?: ReadonlySet<string>;
|
|
29
|
+
}
|
|
30
|
+
export interface PromoteGateResult {
|
|
31
|
+
/** 无任何拦截原因 → 门禁通过。 */
|
|
32
|
+
ok: boolean;
|
|
33
|
+
blocked: PromoteBlock[];
|
|
34
|
+
/** 非拦截级提醒(如 judgePromptHash 缺失、无从核对可比性)。 */
|
|
35
|
+
warnings: PromoteBlock[];
|
|
36
|
+
/** 命中判定的当前有效证据;无当前证据时 undefined —— CLI 据此判定「force 也不能空证据 promote」。 */
|
|
37
|
+
evidence?: ManagedEvidenceRef;
|
|
38
|
+
drifted: boolean;
|
|
39
|
+
}
|
|
40
|
+
export declare function evaluatePromoteGate(input: PromoteGateInput): PromoteGateResult;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { latestCurrentEvidence } from './list-view.js';
|
|
2
|
+
// 默认接受集:只 PROGRESS。
|
|
3
|
+
const DEFAULT_ACCEPTED = new Set(['PROGRESS']);
|
|
4
|
+
export function evaluatePromoteGate(input) {
|
|
5
|
+
const { record, currentContentHash, acceptCautious, currentJudgeHashes } = input;
|
|
6
|
+
const blocked = [];
|
|
7
|
+
const warnings = [];
|
|
8
|
+
const drifted = currentContentHash === undefined || currentContentHash !== record.contentHash;
|
|
9
|
+
// 1. drift:源不可达 / 内容已变 → 当前盘上的源不是被测内容,门禁不过(force 可越,见 CLI)。
|
|
10
|
+
if (drifted)
|
|
11
|
+
blocked.push({ blockKind: 'drifted' });
|
|
12
|
+
// 2. 当前有效证据(contentHash == record.contentHash;源 drift 不影响——证据绑的是记录基线内容)。
|
|
13
|
+
const evidence = latestCurrentEvidence(record);
|
|
14
|
+
if (!evidence) {
|
|
15
|
+
// 无证据无从谈 verdict / 可比性,且 force 也无 evidence 可锚定 → 提前收,终态拦截。
|
|
16
|
+
blocked.push({ blockKind: 'no_current_evidence' });
|
|
17
|
+
return { ok: false, blocked, warnings, drifted };
|
|
18
|
+
}
|
|
19
|
+
// 3. 可比性:judgePromptHash 在场且不属当前任一评委模板 → 评委已变、旧 verdict 不可比 → 拦。
|
|
20
|
+
// 缺失 → 无从核对(旧报告 / 无 judge 评测)→ warn 不拦。cliVersion 仅展示、不硬卡(否则每次发版即全失效)。
|
|
21
|
+
const judgeHash = evidence.comparability?.judgePromptHash;
|
|
22
|
+
if (judgeHash !== undefined && currentJudgeHashes && currentJudgeHashes.size > 0) {
|
|
23
|
+
if (!currentJudgeHashes.has(judgeHash)) {
|
|
24
|
+
blocked.push({ blockKind: 'incomparable', detail: { judgePromptHash: judgeHash } });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else if (judgeHash === undefined) {
|
|
28
|
+
warnings.push({ blockKind: 'incomparable' });
|
|
29
|
+
}
|
|
30
|
+
// 4. verdict ∈ 接受集。
|
|
31
|
+
const accepted = acceptCautious ? new Set([...DEFAULT_ACCEPTED, 'CAUTIOUS']) : DEFAULT_ACCEPTED;
|
|
32
|
+
const verdict = evidence.verdict;
|
|
33
|
+
if (!verdict || !accepted.has(verdict)) {
|
|
34
|
+
blocked.push({ blockKind: 'verdict_blocked', detail: { verdict: verdict ?? 'UNKNOWN' } });
|
|
35
|
+
}
|
|
36
|
+
return { ok: blocked.length === 0, blocked, warnings, evidence, drifted };
|
|
37
|
+
}
|