dsh-skill-tag 0.1.17 → 0.1.18

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.
@@ -181,6 +181,20 @@ tagging-skills 那 17 个长期漏网的原因(详见 `deny-shadow-覆盖率.m
181
181
  再发 —— 一个不合法候选会让**整个** `provider.list()` 抛错,宿主随即丢弃本插件的
182
182
  全部贡献(日志 `skill provider "skill-tag" skipped`),比漏一个更糟。
183
183
 
184
+ **两段都要校验(2026-11-12,issue #6)**。上面这条当时只堵了「`denied` 里看不见
185
+ 的名字」那一段,**看得见的**那一段(照抄上游 summary)一直没校验,于是扫盘带进来的
186
+ 脏名字能原样发出去。触发链:`src/skill-scan.js` 把技能根下裸放的 `README-官方技能说明.md`
187
+ 当成技能(名字取文件名去 `.md`)→ 进 universe → `denied = universe − visible` 里也有它 →
188
+ 第一段照抄发射 → 宿主 `validateCandidate` 抛错 → **所有对话**每轮都失败,报
189
+ `returned invalid skill name`。
190
+
191
+ 现在两层纵深防御:
192
+
193
+ - `src/skill-scan.js`:根级裸放 `.md` **必须有 frontmatter** 才算技能,名字非 kebab-case
194
+ 一律跳过 —— 与宿主 `discoverRoot` / `parseSkillFile` 同口径(宿主对同样的文件打的是
195
+ `skill file ... ignored: invalid skill name`)。
196
+ - `lib/index.js`:第一段与第二段都过 `isSkillName()`,兜住任何别的来源漏进来的脏名字。
197
+
184
198
  ## 5. 约束(踩过的坑)
185
199
 
186
200
  - **cordis 服务必须声明式注入**:`export const inject` 里没有的服务,读 `ctx.xxx`
package/lib/index.js CHANGED
@@ -101,8 +101,15 @@ export function createFilterProvider(sessionId, upstream, denied, ensureFresh, l
101
101
  // 还是上游那份,影子只换掉 invocation 与 provider,右列能看出"它被屏蔽了"。
102
102
  const shadows = [];
103
103
  const emitted = new Set();
104
+ const invalid = new Set();
104
105
  for (const summary of summaries) {
105
106
  if (!summary || !denied.has(summary.name)) continue;
107
+ // 看得见 ≠ 合法(2026-11-12,issue #6):扫盘会把技能根下裸放的
108
+ // `README-官方技能说明.md` 这类文件当成技能,名字取文件名去 `.md`。
109
+ // 旧实现只在第二段校验,这一段整条照抄 → 非法名字原样发出去 →
110
+ // 宿主 validateCandidate 抛错 → 整个 provider 作废 → **所有对话**
111
+ // 报 "returned invalid skill name" 且每轮都失败。两段都必须校验。
112
+ if (!isSkillName(summary.name)) { invalid.add(summary.name); continue; }
106
113
  shadows.push({
107
114
  ...summary,
108
115
  provider: PROVIDER_NAME,
@@ -123,10 +130,9 @@ export function createFilterProvider(sessionId, upstream, denied, ensureFresh, l
123
130
  // 修法:屏蔽是**按名字**生效的,不需要知道那名字长什么样 —— 看不见的
124
131
  // 名字直接造一条最小合法候选,就能在近层整条覆盖上游那份。
125
132
  let backfilled = 0;
126
- const invalid = [];
127
133
  for (const name of denied) {
128
134
  if (emitted.has(name)) continue;
129
- if (!isSkillName(name)) { invalid.push(name); continue; }
135
+ if (!isSkillName(name)) { invalid.add(name); continue; }
130
136
  shadows.push({
131
137
  name,
132
138
  description: `[blocked by skill-tag] ${name}`,
@@ -148,8 +154,8 @@ export function createFilterProvider(sessionId, upstream, denied, ensureFresh, l
148
154
  + ` shadowed=${emitted.size} backfilled=${backfilled}`
149
155
  + `${upstream.lastDegraded === true ? " hostSnapshot=skipped(nested)" : ""}`);
150
156
  }
151
- if (invalid.length > 0) {
152
- logWarn(`[skill-tag] denied names skipped (not kebab-case): ${invalid.slice(0, 8).join(", ")}`);
157
+ if (invalid.size > 0) {
158
+ logWarn(`[skill-tag] names skipped (not kebab-case): ${[...invalid].slice(0, 8).join(", ")}`);
153
159
  }
154
160
  } catch { /* 观测不得影响主路径 */ }
155
161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-tag",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "给 DSH 的技能(skill)打标签,用一条布尔表达式在「工作区 / 对话」两级选出要下发给模型的技能子集",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/src/skill-scan.js CHANGED
@@ -70,6 +70,17 @@ function collectRoot(dir, depth, root, out) {
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * 合法技能名:kebab-case,与宿主 @deepseek-ai/dsh-skill 的 SKILL_NAME 同一正则。
75
+ *
76
+ * 为什么扫描器也要过这一关(2026-11-12,issue #6):扫描结果会进 universe,
77
+ * 于是 denied = universe − visible 里也会出现它;只要这个名字非法,宿主
78
+ * validateCandidate 就会抛错、整个 provider 作废、**所有对话**都跑不下去。
79
+ * 宿主自己的文件系统 provider 早就这么做了(`skill file ... ignored: invalid
80
+ * skill name`),自扫描必须同口径,否则插件会把宿主本来就不认的名字当成技能。
81
+ */
82
+ const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
83
+
73
84
  function pushSkill(out, mdPath, root, disabled, fallbackName) {
74
85
  let fm = null;
75
86
  try {
@@ -77,8 +88,15 @@ function pushSkill(out, mdPath, root, disabled, fallbackName) {
77
88
  } catch {
78
89
  return; // unreadable file — skip
79
90
  }
91
+ // 裸放的根级 .md(flat skill)与宿主同口径:**必须有 frontmatter**。
92
+ // 没有 frontmatter 的 .md 只是一份普通文档(README、笔记…),不是技能;
93
+ // 旧实现会用文件名凭空造一个技能名,正是 issue #6 的来源。
94
+ const isFlat = fallbackName !== undefined;
95
+ if (isFlat && fm === null) return;
80
96
  const name = fm?.name || fallbackName || path.basename(path.dirname(mdPath));
81
97
  if (!name) return;
98
+ // 非法名字宁可丢掉:它既进不了宿主注册表,还会炸掉整批候选。
99
+ if (!SKILL_NAME_RE.test(name)) return;
82
100
  out.push({
83
101
  name,
84
102
  description: fm?.description ?? "",