skill-family-engineering-kit 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +13 -67
- package/README.md +26 -3
- package/SECURITY.md +4 -9
- package/THIRD_PARTY_NOTICES +22 -9
- package/docs/404.html +27 -0
- package/docs/architecture/index.html +84 -10
- package/docs/git-lifecycle/index.html +27 -0
- package/docs/help/index.html +27 -0
- package/docs/index.html +27 -0
- package/docs/integration/audit/failure-evidence/index.html +27 -0
- package/docs/integration/audit/independence/index.html +27 -0
- package/docs/integration/audit/index.html +29 -1
- package/docs/integration/audit/mutation-taxonomy/index.html +27 -0
- package/docs/integration/audit/version-compatibility/index.html +32 -2
- package/docs/migration/index.html +31 -4
- package/docs/public/status/index.html +858 -0
- package/docs/quickstart/index.html +27 -0
- package/docs/search/search_index.json +1 -1
- package/docs/setup/index.html +30 -2
- package/docs/sitemap.xml +4 -0
- package/package.json +5 -5
- package/src/check.mjs +211 -76
- package/src/cli.mjs +185 -4
- package/src/core-check.mjs +223 -0
- package/src/errors.mjs +16 -0
- package/src/host-drivers.mjs +24 -0
- package/src/host-profiles.mjs +51 -0
- package/src/host.mjs +108 -0
- package/src/index.mjs +35 -7
- package/src/report.mjs +335 -0
- package/src/skeleton.mjs +19 -79
- package/src/workspace.mjs +82 -0
package/src/cli.mjs
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { readFileContained, resolveContained } from "skill-family-harness-node";
|
|
5
6
|
import { bundledProfilesRoot } from "./licensing.mjs";
|
|
7
|
+
import { checkReportAction, renderReportAction } from "./report.mjs";
|
|
6
8
|
import {
|
|
7
9
|
COMMAND_SIDE_EFFECTS,
|
|
8
10
|
KIT_EXIT_CODES,
|
|
@@ -12,14 +14,23 @@ import {
|
|
|
12
14
|
unknownCommandError,
|
|
13
15
|
invalidParamsError,
|
|
14
16
|
mutationModeError,
|
|
17
|
+
describeHost,
|
|
18
|
+
probeHost,
|
|
19
|
+
buildHostAdapter,
|
|
20
|
+
materializeHostBuild,
|
|
21
|
+
planHost,
|
|
22
|
+
refuseHostApply,
|
|
15
23
|
} from "./index.mjs";
|
|
16
24
|
|
|
17
25
|
/**
|
|
18
26
|
* skill-family-engineering-kit CLI.
|
|
19
27
|
*
|
|
20
|
-
* Exactly four top-level commands exist; there is no fifth.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
28
|
+
* Exactly four top-level commands exist; there is no fifth. The report layer
|
|
29
|
+
* (FND-ADR-005) hangs under two of them as positional sub-actions —
|
|
30
|
+
* `projection report` (render) and `check report` (graded diagnosis) — not as
|
|
31
|
+
* a fifth command. Exit codes are stable: 0 = ok/clean, 1 = findings (check),
|
|
32
|
+
* 2 = rejected/usage/mechanism error. Every refusal prints its registered
|
|
33
|
+
* SFC code and stable kind.
|
|
23
34
|
*/
|
|
24
35
|
|
|
25
36
|
const HELP_TEXT = `skill-family-engineering-kit —— 构建期工程工具包(恰好四个顶层命令)
|
|
@@ -37,15 +48,22 @@ const HELP_TEXT = `skill-family-engineering-kit —— 构建期工程工具包
|
|
|
37
48
|
projection 投影受管生成物。
|
|
38
49
|
副作用: 只写 manifest 授权且被目标声明为受管的路径;
|
|
39
50
|
未授权路径、手写文件与越界路径一律拒绝(拒绝时零写入)。
|
|
51
|
+
报告子动作: projection report 将调用方提交的 report-model 确定性渲染为
|
|
52
|
+
中立 Markdown(FND-ADR-005);默认只写 stdout,给定 --out/
|
|
53
|
+
--binding 时只写这两个显式且被 --root 收容的路径(原子写)。
|
|
40
54
|
check 契约/漂移/闭包/版本/文档事实/Git 前置状态诊断。
|
|
41
55
|
副作用: 无 —— 只诊断、绝不自动修复,git 仅只读探测。
|
|
56
|
+
报告子动作: check report 对一份已渲染报告做分级诊断:
|
|
57
|
+
硬失败(SFC3001/3002/3003)计为发现(退出码 1);
|
|
58
|
+
风格告警只报告、绝不阻塞机器正确的报告。
|
|
42
59
|
|
|
43
60
|
全局选项:
|
|
44
61
|
--root <dir> 目标工作区根目录(默认当前目录)
|
|
45
62
|
--help, -h 显示本帮助或单个命令的帮助
|
|
46
63
|
|
|
47
64
|
退出码: 0 成功/无发现;1 check 有发现;2 拒绝/用法/机制错误。
|
|
48
|
-
错误码复用 contracts 的 SFC* 体系(SFC2002/SFC2003/SFC2004 + 稳定 details.kind
|
|
65
|
+
错误码复用 contracts 的 SFC* 体系(SFC2002/SFC2003/SFC2004 + 稳定 details.kind;
|
|
66
|
+
报告层硬失败使用 SFC3001/SFC3002/SFC3003)。
|
|
49
67
|
禁止项: git init/commit/push/tag、发布、删除、远端写入;不存在第五个顶层命令。
|
|
50
68
|
`;
|
|
51
69
|
|
|
@@ -61,6 +79,10 @@ function commandHelp(command) {
|
|
|
61
79
|
" --licensing-profile <id> 许可证 Profile id(默认 registry 第一个变体)",
|
|
62
80
|
" --licensing-variant <id> 多变体 Profile 的必选变体 id",
|
|
63
81
|
" --profiles-root <dir> 许可证 Profile 根目录",
|
|
82
|
+
"",
|
|
83
|
+
"宿主构建: skill-family-kit scaffold host-build --root <workspace> --host <id>",
|
|
84
|
+
" --path-category <id> --input <relpath> --out <relpath> [--hosts-root <dir>]",
|
|
85
|
+
" 将完整 source closure 逐字节写入 sibling staging,验证后一次 rename;目标必须不存在。",
|
|
64
86
|
],
|
|
65
87
|
"adopt-plan": [
|
|
66
88
|
"选项:",
|
|
@@ -72,6 +94,11 @@ function commandHelp(command) {
|
|
|
72
94
|
" --profiles-root <dir> 许可证 Profile 根目录",
|
|
73
95
|
" --no-git-spawn 禁用只读 git status 探测,仅用文件系统事实",
|
|
74
96
|
"",
|
|
97
|
+
"只读宿主子动作: host-describe | host-probe | host-plan | host-apply",
|
|
98
|
+
" host-probe 默认不执行进程;仅 --allow-host-spawn + --host-executable <绝对路径> 可启用受审计版本向量;",
|
|
99
|
+
" host-plan 必须显式提供 --build-manifest/--probe-facts,绝不隐式探测;",
|
|
100
|
+
" host-apply 在本阶段稳定拒绝,且不会产生占位文件或临时目录。",
|
|
101
|
+
"",
|
|
75
102
|
" 迁移闭环(FND-070): 只读读取目标仓 skill-family.migration.json",
|
|
76
103
|
" (旧实现退出清单 + 临时例外);例外缺 owner/reason/deadline/",
|
|
77
104
|
" migrationTarget 任一字段即计为冲突;到期例外不自动续期;",
|
|
@@ -81,6 +108,13 @@ function commandHelp(command) {
|
|
|
81
108
|
"选项:",
|
|
82
109
|
" --root <dir> 目标工作区(默认当前目录)",
|
|
83
110
|
" --manifest <relpath> 投影 manifest 相对路径(默认 skill-family.projection.json)",
|
|
111
|
+
"",
|
|
112
|
+
"报告子动作: skill-family-kit projection report [options]",
|
|
113
|
+
" --model <relpath> 经 Contracts 验证的 report-model JSON(必填)",
|
|
114
|
+
" --result <relpath> 机器结果 JSON(必填,仅用于摘要和错误绑定核对)",
|
|
115
|
+
" --out <relpath> 报告 Markdown 输出路径;缺省时 Markdown 只写 stdout",
|
|
116
|
+
" --binding <relpath> 同时写出 report-binding JSON(model/result/report 三摘要)",
|
|
117
|
+
" 确定性: 同一 report-model 反复渲染字节一致;Harness 不推导状态、不补造事实。",
|
|
84
118
|
],
|
|
85
119
|
check: [
|
|
86
120
|
"选项:",
|
|
@@ -88,6 +122,14 @@ function commandHelp(command) {
|
|
|
88
122
|
" --only <class> 只运行一个诊断类: contracts|drift|closure|version|docs|git|identity",
|
|
89
123
|
" --profiles-root <dir> 许可证 Profile 根目录",
|
|
90
124
|
" --no-git-spawn 禁用只读 git status 探测,仅用文件系统事实",
|
|
125
|
+
"",
|
|
126
|
+
"报告子动作: skill-family-kit check report [options]",
|
|
127
|
+
" --report <relpath> 已渲染报告 Markdown(必填,只读)",
|
|
128
|
+
" --model <relpath> 对应 report-model JSON(必填,只读)",
|
|
129
|
+
" --result <relpath> 对应机器结果 JSON(必填,只读;核对 digest/errors)",
|
|
130
|
+
" --binding <relpath> 可选 report-binding JSON;摘要过期即 SFC3001",
|
|
131
|
+
" 分级: 硬失败(SFC3001/3002/3003)计为发现,退出码 1;",
|
|
132
|
+
" 风格告警(超长句/重复段/翻译腔/未解释术语)只报告、绝不改变判定。",
|
|
91
133
|
],
|
|
92
134
|
};
|
|
93
135
|
return [
|
|
@@ -172,8 +214,134 @@ const COMMAND_SPECS = {
|
|
|
172
214
|
"--no-git-spawn": { key: "noGitSpawn", value: false },
|
|
173
215
|
},
|
|
174
216
|
},
|
|
217
|
+
"projection report": {
|
|
218
|
+
flags: {
|
|
219
|
+
"--root": { key: "root", value: true },
|
|
220
|
+
"--model": { key: "model", value: true },
|
|
221
|
+
"--result": { key: "result", value: true },
|
|
222
|
+
"--out": { key: "out", value: true },
|
|
223
|
+
"--binding": { key: "binding", value: true },
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
"check report": {
|
|
227
|
+
flags: {
|
|
228
|
+
"--root": { key: "root", value: true },
|
|
229
|
+
"--report": { key: "report", value: true },
|
|
230
|
+
"--model": { key: "model", value: true },
|
|
231
|
+
"--result": { key: "result", value: true },
|
|
232
|
+
"--binding": { key: "binding", value: true },
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
"scaffold host-build": {
|
|
236
|
+
flags: {
|
|
237
|
+
"--root": { key: "root", value: true },
|
|
238
|
+
"--host": { key: "hostId", value: true },
|
|
239
|
+
"--path-category": { key: "pathCategoryId", value: true },
|
|
240
|
+
"--input": { key: "input", value: true },
|
|
241
|
+
"--out": { key: "out", value: true },
|
|
242
|
+
"--hosts-root": { key: "hostsRoot", value: true },
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
"adopt-plan host-describe": {
|
|
246
|
+
flags: { "--host": { key: "hostId", value: true }, "--hosts-root": { key: "hostsRoot", value: true } },
|
|
247
|
+
},
|
|
248
|
+
"adopt-plan host-probe": {
|
|
249
|
+
flags: {
|
|
250
|
+
"--host": { key: "hostId", value: true },
|
|
251
|
+
"--hosts-root": { key: "hostsRoot", value: true },
|
|
252
|
+
"--allow-host-spawn": { key: "allowHostSpawn", value: false },
|
|
253
|
+
"--host-executable": { key: "hostExecutable", value: true },
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
"adopt-plan host-plan": {
|
|
257
|
+
flags: {
|
|
258
|
+
"--root": { key: "root", value: true },
|
|
259
|
+
"--host": { key: "hostId", value: true },
|
|
260
|
+
"--path-category": { key: "pathCategoryId", value: true },
|
|
261
|
+
"--build-manifest": { key: "buildManifest", value: true },
|
|
262
|
+
"--probe-facts": { key: "probeFacts", value: true },
|
|
263
|
+
"--hosts-root": { key: "hostsRoot", value: true },
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
"adopt-plan host-apply": { flags: {} },
|
|
175
267
|
};
|
|
176
268
|
|
|
269
|
+
async function readJson(root, relPath, label) {
|
|
270
|
+
if (!relPath) throw invalidParamsError(`${label} is required`);
|
|
271
|
+
try {
|
|
272
|
+
return JSON.parse((await readFileContained(root, relPath)).toString("utf8"));
|
|
273
|
+
} catch (cause) {
|
|
274
|
+
if (cause?.code?.startsWith?.("SFC")) throw cause;
|
|
275
|
+
throw invalidParamsError(`${label} must name valid JSON contained in --root`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function runHostSubAction(command, action, rest) {
|
|
280
|
+
const specName = `${command} ${action}`;
|
|
281
|
+
try {
|
|
282
|
+
if (action === "host-apply") refuseHostApply();
|
|
283
|
+
const spec = COMMAND_SPECS[specName];
|
|
284
|
+
const options = parseOptions(rest, { command: specName, flags: spec.flags });
|
|
285
|
+
const hostsRoot = options.hostsRoot;
|
|
286
|
+
let output;
|
|
287
|
+
if (action === "host-describe") {
|
|
288
|
+
output = await describeHost({ hostId: options.hostId, hostsRoot });
|
|
289
|
+
} else if (action === "host-probe") {
|
|
290
|
+
output = await probeHost({ hostId: options.hostId, hostsRoot, allowSpawn: options.allowHostSpawn === true, executable: options.hostExecutable });
|
|
291
|
+
} else if (action === "host-build") {
|
|
292
|
+
const root = options.root ?? ".";
|
|
293
|
+
const input = await readJson(root, options.input, "--input");
|
|
294
|
+
const build = await buildHostAdapter({ hostId: options.hostId, pathCategoryId: options.pathCategoryId, input, hostsRoot });
|
|
295
|
+
if (build.status === "built") {
|
|
296
|
+
if (!options.out) throw invalidParamsError("--out is required");
|
|
297
|
+
const targetRoot = await resolveContained(root, options.out);
|
|
298
|
+
await materializeHostBuild({ targetRoot, build });
|
|
299
|
+
}
|
|
300
|
+
output = build.status === "built" ? { status: "built", manifest: build.manifest, outputRoot: options.out } : build;
|
|
301
|
+
} else if (action === "host-plan") {
|
|
302
|
+
const root = options.root ?? ".";
|
|
303
|
+
const buildManifest = await readJson(root, options.buildManifest, "--build-manifest");
|
|
304
|
+
const probeDocument = await readJson(root, options.probeFacts, "--probe-facts");
|
|
305
|
+
const probeFacts = Array.isArray(probeDocument) ? probeDocument : probeDocument.facts;
|
|
306
|
+
output = await planHost({ hostId: options.hostId, pathCategoryId: options.pathCategoryId, buildManifest, probeFacts, hostsRoot });
|
|
307
|
+
}
|
|
308
|
+
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
|
|
309
|
+
return KIT_EXIT_CODES.ok;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
printError(error);
|
|
312
|
+
return KIT_EXIT_CODES.rejected;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function runReportSubAction(command, rest) {
|
|
317
|
+
const specName = `${command} report`;
|
|
318
|
+
try {
|
|
319
|
+
const options = parseOptions(rest, { command: specName, flags: COMMAND_SPECS[specName].flags });
|
|
320
|
+
if (options.help) {
|
|
321
|
+
process.stdout.write(`${commandHelp(command)}\n`);
|
|
322
|
+
return KIT_EXIT_CODES.ok;
|
|
323
|
+
}
|
|
324
|
+
const action = command === "projection" ? renderReportAction : checkReportAction;
|
|
325
|
+
const { status, output } = await action({
|
|
326
|
+
root: options.root,
|
|
327
|
+
model: options.model,
|
|
328
|
+
result: options.result,
|
|
329
|
+
report: options.report,
|
|
330
|
+
out: options.out,
|
|
331
|
+
binding: options.binding,
|
|
332
|
+
});
|
|
333
|
+
if (output !== undefined) {
|
|
334
|
+
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
|
|
335
|
+
}
|
|
336
|
+
if (status === "ok") return KIT_EXIT_CODES.ok;
|
|
337
|
+
if (status === "findings") return KIT_EXIT_CODES.findings;
|
|
338
|
+
return KIT_EXIT_CODES.rejected;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
printError(error);
|
|
341
|
+
return KIT_EXIT_CODES.rejected;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
177
345
|
export async function cliMain(argv) {
|
|
178
346
|
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
179
347
|
process.stdout.write(HELP_TEXT);
|
|
@@ -185,6 +353,19 @@ export async function cliMain(argv) {
|
|
|
185
353
|
return KIT_EXIT_CODES.rejected;
|
|
186
354
|
}
|
|
187
355
|
|
|
356
|
+
// Positional report sub-actions (FND-ADR-005): `projection report` renders,
|
|
357
|
+
// `check report` diagnoses. They hang under existing commands; the
|
|
358
|
+
// four-command vocabulary and runCommand stay untouched.
|
|
359
|
+
if ((command === "projection" || command === "check") && argv[1] === "report") {
|
|
360
|
+
return runReportSubAction(command, argv.slice(2));
|
|
361
|
+
}
|
|
362
|
+
if (command === "scaffold" && argv[1] === "host-build") {
|
|
363
|
+
return runHostSubAction(command, "host-build", argv.slice(2));
|
|
364
|
+
}
|
|
365
|
+
if (command === "adopt-plan" && ["host-describe", "host-probe", "host-plan", "host-apply"].includes(argv[1])) {
|
|
366
|
+
return runHostSubAction(command, argv[1], argv.slice(2));
|
|
367
|
+
}
|
|
368
|
+
|
|
188
369
|
try {
|
|
189
370
|
const options = parseOptions(argv.slice(1), { command, flags: COMMAND_SPECS[command].flags });
|
|
190
371
|
if (options.help) {
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { KIT_ERROR_KINDS } from "./errors.mjs";
|
|
4
|
+
import { CHECK_CLASSES, runChecks } from "./check.mjs";
|
|
5
|
+
import {
|
|
6
|
+
IDENTITY_RECORD_PATH,
|
|
7
|
+
MANAGED_LOCK_PATH,
|
|
8
|
+
PROJECT_MANIFEST_PATH,
|
|
9
|
+
} from "./skeleton.mjs";
|
|
10
|
+
import {
|
|
11
|
+
listTargetEntries,
|
|
12
|
+
loadTargetFacts,
|
|
13
|
+
matchAnyGitignorePattern,
|
|
14
|
+
matchAnyGlob,
|
|
15
|
+
resolveTargetRoot,
|
|
16
|
+
} from "./workspace.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* runCoreCheck — the single shared production entry of the closed-world
|
|
20
|
+
* project check (C2).
|
|
21
|
+
*
|
|
22
|
+
* The generated `scripts/check-core.mjs` entrypoints of scaffolded projects
|
|
23
|
+
* ONLY import this function: safe file-tree enumeration, lstat/symlink/FIFO
|
|
24
|
+
* and special-entry rejection, file-registry classification (managed /
|
|
25
|
+
* handwritten / artifact closed world), declaration path containment, the
|
|
26
|
+
* seven check classes, and the stable finding/exit mapping all live here and
|
|
27
|
+
* nowhere else. No generated string may re-derive a recursive walk, an lstat
|
|
28
|
+
* classification, a glob match, or a local closed-world algorithm.
|
|
29
|
+
*
|
|
30
|
+
* Stages (all read-only; never opens a file before it is lstat-classified):
|
|
31
|
+
* 1. declaration containment — escaping managed declarations (absolute
|
|
32
|
+
* paths, `..` segments) are reported lexically and never touch the fs;
|
|
33
|
+
* 2. managed preflight — lstat only: missing / symlink / non-regular
|
|
34
|
+
* managed paths fail closed before anything could read them;
|
|
35
|
+
* 3. tree enumeration — every symlink and special entry fails closed;
|
|
36
|
+
* every regular file must belong to the registered closed world
|
|
37
|
+
* (managed, handwritten, artifact, or a tracked tool lock);
|
|
38
|
+
* 4. the seven check classes through runChecks — skipped whenever an
|
|
39
|
+
* unsafe path (symlink/special at a managed or class-read path) would
|
|
40
|
+
* be opened by them: FIFOs must never be read (they block), symlinks
|
|
41
|
+
* must never be followed.
|
|
42
|
+
*
|
|
43
|
+
* Exit mapping: 0 clean; 2 when any finding carries a security kind
|
|
44
|
+
* (CORE_CHECK_SECURITY_KINDS, including the harness containment kinds that
|
|
45
|
+
* can surface through escaping lock entries); 1 otherwise.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** Finding kinds that always map to the security exit code 2. */
|
|
49
|
+
export const CORE_CHECK_SECURITY_KINDS = Object.freeze([
|
|
50
|
+
// Kit kinds of this entry.
|
|
51
|
+
KIT_ERROR_KINDS.SYMLINK_AT_MANAGED_PATH,
|
|
52
|
+
KIT_ERROR_KINDS.NOT_A_REGULAR_FILE,
|
|
53
|
+
KIT_ERROR_KINDS.SYMLINK_ENTRY,
|
|
54
|
+
KIT_ERROR_KINDS.SPECIAL_ENTRY,
|
|
55
|
+
KIT_ERROR_KINDS.UNCONTAINED_DECLARATION,
|
|
56
|
+
// Stable harness containment kinds that surface through drift findings when
|
|
57
|
+
// a lock entry tries to escape the target (reported, never followed).
|
|
58
|
+
"absolute-path",
|
|
59
|
+
"windows-drive-path",
|
|
60
|
+
"windows-path",
|
|
61
|
+
"unc-path",
|
|
62
|
+
"path-traversal",
|
|
63
|
+
"symlink-escape",
|
|
64
|
+
"realpath-escape",
|
|
65
|
+
"invalid-path",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const CORE_CLASS = "core";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Paths the seven check classes open by name. They take part in the unsafe
|
|
72
|
+
* read-set even when they are not declared managed (README.md is handwritten
|
|
73
|
+
* in the skeleton), so a symlink/FIFO there can never hang or escape a read.
|
|
74
|
+
*/
|
|
75
|
+
const CLASS_READ_PATHS = Object.freeze([
|
|
76
|
+
"README.md",
|
|
77
|
+
"package.json",
|
|
78
|
+
PROJECT_MANIFEST_PATH,
|
|
79
|
+
MANAGED_LOCK_PATH,
|
|
80
|
+
IDENTITY_RECORD_PATH,
|
|
81
|
+
"identity-record.json",
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/** Lexical containment of a managed declaration; never touches the fs. */
|
|
85
|
+
export function isContainedDeclaration(rel) {
|
|
86
|
+
if (typeof rel !== "string" || rel.length === 0) return false;
|
|
87
|
+
if (rel.startsWith("/")) return false;
|
|
88
|
+
if (rel.split("/").includes("..")) return false;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Runs the unified closed-world check over one target.
|
|
94
|
+
* Options: { root, allowGitSpawn, profilesRoot }.
|
|
95
|
+
* Returns { kind, ok, exitCode, findings, classes, unsafeReadPaths }.
|
|
96
|
+
* Never writes anywhere.
|
|
97
|
+
*/
|
|
98
|
+
export async function runCoreCheck({ root, allowGitSpawn = true, profilesRoot } = {}) {
|
|
99
|
+
const rootAbs = await resolveTargetRoot(root ?? ".");
|
|
100
|
+
const facts = await loadTargetFacts(rootAbs);
|
|
101
|
+
const findings = [];
|
|
102
|
+
const push = (kind, message, extra) =>
|
|
103
|
+
findings.push({ class: CORE_CLASS, kind, code: "SFC2004", message, ...(extra ?? {}) });
|
|
104
|
+
|
|
105
|
+
// ---- Stage 1: declaration containment (purely lexical) -----------------
|
|
106
|
+
const declarations = [...facts.managedSet].sort();
|
|
107
|
+
const containedDeclarations = [];
|
|
108
|
+
for (const rel of declarations) {
|
|
109
|
+
if (isContainedDeclaration(rel)) {
|
|
110
|
+
containedDeclarations.push(rel);
|
|
111
|
+
} else {
|
|
112
|
+
push(
|
|
113
|
+
KIT_ERROR_KINDS.UNCONTAINED_DECLARATION,
|
|
114
|
+
`managed declaration escapes the target root (absolute path or '..' segment): ${rel}`,
|
|
115
|
+
{ path: rel },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- Stage 2: managed preflight (lstat only, never open/read) ----------
|
|
121
|
+
const unsafeReadPaths = new Set();
|
|
122
|
+
for (const rel of containedDeclarations) {
|
|
123
|
+
let st;
|
|
124
|
+
try {
|
|
125
|
+
st = await lstat(path.join(rootAbs, rel));
|
|
126
|
+
} catch {
|
|
127
|
+
push(
|
|
128
|
+
KIT_ERROR_KINDS.MANAGED_FILE_MISSING,
|
|
129
|
+
`managed file declared in the registry/manifest/lock does not exist: ${rel}`,
|
|
130
|
+
{ path: rel },
|
|
131
|
+
);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (st.isSymbolicLink()) {
|
|
135
|
+
push(KIT_ERROR_KINDS.SYMLINK_AT_MANAGED_PATH, `managed path is a symbolic link: ${rel}`, { path: rel });
|
|
136
|
+
unsafeReadPaths.add(rel);
|
|
137
|
+
} else if (!st.isFile()) {
|
|
138
|
+
push(
|
|
139
|
+
KIT_ERROR_KINDS.NOT_A_REGULAR_FILE,
|
|
140
|
+
`managed path is not a regular file (FIFO/socket/device/directory): ${rel}`,
|
|
141
|
+
{ path: rel },
|
|
142
|
+
);
|
|
143
|
+
unsafeReadPaths.add(rel);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- Stage 3: safe tree enumeration + closed-world classification ------
|
|
148
|
+
const entries = await listTargetEntries(rootAbs);
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
if (entry.kind === "directory" || entry.kind === "directory-opaque") continue;
|
|
151
|
+
if (entry.kind === "symlink") {
|
|
152
|
+
// Symlinks are never classified by content patterns: unregistered ones
|
|
153
|
+
// fail closed; managed ones were already reported by the preflight.
|
|
154
|
+
if (!facts.managedSet.has(entry.path)) {
|
|
155
|
+
push(
|
|
156
|
+
KIT_ERROR_KINDS.SYMLINK_ENTRY,
|
|
157
|
+
`symbolic link is outside the registered closed world (managed/handwritten/artifact): ${entry.path}`,
|
|
158
|
+
{ path: entry.path },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (facts.managedSet.has(entry.path) || CLASS_READ_PATHS.includes(entry.path)) {
|
|
162
|
+
unsafeReadPaths.add(entry.path);
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (entry.kind === "special") {
|
|
167
|
+
if (!facts.managedSet.has(entry.path)) {
|
|
168
|
+
push(
|
|
169
|
+
KIT_ERROR_KINDS.SPECIAL_ENTRY,
|
|
170
|
+
`special entry (FIFO/socket/device) is outside the registered closed world: ${entry.path}`,
|
|
171
|
+
{ path: entry.path },
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (facts.managedSet.has(entry.path) || CLASS_READ_PATHS.includes(entry.path)) {
|
|
175
|
+
unsafeReadPaths.add(entry.path);
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// Regular files must belong to exactly one registered class. Managed
|
|
180
|
+
// bytes are verified by the drift class, never re-derived here.
|
|
181
|
+
if (facts.managedSet.has(entry.path)) continue;
|
|
182
|
+
if (matchAnyGlob(facts.handwrittenPatterns, entry.path)) continue;
|
|
183
|
+
if (matchAnyGitignorePattern(facts.artifactPatterns, entry.path)) continue;
|
|
184
|
+
if (facts.trackedToolLocks.includes(entry.path)) continue;
|
|
185
|
+
push(
|
|
186
|
+
KIT_ERROR_KINDS.UNREGISTERED_FILE,
|
|
187
|
+
`regular file is not registered in the closed world (managed/handwritten/artifact): ${entry.path}`,
|
|
188
|
+
{ path: entry.path },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- Stage 4: the seven check classes through the shared entry ---------
|
|
193
|
+
let classReport = null;
|
|
194
|
+
if (unsafeReadPaths.size === 0) {
|
|
195
|
+
classReport = await runChecks({ root: rootAbs, allowGitSpawn, profilesRoot });
|
|
196
|
+
findings.push(...classReport.findings);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let exitCode = 0;
|
|
200
|
+
if (findings.length > 0) {
|
|
201
|
+
const securityKinds = new Set(CORE_CHECK_SECURITY_KINDS);
|
|
202
|
+
exitCode = findings.some((f) => securityKinds.has(f.kind)) ? 2 : 1;
|
|
203
|
+
}
|
|
204
|
+
if (classReport && classReport.mechanism) exitCode = 2;
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
kind: "skill-family.core-check-report",
|
|
208
|
+
schemaVersion: 1,
|
|
209
|
+
target: { root: ".", entryCount: entries.length },
|
|
210
|
+
ok: findings.length === 0,
|
|
211
|
+
exitCode,
|
|
212
|
+
findings,
|
|
213
|
+
classes:
|
|
214
|
+
classReport?.classes ??
|
|
215
|
+
CHECK_CLASSES.map((name) => ({ name, selected: false, completed: false, findings: 0 })),
|
|
216
|
+
classChecksSkipped:
|
|
217
|
+
unsafeReadPaths.size > 0
|
|
218
|
+
? { reason: "unsafe read-set (symlink/special at a managed or class-read path)", paths: [...unsafeReadPaths].sort() }
|
|
219
|
+
: null,
|
|
220
|
+
policy:
|
|
221
|
+
"core check is diagnosis only: it enumerates with lstat, never opens an unclassified entry, never follows symlinks, and never writes",
|
|
222
|
+
};
|
|
223
|
+
}
|
package/src/errors.mjs
CHANGED
|
@@ -48,9 +48,12 @@ export const KIT_ERROR_KINDS = Object.freeze({
|
|
|
48
48
|
// check / shared
|
|
49
49
|
CONTRACTS_MISSING: "contracts-missing",
|
|
50
50
|
CONTRACT_PARSE_FAILED: "contract-parse-failed",
|
|
51
|
+
DOCUMENT_INCOMPLETE: "document-incomplete",
|
|
52
|
+
CHECK_CLASS_FAILED: "check-class-failed",
|
|
51
53
|
MANAGED_FILE_MISSING: "managed-file-missing",
|
|
52
54
|
MANAGED_FILE_DRIFT: "managed-file-drift",
|
|
53
55
|
SYMLINK_AT_MANAGED_PATH: "symlink-at-managed-path",
|
|
56
|
+
NOT_A_REGULAR_FILE: "not-a-regular-file",
|
|
54
57
|
CLOSURE_INPUT_MISSING: "closure-input-missing",
|
|
55
58
|
CONTRACTS_VERSION_MISMATCH: "contracts-version-mismatch",
|
|
56
59
|
README_MISSING: "readme-missing",
|
|
@@ -59,6 +62,19 @@ export const KIT_ERROR_KINDS = Object.freeze({
|
|
|
59
62
|
GIT_NO_COMMITS: "git-no-commits",
|
|
60
63
|
GIT_DIRTY: "git-dirty",
|
|
61
64
|
MUTATION_MODE_REQUESTED: "mutation-mode-requested",
|
|
65
|
+
// core check: the single shared closed-world entry (C2)
|
|
66
|
+
UNREGISTERED_FILE: "unregistered-file",
|
|
67
|
+
SYMLINK_ENTRY: "symlink-entry",
|
|
68
|
+
SPECIAL_ENTRY: "special-entry",
|
|
69
|
+
UNCONTAINED_DECLARATION: "uncontained-declaration",
|
|
70
|
+
// report sub-actions (projection report / check report, FND-ADR-005)
|
|
71
|
+
REPORT_INPUT_MISSING: "report-input-missing",
|
|
72
|
+
REPORT_PATH_CONFLICT: "report-path-conflict",
|
|
73
|
+
REPORT_WRITE_FAILED: "report-write-failed",
|
|
74
|
+
// read-only host integration slice
|
|
75
|
+
HOST_CONTRACT_INVALID: "host-contract-invalid",
|
|
76
|
+
HOST_PROBE_FAILED: "host-probe-failed",
|
|
77
|
+
HOST_BUILD_FAILED: "host-build-failed",
|
|
62
78
|
});
|
|
63
79
|
|
|
64
80
|
/** Error carrying one frozen SFC code plus a stable kit kind in details. */
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { probeVersionVector } from "skill-family-harness-node";
|
|
3
|
+
|
|
4
|
+
const CAPABILITIES = Object.freeze(["cli", "version", "payload", "registry", "discover", "enabled", "reload", "smoke", "uninstall"]);
|
|
5
|
+
const DRIVERS = Object.freeze({
|
|
6
|
+
"claude-version-v1": Object.freeze({ executableBasename: "claude", argv: Object.freeze(["--version"]) }),
|
|
7
|
+
"codex-version-v1": Object.freeze({ executableBasename: "codex", argv: Object.freeze(["--version"]) }),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Selects one audited host-specific driver vector. The generic process and
|
|
12
|
+
* four-state classification mechanism lives in Harness.
|
|
13
|
+
*/
|
|
14
|
+
export async function probeTrustedVersionDriver({ hostId, driverId, capabilities = CAPABILITIES, executable, allowSpawn = false, timeoutMs = 5000, runner } = {}) {
|
|
15
|
+
const driver = DRIVERS[driverId];
|
|
16
|
+
if (!driver) throw new TypeError(`unknown frozen host driver: ${String(driverId)}`);
|
|
17
|
+
if (allowSpawn && path.basename(executable ?? "") !== driver.executableBasename) {
|
|
18
|
+
throw new TypeError(`driver ${driverId} requires an explicit ${driver.executableBasename} executable`);
|
|
19
|
+
}
|
|
20
|
+
return probeVersionVector({ hostId, capabilities, executable, argv: driver.argv, allowSpawn, timeoutMs, runner });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const HOST_CAPABILITIES = CAPABILITIES;
|
|
24
|
+
export const HOST_DRIVER_IDS = Object.freeze(Object.keys(DRIVERS));
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { validateDocument } from "skill-family-contracts";
|
|
2
|
+
import { readFileContained } from "skill-family-harness-node";
|
|
3
|
+
import { invalidParamsError, kitError, KIT_ERROR_KINDS } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
const HOST_DESCRIPTOR_SCHEMA_ID = "https://contracts.skill-family.example/v1/host-descriptor.json";
|
|
6
|
+
const HOST_REGISTRY_SCHEMA_ID = "https://contracts.skill-family.example/v1/host-registry.json";
|
|
7
|
+
const HOST_ID_PATTERN = /^[a-z][a-z0-9-]{1,63}$/;
|
|
8
|
+
|
|
9
|
+
function validateRegistered(document, schemaId, message) {
|
|
10
|
+
const result = validateDocument(document, { schemaId, dialect: "2020-12", policy: "strict" });
|
|
11
|
+
if (!result.valid) throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, message, { errors: result.errors });
|
|
12
|
+
return result.data;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assertRegistrySemantics(registry) {
|
|
16
|
+
return registry;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function loadHostRegistry({ hostsRoot, registry } = {}) {
|
|
20
|
+
if (registry !== undefined) return Object.freeze(structuredClone(assertRegistrySemantics(validateRegistered(registry, HOST_REGISTRY_SCHEMA_ID, "host registry fails its registered contract"))));
|
|
21
|
+
if (!hostsRoot) throw invalidParamsError("loadHostRegistry requires an explicit hostsRoot or injected registry");
|
|
22
|
+
let document;
|
|
23
|
+
try {
|
|
24
|
+
document = JSON.parse((await readFileContained(hostsRoot, "registry.json")).toString("utf8"));
|
|
25
|
+
} catch (cause) {
|
|
26
|
+
if (cause?.code?.startsWith?.("SFC")) throw cause;
|
|
27
|
+
throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "host registry cannot be read or parsed", { causeKind: cause?.details?.kind ?? "unknown" });
|
|
28
|
+
}
|
|
29
|
+
return Object.freeze(structuredClone(assertRegistrySemantics(validateRegistered(document, HOST_REGISTRY_SCHEMA_ID, "host registry fails its registered contract"))));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function describeHost({ hostId, hostsRoot, registry } = {}) {
|
|
33
|
+
if (!HOST_ID_PATTERN.test(hostId ?? "")) throw invalidParamsError("describeHost requires a valid hostId");
|
|
34
|
+
const loaded = await loadHostRegistry({ hostsRoot, registry });
|
|
35
|
+
if (!loaded.hosts.includes(hostId)) throw invalidParamsError(`unknown host: ${hostId}`, { hostId });
|
|
36
|
+
if (!hostsRoot) throw invalidParamsError("host descriptor resolution requires an explicit hostsRoot");
|
|
37
|
+
let document;
|
|
38
|
+
try {
|
|
39
|
+
document = JSON.parse((await readFileContained(hostsRoot, `${hostId}/host-descriptor.json`)).toString("utf8"));
|
|
40
|
+
} catch (cause) {
|
|
41
|
+
if (cause?.code?.startsWith?.("SFC")) throw cause;
|
|
42
|
+
throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "host descriptor cannot be read or parsed", { hostId, causeKind: cause?.details?.kind ?? "unknown" });
|
|
43
|
+
}
|
|
44
|
+
const descriptor = validateRegistered(document, HOST_DESCRIPTOR_SCHEMA_ID, "host descriptor fails its registered contract");
|
|
45
|
+
if (descriptor.hostId !== hostId) throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "host descriptor hostId does not match its registry entry", { hostId });
|
|
46
|
+
const categoryIds = descriptor.pathCategories.map((category) => category.id);
|
|
47
|
+
if (new Set(categoryIds).size !== categoryIds.length) throw kitError(KIT_ERROR_KINDS.HOST_CONTRACT_INVALID, "host descriptor has duplicate path category ids", { hostId });
|
|
48
|
+
return Object.freeze(structuredClone(descriptor));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { HOST_DESCRIPTOR_SCHEMA_ID, HOST_REGISTRY_SCHEMA_ID };
|