@geoly-ai/skills-hub 0.3.5 → 0.3.7

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.
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env node
2
+ // 投稿的结构门(§6 那张表里**尚无实现**的那几条)—— 06-submission.md §6。
3
+ //
4
+ // 🔴 **只写还不存在的那几条。** §6 的表里大半已经有实现了,本模块不重写它们:
5
+ // · 载荷规则(文件类型 / mode / 上限 / 无 PAX 扩展头)→ `packer.collectTree` + `packEntries`
6
+ // · 六(七)项绑定 → `artifact.assertManifestBinding`
7
+ // · 路径 grammar → `untar` / `safe-fs` 的 parseSafeRelPath
8
+ // · pack lock / pack 兼容性 → `pack.resolvePackInstall` / `checkPackCompat`
9
+ // · namespace 所有权 → `promote/build-inputs.resolveOwner`
10
+ // 同一个不变量有两个校验器,就是 R-11 反复出现的形状。
11
+ //
12
+ // 本模块新写四条:
13
+ // ① 保留 **namespace**(`registry/reserved.json`;05-lifecycle.md §1.1)
14
+ // ② 归一化重名(NFKC + 小写 + 去连字符,**同 namespace 内**)
15
+ // ③ 版本未占用(**含已 yank** —— 制品不可变,yank 不释放版本号)
16
+ // ④ capability 一致性(声明 `none` 却含 `0755` / 脚本 → 拒;外部 URL → **只告警**)
17
+
18
+ import { readFileSync, existsSync } from 'node:fs';
19
+
20
+ import { parseStrict } from '../../src/canonical-json.mjs';
21
+
22
+ export const RESERVED_SCHEMA = 'geoly.skills.reserved/1';
23
+
24
+ class GateError extends Error {
25
+ constructor(code, msg) { super(msg); this.name = 'GateError'; this.code = code; }
26
+ }
27
+ const bad = (code, msg) => { throw new GateError(code, msg); };
28
+
29
+ // ── ② 归一化重名 ───────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * §6:「NFKC + 小写 + 去连字符后不与**同 namespace 内**已有 name 撞」。
33
+ *
34
+ * ⚠️ **诚实边界:今天只有「去连字符」这一步真的会触发。**
35
+ * name 的 grammar 是 `[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?`(01-artifacts.md §3)
36
+ * —— 全小写 ASCII。于是对任何**已经过 grammar 门**的名字:
37
+ * · NFKC 是恒等变换(没有可归一化的兼容字符);
38
+ * · `toLowerCase()` 也是恒等变换。
39
+ * 三步照写是为了**grammar 将来放宽时这道门仍然正确**,不是因为它们现在有用。
40
+ * `test/submission-gates.test.mjs` 里有一条断言钉住这个事实 ——
41
+ * 哪天 grammar 放宽了,那条断言会变,改的人就会看到这段说明。
42
+ */
43
+ export function normalizeName(name) {
44
+ return name.normalize('NFKC').toLowerCase().split('-').join('');
45
+ }
46
+
47
+ /**
48
+ * @param {object} a
49
+ * @param {string} a.name 本次投稿的 name
50
+ * @param {string[]} a.existing **同 namespace 内**已有的 name(含已 yank 的)
51
+ */
52
+ export function assertNoNormalizedCollision({ name, existing }) {
53
+ const target = normalizeName(name);
54
+ const hit = existing.filter((e) => e !== name && normalizeName(e) === target);
55
+ if (hit.length) {
56
+ bad('E_NAME_COLLIDE',
57
+ `${name} 归一化后是 ${JSON.stringify(target)},与同 namespace 内已有的 ${hit.join(', ')} 相撞。\n`
58
+ + ' §6:归一化重名一律拒绝 —— 两个只差连字符的名字,agent 的路由判定分不开,'
59
+ + '而人也看不出来。');
60
+ }
61
+ return true;
62
+ }
63
+
64
+ // ── ① 保留 namespace ───────────────────────────────────────────────────────
65
+
66
+ // ── 同形折叠(confusable fold)—— **只服务于保留清单比对** ──────────────────
67
+ //
68
+ // 🔴 **为什么只在这一层。** `normalizeName` 那套(NFKC + 小写 + 去连字符)挡得住
69
+ // `GEOLY` / `geoly` / `g-e-o-l-y`,却**挡不住数字替字母**:
70
+ // `ge0ly`、`geo1y`、`anthrop1c` 原来全部放行 —— 而防仿冒正是保留
71
+ // `anthropic` / `claude` / `openai` / `geoly` 的**全部理由**。
72
+ //
73
+ // 🔴 **攻击面是可枚举的,不是无底洞。** namespace 的 grammar 是
74
+ // `[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?`(01-artifacts.md §3)—— 攻击者手里
75
+ // **只有** a–z、0–9、连字符。连字符已被 `normalizeName` 去掉,非 ASCII 已被
76
+ // NFKC + grammar 拦在门外。于是残余的同形手段**只剩两类**:
77
+ // ① 数字冒充字母(`0`/`o`、`1`/`l`/`i`、`5`/`s` …)
78
+ // ② 小写 ASCII 字母冒充另一个字母或字母序列(`rn`/`m`、`vv`/`w`、`cl`/`d`)
79
+ // 本模块把这两类都折掉,然后**全串相等**比对。
80
+ //
81
+ // ⚠️ **明确不覆盖的层(不要以为这道门是「防仿冒」的完备实现):**
82
+ // · **子串 / 前后缀包含**:`geoly-ai`、`my-claude-helper`、`claudes` 一律放行。
83
+ // 这是**有意的** —— `geoly-ai` / `prompts-map` / `plaud-theme` 已在
84
+ // `registry/owners.json` 里且有线上制品,包含式判定会当场把它们杀掉。
85
+ // · **编辑距离 / 模糊相似**:`anthropi`、`anthropicc`、`caludе`(转置)不管。
86
+ // · **语音相似**:`fasebook` 这一路不管。
87
+ // · 🔴 **`l`/`i` 之外的字母↔字母替换**:`qeoly`、`beoly`、`qithub`、`bithub`、
88
+ // `reqistry`、`githug` **全部放行**。这不是漏了,是**算过账之后不做**:
89
+ // 要挡住它们就得把 `b`/`g`/`q` 并成一个字母等价类,而那立刻推出 `b ≡ g` ——
90
+ // 正常名字 `hug` 当场被判成冒充 `hub`(Codex 2026-09-03 实测给的)。
91
+ // **数字**之所以能按歧义处理(`hu6` 拦、`hug` 放行),是因为「名字里出现数字」
92
+ // 本身就是可用的信号;两个都是普通字母时没有这个信号,只能二选一。
93
+ // `l`/`i` 是唯一一对强到值得付这个代价的 —— 无衬线字体里它们**完全同形**。
94
+ // · **`li`→`h`、`ci`→`a`、`nn` 之外的更弱多字符对**:见下面 MULTI 的注释,
95
+ // 逐条写了为什么不做。
96
+ // 真正兜住这些的是 §8 的人工门,不是这道结构门。
97
+
98
+ /** 小写 ASCII 里真正的**字母↔字母**同形对。`l` 与 `i` 归一到 `i`。 */
99
+ const LETTER_CLASS = { l: 'i' };
100
+
101
+ /**
102
+ * 数字 → 它能冒充的字母。**只有一个候选的**在折叠时就地替换掉。
103
+ * 🔴 `6` 与 `9` 有**两个**候选(`6` 既像 `b` 又像 `g`,`9` 既像 `g` 又像 `q`),
104
+ * **不能**把它们并进同一个等价类 —— 那会让 `b ≡ g`,于是 `hug` 被判成
105
+ * 冒充 `hub`(Codex 2026-09-03 给的误伤例)。它们**原样保留**,
106
+ * 在 `confusableEquals` 里按「候选集合有交集」逐位判 —— 那个关系
107
+ * **故意不是传递的**:`b ~ 6 ~ g` 但 `b ≁ g`。
108
+ * 因为比对永远是「候选 vs 保留名」的单向两两比,不传递不构成问题。
109
+ */
110
+ const DIGIT_SOLE = { 0: 'o', 1: 'i', 2: 'z', 3: 'e', 4: 'a', 5: 's', 7: 't', 8: 'b' };
111
+ const DIGIT_AMBIG = { 6: ['b', 'g'], 9: ['g', 'q'] };
112
+
113
+ /**
114
+ * 多字符同形对。**在单字符折叠之后**施加,且模式写的是**折叠后的字母表**。
115
+ *
116
+ * 🔴 顺序是判据的一部分,不是实现细节。Codex 2026-09-03 指出:若按
117
+ * 「先多字符、后单字符」,`cl`→`d` 只认字面 `cl`,而 `l` 又会折成 `i` ——
118
+ * 于是 `claude`→`daude` 但 `c1aude`→`ciaude`,**等价关系不闭包,`c1aude` 直接绕过**。
119
+ * 改成「先单字符、后多字符 + 模式用折叠后字母」就由构造保证闭包:
120
+ * `cl` / `c1` / `ci` 都先变成 `ci`,再统一折成 `d`。
121
+ *
122
+ * 🔴 四条规则的**首字符两两不同**(r / n / v / c),所以同一位置最多命中一条 ——
123
+ * 不存在「贪心顺序依赖」这种可被利用的非合流。输出字符(m / w / d)也都不是
124
+ * 任何规则的首字符或次字符,所以**单趟左到右扫描就是不动点**。有测试钉住这两条。
125
+ *
126
+ * ⚠️ 没做的:`li`→`h`(会把 `liub` 判成 `hub`,而那可能是正常名字)、
127
+ * `ci`→`a`(与这里的 `ci`→`d` 直接冲突,二选一)、`vv` 之外的 `w` 变体。
128
+ */
129
+ const MULTI = [['rn', 'm'], ['nn', 'm'], ['vv', 'w'], ['ci', 'd']];
130
+
131
+ /**
132
+ * 把名字折成「同形规范形」。结果里可能仍留有 `6` / `9` —— 它们是**歧义占位符**,
133
+ * 由 `confusableEquals` 处理,见 `DIGIT_AMBIG`。
134
+ */
135
+ export function foldConfusables(name) {
136
+ let s = '';
137
+ for (const ch of normalizeName(name)) s += LETTER_CLASS[ch] ?? DIGIT_SOLE[ch] ?? ch;
138
+ let out = '';
139
+ for (let i = 0; i < s.length;) {
140
+ const rule = MULTI.find(([pat]) => s.startsWith(pat, i));
141
+ if (rule) { out += rule[1]; i += rule[0].length; } else { out += s[i]; i += 1; }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /** 一个折叠后字符能代表的字母集合。 */
147
+ const charAlts = (c) => DIGIT_AMBIG[c] ?? [c];
148
+
149
+ /** 两个**已折叠**的名字是否同形。⚠️ 故意不传递,见 `DIGIT_AMBIG`。 */
150
+ export function confusableEquals(a, b) {
151
+ if (a.length !== b.length) return false;
152
+ for (let i = 0; i < a.length; i += 1) {
153
+ if (!charAlts(a[i]).some((x) => charAlts(b[i]).includes(x))) return false;
154
+ }
155
+ return true;
156
+ }
157
+
158
+ /**
159
+ * 🔴 **保留清单管的是 `namespace`,不是制品 `name`。**
160
+ * 05-lifecycle.md §1.1:「以下 **namespace** 只能由 hub 维护者使用:
161
+ * `geoly`、`hub`、`official`、`skills`、`registry`、`anthropic`、`claude`、
162
+ * `cursor`、`codex`、`openai`、`github`、`npm`、`system`、`admin`、`security`、
163
+ * `test`、`example`、`local`」。
164
+ *
165
+ * ⚠️ 第一版我按 `name` 查(Codex 2026-08-31 指出)—— 那道门等于没设:
166
+ * 一个 `namespace: geoly` 的投稿会**直接放行**,而那正是它要拦的东西。
167
+ *
168
+ * 🔴 而且它**不是**一刀切禁用:维护者可以用。所以判据是
169
+ * 「保留 namespace **且** 投稿者不是维护者」。「是不是维护者」是 PR 侧的事实,
170
+ * 由调用方传进来 —— 同 `promote/build-inputs.mjs` 的分工。
171
+ */
172
+ export function readReserved(path) {
173
+ if (!existsSync(path)) return { schema: RESERVED_SCHEMA, namespaces: [] };
174
+ // 🔴 用 `parseStrict` 不用 `JSON.parse`:11-wire-contract.md §1 把
175
+ // `registry/reserved.json` 列进了适用对象,而 §2 的解析规则要求**拒绝重复 key**。
176
+ // `JSON.parse` 对重复 key 静默取最后一个 —— `{"namespaces":[…],"namespaces":[]}`
177
+ // 就能把整张保留清单清空,而文件看起来完全正常(Codex 2026-08-31)。
178
+ const doc = parseStrict(readFileSync(path, 'utf8'));
179
+ if (doc.schema !== RESERVED_SCHEMA) {
180
+ bad('E_RESERVED_SCHEMA', `reserved.json 的 schema 应为 ${RESERVED_SCHEMA},得到 ${JSON.stringify(doc.schema)}`);
181
+ }
182
+ if (!Array.isArray(doc.namespaces) || doc.namespaces.some((n) => typeof n !== 'string')) {
183
+ bad('E_RESERVED_SCHEMA', 'reserved.json.namespaces 必须是字符串数组');
184
+ }
185
+ // 🔴 **同形折叠必须在保留清单内部保持单射 —— 否则 fail-closed**(Codex 2026-09-03)。
186
+ // 这是给「折叠表将来被放宽」准备的告警器:哪天有人往 `LETTER_CLASS` / `MULTI` 里
187
+ // 加一条,把两个**本来互不相同的真实单词**折到一起了,这里会当场红 ——
188
+ // 而不是等到某个正常投稿被莫名其妙判成仿冒才发现。
189
+ const folds = doc.namespaces.map((n) => ({ n, f: foldConfusables(n) }));
190
+ for (let i = 0; i < folds.length; i += 1) {
191
+ for (let j = i + 1; j < folds.length; j += 1) {
192
+ if (confusableEquals(folds[i].f, folds[j].f)) {
193
+ bad('E_RESERVED_AMBIGUOUS',
194
+ `保留清单内部自撞:${folds[i].n} 与 ${folds[j].n} 同形折叠后无法区分`
195
+ + `(${JSON.stringify(folds[i].f)} / ${JSON.stringify(folds[j].f)})。\n`
196
+ + ' 三种可能:① 其中一条是笔误;② 其中一条是**冗余别名**'
197
+ + '(如 `openai` 之外再列 `open-ai` —— 归一化后本就相同,直接删掉那条即可);'
198
+ + '③ structural-gates.mjs 的折叠表放得太宽 —— 宽到能把两个正常单词合并,'
199
+ + '就一定会误伤正常投稿。');
200
+ }
201
+ }
202
+ }
203
+ return doc;
204
+ }
205
+
206
+ /**
207
+ * @param {object} a
208
+ * @param {string} a.namespace
209
+ * @param {object} a.reserved
210
+ * @param {boolean} a.byMaintainer 投稿者是不是 hub 维护者(PR 侧的事实)
211
+ */
212
+ export function assertReservedNamespaceAllowed({ namespace, reserved, byMaintainer = false }) {
213
+ const target = normalizeName(namespace);
214
+ const hit = reserved.namespaces.filter((r) => normalizeName(r) === target);
215
+ // 🔴 归一化相等之外,再查一层**同形折叠**(`ge0ly` / `geo1y` / `anthrop1c`)——
216
+ // 见上面那段「攻击面是可枚举的」。折叠只用在**这一层**(与 18 条固定清单
217
+ // 比全串相等),**不用在 `assertNoNormalizedCollision`**:那边比的是
218
+ // 同 namespace 内的**开放集合**,折叠会让两个正常名字互撞,
219
+ // 且撞的对数随注册量二次增长,而它挡的不是仿冒、是「人和 agent 分不开」。
220
+ const folded = foldConfusables(namespace);
221
+ const lookalike = reserved.namespaces.filter(
222
+ (r) => !hit.includes(r) && confusableEquals(foldConfusables(r), folded),
223
+ );
224
+ if (hit.length === 0 && lookalike.length === 0) return true;
225
+ if (byMaintainer) return true; // §1.1:维护者可以用
226
+ if (hit.length) {
227
+ bad('E_RESERVED',
228
+ `namespace ${namespace} 命中保留清单(${hit.join(', ')},归一化后相同)——`
229
+ + '05-lifecycle.md §1.1:这些 namespace 只能由 hub 维护者使用。');
230
+ }
231
+ bad('E_RESERVED',
232
+ `namespace ${namespace} 与保留 namespace ${lookalike.join(', ')} **同形**`
233
+ + `(数字/字母同形折叠后都是 ${JSON.stringify(folded)})——`
234
+ + '05-lifecycle.md §1.1:这些 namespace 只能由 hub 维护者使用,'
235
+ + '而「数字替字母」正是保留它们要防的那种仿冒。\n'
236
+ + ' ⚠️ 这道门只比**全串**:名字里带数字本身没问题(`web3`、`s3`、`i18n` 都放行),'
237
+ + '包含保留词也没问题(`geoly-ai` 放行)—— 只有整个名字折叠后与保留名相同才拒。'
238
+ + '要是你确实不是在仿冒,换一个不会折叠到保留名上的写法即可。');
239
+ }
240
+
241
+ // ── ③ 版本未占用 ───────────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * §6:「semver 合法、**未占用**、无 `+build`」。§3 又特别写明「版本号在该
245
+ * `<ns>/<name>` 下**从未被使用过(含已 yank)**」。
246
+ *
247
+ * 🔴 **yank 不释放版本号。** 制品不可变(01-artifacts.md §1):yank 只是标记
248
+ * 「别再用了」,文件仍在。放行重用等于让同一个 ArtifactId 指向两份不同的字节 ——
249
+ * 而 ArtifactId 是整个信任链的主键。
250
+ *
251
+ * @param {string} a.id 本次投稿的 ArtifactId
252
+ * @param {string[]} a.existingIds 已有的全部 ArtifactId(**含已 yank**)
253
+ */
254
+ export function assertVersionUnused({ id, existingIds }) {
255
+ if (existingIds.includes(id)) {
256
+ bad('E_VERSION_TAKEN',
257
+ `${id} 已经存在 —— 版本号不可重用,**yank 也不释放它**(06-submission.md §3、01-artifacts.md §1)。\n`
258
+ + ' 要改内容就发新版本。');
259
+ }
260
+ return true;
261
+ }
262
+
263
+ // ── ④ capability 一致性 ────────────────────────────────────────────────────
264
+
265
+ /** 看起来像脚本的扩展名。**不是白名单,是启发式** —— 见下面的诚实边界。 */
266
+ export const SCRIPT_EXT = /\.(sh|bash|zsh|py|rb|pl|js|mjs|cjs|ts|ps1)$/i;
267
+
268
+ /**
269
+ * 载荷里**看得见的**可执行迹象:可执行位、脚本扩展名、shebang。
270
+ * 🔴 单独导出,因为 `tier-gate.mjs` 也要用它 —— 那里判的是
271
+ * 「审查等级能不能只信投稿者自己填的 capabilities」。
272
+ * 两处用同一条判据,不会分叉。
273
+ */
274
+ export function executableEvidence(entries) {
275
+ const out = [];
276
+ for (const e of entries) {
277
+ if ((e.mode & 0o111) !== 0) { out.push(`${e.path}:mode 0${e.mode.toString(8)} 带可执行位`); continue; }
278
+ if (SCRIPT_EXT.test(e.path)) { out.push(`${e.path}:看起来是脚本(按扩展名)`); continue; }
279
+ if (e.data.toString('utf8').startsWith('#!')) out.push(`${e.path}:以 shebang 开头`);
280
+ }
281
+ return out;
282
+ }
283
+ /** `http://` / `https://`;不含 `https://` 出现在纯文本说明里的情况 —— 分不开,见下。 */
284
+ const RE_EXTERNAL_URL = /\bhttps?:\/\/[^\s)"'<>]+/i;
285
+
286
+ /**
287
+ * §6:「声明 `none` 却含 `0755` / 外部 URL / 脚本 → 拒绝」。
288
+ *
289
+ * ⚠️ **这条门天生是启发式的,不要把它当成安全保证。**
290
+ * · `0755` 是**确定**信号:可执行位就是可执行位;
291
+ * · 「脚本」按扩展名 + shebang 判 —— 一个没扩展名、没 shebang 的脚本躲得过去;
292
+ * · 「外部 URL」最粗:一条指向文档的链接与一条「去这里取指令」在字节上没有区别。
293
+ * §8 的人工门第 6 条(间接指令)才是真正处理它的地方,本门只能拦住最直白的。
294
+ * 🔴 **外部 URL 这一项只告警、不拒绝**,理由见下面 `warnings` 那段 ——
295
+ * 它是本模块唯一一处**偏离 §6 字面表述**的地方,已记进交付汇报。
296
+ * 漏报的代价由 §8 的人来兜。
297
+ *
298
+ * @param {string[]} a.capabilities
299
+ * @param {Array<{path:string, mode:number, data:Buffer}>} a.entries packer.collectTree 的产物
300
+ */
301
+ export function assertCapabilityConsistency({ capabilities, entries }) {
302
+ // 🔴 **先去重再比。** 早先写的是 `length === 1 && [0] === 'none'`,于是
303
+ // `['none','none']` 会让 `declaredNone` 为假、整道门被跳过 —— 一个纯粹靠
304
+ // 写两遍就能绕过的门(Codex 2026-08-31)。schema 那边没有 uniqueItems。
305
+ const uniqCaps = Array.isArray(capabilities) ? [...new Set(capabilities)] : null;
306
+ const declaredNone = uniqCaps !== null && uniqCaps.length === 1 && uniqCaps[0] === 'none';
307
+ if (!declaredNone) return { checked: false, reason: '未声明 none,本门不适用' };
308
+
309
+ const problems = executableEvidence(entries);
310
+ const warnings = [];
311
+ for (const e of entries) {
312
+ const m = RE_EXTERNAL_URL.exec(e.data.toString('utf8'));
313
+ if (m) warnings.push(`${e.path}:含外部 URL ${m[0].slice(0, 60)}`);
314
+ }
315
+ if (warnings.length) {
316
+ // 🔴 **外部 URL 只告警,不拒绝** —— 这一条**偏离** §6 的字面表述,理由写在这里。
317
+ // §6 把「外部 URL」与「0755 / 脚本」并列成拒绝条件,但两者性质不同:
318
+ // `SKILL.md` 里放一条参考文档的链接**极其常见**,而「引用外部资料」
319
+ // 不等于「运行时访问外部网络」。按字面实现的话,几乎每个声明 `none` 的
320
+ // 正常 skill 都会被拒 —— 而逼人去掉链接、或改声明 `network`(那会错误地
321
+ // 抬高审查 Tier 与安装确认要求),两条出路都是错的(Codex 2026-08-31)。
322
+ // ⚠️ 一道几乎总在报红的门,两周之内就会被关掉 —— 那时它一条都拦不住了。
323
+ // 真正处理它的是 §8 人工门第 6 条(间接指令:「引用外部文档,按那里说的做」)。
324
+ // 这条偏离已记进交付汇报,等规格侧确认。
325
+ for (const w of warnings) process.stderr.write(`⚠️ capability 一致性(仅告警):${w}\n`);
326
+ }
327
+ if (problems.length) {
328
+ bad('E_CAPABILITY_INCONSISTENT',
329
+ `声明了 capabilities: ["none"],但载荷里有:\n`
330
+ + problems.map((p) => ` · ${p}`).join('\n')
331
+ + '\n §6:声明不实视为恶意投稿(§7 末段)。要么去掉,要么如实声明对应的 capability。');
332
+ }
333
+ return { checked: true, problems: [], warnings };
334
+ }
335
+
336
+ export { GateError };
package/src/artifact.mjs CHANGED
@@ -181,7 +181,25 @@ export function verifyAndExtract({ bytes, record, parent = tmpdir() }) {
181
181
 
182
182
  const SKILL_MANIFEST_KEYS = {
183
183
  required: ['schema', 'kind', 'namespace', 'name', 'version', 'description', 'license',
184
- 'clients', 'capabilities', 'replaces', 'conflicts', 'provenance'],
184
+ 'clients', 'capabilities', 'replaces', 'conflicts'],
185
+ // 🔴 **`provenance` 从必填改成可选**(2026-09-05 用户拍板)。
186
+ //
187
+ // 原因是它构成一个**时间循环**:`submitted_by_pr` 必须等于真实 PR 号,
188
+ // 而 PR 号只有开了 PR 才知道 —— 于是投稿者被要求在开 PR **之前**
189
+ // 写进一个开 PR 之后才存在的值。实际做法是「先开 PR → 回填 → 强推同一分支」,
190
+ // 而漏回填的投稿会**先进 main、再在 promote 卡死**。
191
+ //
192
+ // ⚠️ 更能说明问题的是它与 `PROMOTION.json` **自相矛盾**:那边明确
193
+ // **拒绝**投稿者声明 `author_github_id` / `submitted_by_pr`
194
+ // (理由:投稿者只能声明只有他知道的事),而这边却要求他自己写同样两个字段。
195
+ // 同一个仓库里两套相反的规则 —— 我自己在两天里踩了它两次。
196
+ //
197
+ // 现在:缺省即由 promote 填(与 pack 一致);**若投稿者写了,仍然逐字核对**
198
+ // (`assertProvenanceMatchesPr`)—— 不静默改写,写错了要有人看见。
199
+ //
200
+ // 🔴 改成 optional 而不是删掉:已发布的 5 张快照里的制品**都带着这个字段**,
201
+ // 删掉会让它们验不过。可选是唯一向后兼容的形状。
202
+ optional: ['provenance'],
185
203
  };
186
204
 
187
205
  const PACK_MANIFEST_KEYS = {
package/src/cli.mjs CHANGED
@@ -29,6 +29,12 @@ const HELP = `skills-hub —— geoly skill 分发(M1 + M2 的命令面)
29
29
  把 pack 与全部成员物化成一棵目录树(不走安装账本)
30
30
  update [<spec>…] | --all 重解析账本里的 root:展示 diff、确认后在一个事务里应用
31
31
  remove <name> 减引用;🔴 **引用归零才删目录**(why <name> 看谁在要它)
32
+ publish [path] [--dry-run] 投稿:本地过一遍服务端那批门 → 建 fork → 开 PR
33
+ 🔴 用你**已有**的 GitHub token(GEOLY_GITHUB_TOKEN /
34
+ GH_TOKEN / GITHUB_TOKEN / \`gh auth token\`),CLI
35
+ **不持有、不存储**它,也没有 login / logout。
36
+ 动手前会把 token 的权限面摆给你看并要求确认 ——
37
+ \`--yes\` 只表示**确认了那个风险**,不跳过任何校验。
32
38
 
33
39
  stats [--json] [--export <file>] 本地埋点报表
34
40
  telemetry <status|flush> 埋点/上报开关与队列
@@ -46,9 +52,16 @@ const HELP = `skills-hub —— geoly skill 分发(M1 + M2 的命令面)
46
52
  --no-bundled / --pre / --json / --yes
47
53
  --yes-i-really-want-everything 仅 --all 在非交互下使用(--yes 不够)
48
54
  --keep-generations <N> attic 保留代数,默认 3
55
+ --scan-max-depth <N> 嵌套 target 预检的扫描深度预算,默认 64(硬顶 1024)
56
+ --scan-max-dirs <N> 同上,目录数预算,默认 100000(硬顶 5000000)
57
+ 报 target.nested-scan-incomplete 时,错误里会点名撞的是**哪一个**上限;
58
+ 只有那一条说「深度上限到顶」/「目录数上限用尽」时抬这里才有用。
59
+ 说「读不进去」的那种是权限问题,抬预算没有用。
49
60
 
50
- 🔴 没有 --no-verify、--insecure、--force、--force-unlock、--assume-idle。
51
- 验签与摘要校验不可关闭;替换必须点名;陈旧/yank/全量各有独立开关。
61
+ 🔴 没有 --no-verify、--insecure、--force、--force-unlock、--assume-idle、
62
+ --skip-nested-scan。验签与摘要校验不可关闭;替换必须点名;
63
+ 陈旧/yank/全量各有独立开关;扫描预算只能**抬高**,不能关掉 ——
64
+ 关掉它就是把「无法证明没有嵌套 target」改写成「假设没有」(04-install.md §3.5)。
52
65
 
53
66
  埋点(docs/telemetry/00-spec.md):
54
67
  🔴 上报**默认开**(有内置默认端点)。**install 成功收尾后**会静默发一次
@@ -76,6 +89,7 @@ const COMMANDS = {
76
89
  remove: () => import('./commands/remove.mjs').then((m) => m.cmdRemove),
77
90
  update: () => import('./commands/update.mjs').then((m) => m.cmdUpdate),
78
91
  vendor: () => import('./commands/vendor.mjs').then((m) => m.cmdVendor),
92
+ publish: () => import('./commands/publish.mjs').then((m) => m.cmdPublish),
79
93
  };
80
94
 
81
95
  /**
@@ -16,6 +16,7 @@ import { homedir, platform } from 'node:os';
16
16
  import { existsSync, readFileSync } from 'node:fs';
17
17
  import { isAbsolute, resolve as resolvePath } from 'node:path';
18
18
  import { UsageError, UnsupportedError } from '../exit-codes.mjs';
19
+ import { SCAN_CEILINGS } from '../target.mjs';
19
20
 
20
21
  /** §2 那张表。`arg: true` = 后面跟一个值。 */
21
22
  const GLOBAL_FLAGS = Object.freeze({
@@ -31,6 +32,8 @@ const GLOBAL_FLAGS = Object.freeze({
31
32
  '--no-bundled': { arg: false },
32
33
  '--freeze-attic': { arg: true },
33
34
  '--keep-generations': { arg: true },
35
+ '--scan-max-depth': { arg: true },
36
+ '--scan-max-dirs': { arg: true },
34
37
  '--json': { arg: false },
35
38
  '--yes': { arg: false },
36
39
  '--yes-i-really-want-everything': { arg: false },
@@ -51,6 +54,12 @@ const REMOVED_FLAGS = Object.freeze({
51
54
  '--clear-lock': '同 --force-unlock:协议里没有任何 unlink,也就没有可清的东西(§5.1)。',
52
55
  '--assume-idle':
53
56
  '本工具**不检测也不阻断**正在运行的 agent(D5,04-install.md §9),所以没有可假设的东西。',
57
+ '--skip-nested-scan':
58
+ '嵌套 target 扫描**不能关闭**(04-install.md §3.5)。关掉它是把「无法证明没有嵌套」'
59
+ + '改写成「假设没有嵌套」,而外层替换会连内层的 .geoly/ 一起搬走、两把锁互不相识。\n'
60
+ + '扫描超预算时报错里会点名撞的是哪个上限:深度/目录数用 `--scan-max-depth <N>` / '
61
+ + '`--scan-max-dirs <N>` 抬高(仍然是真扫描、仍然 fail-closed);'
62
+ + '说「读不进去」的那种是权限问题,抬预算没有用。',
54
63
  '--allow-pending':
55
64
  'Q12 是阻塞门,不是建议。要开某一格就补那一格的实测证据(docs/m1/01-residual-risks.md R-4)。',
56
65
  });
@@ -152,6 +161,18 @@ function applyGlobal(g, name, val) {
152
161
  case '--no-bundled': g.noBundled = true; break;
153
162
  case '--freeze-attic': g.freezeAttic = val; break;
154
163
  case '--keep-generations': g.keepGenerations = uintArg(name, val); break;
164
+ // 🔴 预检的**预算旋钮**,不是关闭开关。抬高它仍然是一次真扫描、仍然 fail-closed;
165
+ // 抬到硬顶还不够就照旧拒绝。故意**不提供** `--skip-nested-scan`:
166
+ // 那会把「无法证明没有嵌套」改成「假设没有嵌套」,正是 §3.5 要防的事
167
+ // (同 REMOVED_FLAGS 里 --force / --no-verify 的理由)。
168
+ // 上界由 target.mjs 的 SCAN_CEILINGS 兜底,超了报错而不是静默截断。
169
+ // 🔴 硬顶要在**解析期**就拒,不能等到预检里才抛:那时 install 已经
170
+ // 建过 target/.geoly、取过锁,而 target.mjs 抛的是普通 Error ——
171
+ // 会被 classify 判成 unclassified(退出码 2「完整性失败」),
172
+ // 把一个「参数超上限」说成「制品坏了」。解析期拒 = 零磁盘副作用 + 退出码 1。
173
+ // (target.mjs 里那道保留为纵深防御:库调用方不经过本文件。)
174
+ case '--scan-max-depth': g.scanMaxDepth = scanArg(name, val, 'maxDepth'); break;
175
+ case '--scan-max-dirs': g.scanMaxDirs = scanArg(name, val, 'maxDirs'); break;
155
176
  case '--json': g.json = true; break;
156
177
  case '--yes': g.yes = true; break;
157
178
  case '--yes-i-really-want-everything': g.yesEverything = true; break;
@@ -160,6 +181,22 @@ function applyGlobal(g, name, val) {
160
181
  }
161
182
  }
162
183
 
184
+ /**
185
+ * 扫描预算旋钮:非负整数 **且** 不超过 `target.mjs` 的硬顶。
186
+ * 🔴 硬顶的**唯一出处**是 `SCAN_CEILINGS` —— 这里不复制一份数字,
187
+ * 复制的那份迟早会跟本体漂。
188
+ */
189
+ function scanArg(name, val, key) {
190
+ const n = uintArg(name, val);
191
+ if (n > SCAN_CEILINGS[key]) {
192
+ throw new UsageError(
193
+ `${name} 超过硬顶 ${SCAN_CEILINGS[key]}(收到 ${n})。`
194
+ + '预算旋钮不是关闭开关:无上限的遍历等于没有资源闸(04-install.md §3.5.1)。',
195
+ );
196
+ }
197
+ return n;
198
+ }
199
+
163
200
  /** 11-wire-contract.md §2:数字只允许非负整数,不允许前导零、浮点、指数、`-0`。 */
164
201
  export function uintArg(name, val) {
165
202
  if (!/^(0|[1-9]\d*)$/.test(val)) {
@@ -263,6 +300,14 @@ export function makeContext(globals, deps = {}) {
263
300
  * 照样可读,但那不是「用户正坐在终端前逐条看名单」。
264
301
  */
265
302
  stdin: deps.stdin ?? process.stdin,
303
+ /**
304
+ * 预检的扫描预算,直接喂给 `precheckTarget({ scan })`。
305
+ * 🔴 没给旋钮时这两项是 `undefined` —— `normalizeScan` 把 `undefined` 读成
306
+ * 「用默认值」。**不要**在这里填上默认值:填了以后默认值就有了两个出处
307
+ * (这里一份、target.mjs 一份),改一处漏一处的那种漂移正好发生在
308
+ * 「上限是多少」这个用户会照着报错去调的数上。
309
+ */
310
+ scan: Object.freeze({ maxDepth: globals.scanMaxDepth, maxDirs: globals.scanMaxDirs }),
266
311
  });
267
312
  return ctx;
268
313
  }
@@ -684,7 +684,13 @@ export async function cmdInstall(ctx, argv, out) {
684
684
  const t = byPath.get(o.path);
685
685
  const started = Date.now();
686
686
  try {
687
- const r = installOneTarget(ctx, t, perClient.get(t.client), { snap, floor, pinned, out, verifier });
687
+ const r = installOneTarget(ctx, t, perClient.get(t.client), {
688
+ snap, floor, pinned, out, verifier,
689
+ // 🔴 §3.5 识别范围 ① 是「本次命令的**全部** target」,不是「自己」。
690
+ // 只传自己会漏掉「两个 target 互相嵌套、但内层还没有 .geoly 状态」——
691
+ // 那正是首次安装时的形状(内层的状态目录还没建出来)。
692
+ targetSet: selected.map((s) => s.target),
693
+ });
688
694
  results.push({
689
695
  ...r, client: t.client, scope: t.scope, target: t.target, ok: true, ms: Date.now() - started,
690
696
  });
@@ -754,7 +760,7 @@ export async function cmdInstall(ctx, argv, out) {
754
760
  }
755
761
 
756
762
  /** 单个 target 的第 2–10 步。 */
757
- function installOneTarget(ctx, t, { units, rootSpecs, packInfos }, { snap, floor, out, verifier }) {
763
+ function installOneTarget(ctx, t, { units, rootSpecs, packInfos }, { snap, floor, out, verifier, targetSet }) {
758
764
  const target = t.target;
759
765
  const P0 = layout(target);
760
766
  const onLedgerChanged = makeLockfileHook(ctx, { snap, verifier });
@@ -764,7 +770,11 @@ function installOneTarget(ctx, t, { units, rootSpecs, packInfos }, { snap, floor
764
770
  if (rec.outcome !== 'nothing') out.note(`${t.client}:入口分流 —— ${rec.outcome}`);
765
771
 
766
772
  // ── 第 3 步:预检(🔴 assertPrecheckOk 必须调)─────────────────────────
767
- const pre = precheckTarget(target, { base: t.base, targetSet: [target] });
773
+ const pre = precheckTarget(target, {
774
+ base: t.base,
775
+ targetSet: targetSet ?? [target],
776
+ scan: ctx.scan,
777
+ });
768
778
  assertPrecheckOk(pre);
769
779
 
770
780
  // ── 第 4 步:取字节 → 验签/验资产/解包/manifest 绑定 ────────────────────
@@ -116,8 +116,14 @@ export class Output {
116
116
  message,
117
117
  unclassified: cls.unclassified,
118
118
  // 预检聚合错带全部违规项 —— 🔴 JSON 里**始终保留全部**,不只报优先级最高那条
119
+ // 🔴 `detail` 必须一起出:它是**给机器读的那一半**(嵌套 target 的 relation/via、
120
+ // 扫描没跑完时撞的是哪个上限、实际值、样例路径)。只留 message 等于逼
121
+ // 调用方去正则解析中文文案 —— 那正是 `detail` 存在的理由。
122
+ // ⚠️ 白名单式挑字段是对的,别改成整个 `...v` 透传。
119
123
  violations: Array.isArray(err?.violations)
120
- ? err.violations.map((v) => ({ code: v.code, message: v.message, path: v.path }))
124
+ ? err.violations.map((v) => pruneUndefined({
125
+ code: v.code, message: v.message, path: v.path, detail: v.detail,
126
+ }))
121
127
  : undefined,
122
128
  candidates: Array.isArray(err?.candidates) ? err.candidates : undefined,
123
129
  }),