opencode-skills-doctor 0.3.8 → 0.3.10

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 CHANGED
@@ -4,13 +4,13 @@
4
4
 
5
5
  Local scanner, linter, fixer, updater and **OpenCode plugin** for skills — zero dependencies, Node ≥ 20.
6
6
 
7
- **English** | [简体中文](README.zh-CN.md)
7
+ **English** | [简体中文](docs/README.zh-CN.md)
8
8
 
9
9
  ## Why
10
10
 
11
11
  On every model step, OpenCode advertises every skill that has a `description` — the model sees `ID + name + description` for each of them. Skills are not free: they are a permanent context tax on every request.
12
12
 
13
- **What this costs.** 29 skills with long descriptions ≈ **3,000+ tokens every step**. Over a 100-step session that is ~300k tokens of pure overhead — spent before the model reads a single line of your code.
13
+ **What this costs.** 29 skills with long descriptions ≈ **3,000+ tokens every step**. Over a 100-step session that is ~300k tokens of pure overhead — spent before the model reads a single line of your code. And that is only 29 skills: many users have far more they downloaded without noticing, some already out of date. This tool helps you understand your skill repository much better.
14
14
 
15
15
  **What can go wrong.** As skills pile up:
16
16
  - duplicate IDs silently shadow each other — you may be editing a file that never loads;
@@ -244,6 +244,16 @@ On Windows, if the stdio spawn cannot resolve `npx`, use `command: cmd` and `arg
244
244
 
245
245
  `check --json`, `scan --json` and `update --json` all print structured JSON, ready for CI or a web report. Fields include `summary`, `findings[]` and `skills[]` (with `managed: { version, url, root }`).
246
246
 
247
+ ## Security
248
+
249
+ This tool reads and writes skill files. Before using it against a real setup:
250
+
251
+ - `update` downloads catalog content **without signature verification** — treat catalogs as code;
252
+ - `fix` / `--prune` move or delete files — preview with `--dry-run` first;
253
+ - it is **not a sandbox** and does not constrain what an agent can do.
254
+
255
+ See [SECURITY.md](SECURITY.md) for the full threat model, hardening checklist and private vulnerability reporting.
256
+
247
257
  ## Caveats and known limitations
248
258
 
249
259
  **Install / plugin**
package/SECURITY.md ADDED
@@ -0,0 +1,68 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ | Version | Supported |
6
+ |---|---|
7
+ | 0.3.x | ✅ |
8
+ | < 0.3 | ❌ |
9
+
10
+ Pre-1.0: only the latest minor receives fixes.
11
+
12
+ ## Reporting a vulnerability
13
+
14
+ Please **do not open a public issue**. Use GitHub private vulnerability reporting:
15
+
16
+ <https://github.com/wangyuchen5110/opencodeplugginprogram/security/advisories/new>
17
+
18
+ Include the version, a reproduction, the impact, and a suggested fix if you have one. Best-effort response within 7 days.
19
+
20
+ ## What this tool does with your system
21
+
22
+ - Runs locally with the privileges of the invoking user. **No telemetry.**
23
+ - Network access only for `update` (HTTP catalogs) and for `npx` when you install/run the package that way.
24
+ - Reads skill files and frontmatter, `opencode.json(c)` / `opencode.jsonc`, and `.skills-doctor.lock.json`. It does **not** read model credentials, API keys, or conversation content.
25
+ - Writes only on explicit commands:
26
+ - `fix` — moves shadowed definitions into `.skills-doctor-archive/`, optionally edits `metadata.opencode/autoinvoke` (`--hide`);
27
+ - `update` — writes downloaded files into a skill directory and updates the lock;
28
+ - everything else (`check`, `scan`, `mcp`) is read-only.
29
+
30
+ ## Risk areas and mitigations
31
+
32
+ ### `update` downloads untrusted content
33
+
34
+ - There is **no signature verification**. The catalog's `version` field is trusted as-is.
35
+ - Downloads are restricted to the same origin as `index.json`; path traversal (`..`, absolute paths, schemes) is rejected; files are written only under the target skill directory.
36
+ - Downloaded skills are **instructions for an agent**. Treat catalogs as code: use a catalog you control, pin it, review diffs, and run `update --check-only` before applying.
37
+ - The updater executes no code from the catalog. It only fetches and writes files.
38
+
39
+ ### `fix` and `--prune` change files
40
+
41
+ - `fix` archives only definitions that are **shadowed** (never loaded). The effective skill is not moved. Archived files go to `.skills-doctor-archive/<timestamp>/` and can be restored by moving them back.
42
+ - `fix` defaults to **project-only**; `--all` extends it to global sources (`~/.config/opencode/skills`, `~/.claude/skills`, ...).
43
+ - `update --prune` **deletes** skill directories that disappeared from a catalog. Without `--prune` they are only reported.
44
+ - Always use `--dry-run` first and review the paths that would change.
45
+
46
+ ### MCP server and plugins
47
+
48
+ - `skills-doctor mcp` only runs the local audit. It does not proxy, execute, or store MCP content.
49
+ - The OpenCode plugin runs inside the OpenCode process with your privileges. It registers one tool and one command; it does not intercept unrelated tool calls.
50
+ - The MCP client / plugin may start the package through `npx`, which downloads the published package from npm. Pin a version (`opencode-skills-doctor@0.3.9`) if you need reproducibility.
51
+
52
+ ### Not a sandbox
53
+
54
+ This tool audits configuration and files. **It does not constrain what an agent can do.** Use the permission and sandbox controls of your agent runtime (OpenCode permissions, OS sandboxing, containers) for containment.
55
+
56
+ ## Hardening checklist
57
+
58
+ - Run `check` and `fix --dry-run` before letting `fix` or `--prune` modify anything.
59
+ - Use `--no-global` on CI unless you explicitly want machine-wide skills audited.
60
+ - Pin the package version in automation: `npx -y opencode-skills-doctor@0.3.9 check --strict`.
61
+ - Review `.skills-doctor-archive/` and delete it when you no longer need the backups.
62
+ - Treat every catalog and every downloaded `SKILL.md` as code you are installing.
63
+
64
+ ## Out of scope
65
+
66
+ - Vulnerabilities in OpenCode, Claude Code, Codex, Codeium/Kimi Code, DeepSeek Harness, or any other host runtime.
67
+ - Malicious content inside third-party skills or catalogs (that is a trust decision for the operator; see above).
68
+ - Behavior of the models that read the skills.
@@ -0,0 +1,68 @@
1
+ # 安全策略
2
+
3
+ ## 支持的版本
4
+
5
+ | 版本 | 支持 |
6
+ |---|---|
7
+ | 0.3.x | ✅ |
8
+ | < 0.3 | ❌ |
9
+
10
+ 1.0 之前,只维护最新的次版本。
11
+
12
+ ## 报告漏洞
13
+
14
+ **请不要开公开 issue**,使用 GitHub 的私密漏洞报告:
15
+
16
+ <https://github.com/wangyuchen5110/opencodeplugginprogram/security/advisories/new>
17
+
18
+ 请附上版本、复现步骤、影响,以及你建议的修复。我们会尽力在 7 天内响应。
19
+
20
+ ## 这个工具会对你的系统做什么
21
+
22
+ - 以调用者的权限**在本机**运行,无遥测。
23
+ - 只有 `update`(HTTP catalog)和通过 `npx` 安装/运行时才会联网。
24
+ - 读取 skill 文件与 frontmatter、`opencode.json(c)` / `opencode.jsonc`、`.skills-doctor.lock.json`;**不读取**模型凭据、API key 或对话内容。
25
+ - 只在明确命令下写入:
26
+ - `fix` —— 把被遮蔽的定义移入 `.skills-doctor-archive/`,`--hide` 会改 `metadata.opencode/autoinvoke`;
27
+ - `update` —— 下载文件写入 skill 目录并更新 lock;
28
+ - 其余(`check`、`scan`、`mcp`)都是只读。
29
+
30
+ ## 风险点与缓解措施
31
+
32
+ ### `update` 会下载不受信内容
33
+
34
+ - **没有签名校验**,catalog 的 `version` 字段被直接信任。
35
+ - 下载限制为与 `index.json` 同源;拒绝路径穿越(`..`、绝对路径、协议前缀);文件只会写在目标 skill 目录内。
36
+ - 下载的 skill 本质是**给 agent 的指令**。把 catalog 当代码对待:只用你控制的 catalog、固定版本、审查 diff、先跑 `update --check-only`。
37
+ - 更新器不会执行 catalog 里的任何代码,只做抓取和写文件。
38
+
39
+ ### `fix` 与 `--prune` 会改文件
40
+
41
+ - `fix` 只归档**被遮蔽**(永远不会加载)的定义,不会移动生效版本。归档到 `.skills-doctor-archive/<时间戳>/`,移回去即可恢复。
42
+ - `fix` 默认**只处理项目内**;`--all` 才会扩展到全局来源(`~/.config/opencode/skills`、`~/.claude/skills` 等)。
43
+ - `update --prune` 会**删除**已从 catalog 下架的 skill 目录;不加 `--prune` 只提示不删。
44
+ - 一律先用 `--dry-run` 确认会改哪些路径。
45
+
46
+ ### MCP server 与插件
47
+
48
+ - `skills-doctor mcp` 只执行本地审计,不代理、不执行、不存储 MCP 内容。
49
+ - OpenCode 插件运行在 OpenCode 进程内,使用你的权限;只注册一个工具和一个命令,不会拦截无关的工具调用。
50
+ - MCP 客户端/插件可能通过 `npx` 启动,会从 npm 下载已发布的包。需要可复现时固定版本(`opencode-skills-doctor@0.3.9`)。
51
+
52
+ ### 它不是沙箱
53
+
54
+ 这个工具只审计配置和文件,**不限制 agent 能做什么**。隔离请使用 agent 运行时的权限/沙箱能力(OpenCode permissions、操作系统沙箱、容器等)。
55
+
56
+ ## 加固清单
57
+
58
+ - 让 `fix` / `--prune` 改任何东西之前,先跑 `check` 和 `fix --dry-run`。
59
+ - CI 里用 `--no-global`,除非你确实想审计整机 skill。
60
+ - 自动化里固定版本:`npx -y opencode-skills-doctor@0.3.9 check --strict`。
61
+ - 定期审查并清理 `.skills-doctor-archive/`。
62
+ - 把每个 catalog 和每个下载的 `SKILL.md` 都当成你要安装的代码。
63
+
64
+ ## 不在范围内
65
+
66
+ - OpenCode、Claude Code、Codex、Kimi Code、DeepSeek Harness 等宿主运行时自身的漏洞。
67
+ - 第三方 skill / catalog 里的恶意内容(这是使用方的信任决策,见上)。
68
+ - 读取这些 skill 的模型本身的行为。
@@ -4,13 +4,13 @@
4
4
 
5
5
  OpenCode skill 的**本地检测器 + 更新器 + 修复器**,并附带 **OpenCode 插件入口** —— 零依赖,Node ≥ 20。
6
6
 
7
- [English](README.md) | **简体中文**
7
+ [English](../README.md) | **简体中文**
8
8
 
9
9
  ## 为什么需要它
10
10
 
11
11
  opencode 每个模型步骤都会把所有「有 description 的 skill」展示给模型(`ID + name + description`)。skill 不是免费的——它是**每一次请求都要交的上下文税**。
12
12
 
13
- **成本有多大**:29 个 skill、描述偏长,就是**每步 3000+ tokens**。一次 100 步的会话,光 skill 目录就烧掉约 30 万 tokens,模型还没开始读你一行代码。
13
+ **成本有多大**:29 个 skill、描述偏长,就是**每步 3000+ tokens**。一次 100 步的会话,光 skill 目录就烧掉约 30 万 tokens,模型还没开始读你一行代码。而这只是 29 个——很多人还有更多没注意就装上的 skill,有些已经过时。这个工具能帮你真正看清自己的 skill 仓库。
14
14
 
15
15
  **会出什么问题**:skill 越攒越多,就会出现
16
16
  - 同 ID 互相遮蔽——你改的可能是一份永远不会加载的文件;
@@ -244,6 +244,16 @@ Windows 上如果 stdio 起不来 `npx`,改成 `command: cmd`、`args: ["/c",
244
244
 
245
245
  `check --json` / `scan --json` / `update --json` 都会输出结构化 JSON,方便接 CI 或做网页报告。字段包括 `summary`、`findings[]`、`skills[]`(含 `managed: { version, url, root }`)。
246
246
 
247
+ ## 安全
248
+
249
+ 这个工具会读写 skill 文件。对真实环境使用前:
250
+
251
+ - `update` 下载的 catalog 内容**没有签名校验**——把 catalog 当代码对待;
252
+ - `fix` / `--prune` 会移动或删除文件——先 `--dry-run` 预览;
253
+ - 它**不是沙箱**,不限制 agent 能做什么。
254
+
255
+ 完整威胁模型、加固清单和私密漏洞报告方式见 [SECURITY.zh-CN.md](../SECURITY.zh-CN.md)。
256
+
247
257
  ## 注意事项与已知限制
248
258
 
249
259
  **安装 / 插件**
package/package.json CHANGED
@@ -1,55 +1,59 @@
1
- {
2
- "name": "opencode-skills-doctor",
3
- "version": "0.3.8",
4
- "description": "Audit, fix and update agent skills: per-step context cost, shadowed duplicates, safe archive, catalog sync. CLI, OpenCode plugin and MCP server (Claude Code / Codex / any MCP client).",
5
- "type": "module",
6
- "exports": {
7
- ".": "./src/plugin.mjs",
8
- "./plugin": "./src/plugin.mjs",
9
- "./server": "./src/plugin-v1.mjs",
10
- "./v1": "./src/plugin-v1.mjs",
11
- "./audit": "./src/audit.mjs"
12
- },
13
- "bin": {
14
- "skills-doctor": "./bin/skills-doctor.mjs"
15
- },
16
- "peerDependencies": {
17
- "@opencode/plugin": ">=2.0.0",
18
- "@opencode-ai/plugin": ">=1.0.0"
19
- },
20
- "peerDependenciesMeta": {
21
- "@opencode/plugin": {
22
- "optional": true
23
- },
24
- "@opencode-ai/plugin": {
25
- "optional": true
26
- }
27
- },
28
- "engines": {
29
- "node": ">=20"
30
- },
31
- "scripts": {
32
- "test": "node test/doctor.test.mjs"
33
- },
34
- "files": [
35
- "bin",
36
- "src",
37
- "README.md",
38
- "README.zh-CN.md"
39
- ],
40
- "keywords": [
41
- "opencode",
42
- "skills",
43
- "agent-skills",
44
- "mcp",
45
- "lint",
46
- "fix",
47
- "cli",
48
- "plugin"
49
- ],
50
- "repository": {
51
- "type": "git",
52
- "url": "https://github.com/wangyuchen5110/opencodeplugginprogram.git"
53
- },
54
- "license": "MIT"
55
- }
1
+ {
2
+ "name": "opencode-skills-doctor",
3
+ "version": "0.3.10",
4
+ "description": "Audit, fix and update agent skills: per-step context cost, shadowed duplicates, safe archive, catalog sync. CLI, OpenCode plugin and MCP server (Claude Code / Codex / any MCP client).",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/plugin.mjs",
8
+ "./plugin": "./src/plugin.mjs",
9
+ "./server": "./src/plugin-v1.mjs",
10
+ "./v1": "./src/plugin-v1.mjs",
11
+ "./audit": "./src/audit.mjs"
12
+ },
13
+ "bin": {
14
+ "skills-doctor": "./bin/skills-doctor.mjs"
15
+ },
16
+ "peerDependencies": {
17
+ "@opencode/plugin": ">=2.0.0",
18
+ "@opencode-ai/plugin": ">=1.0.0"
19
+ },
20
+ "peerDependenciesMeta": {
21
+ "@opencode/plugin": {
22
+ "optional": true
23
+ },
24
+ "@opencode-ai/plugin": {
25
+ "optional": true
26
+ }
27
+ },
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "scripts": {
32
+ "test": "node test/doctor.test.mjs",
33
+ "bench": "node scripts/bench.mjs",
34
+ "prepublishOnly": "node test/doctor.test.mjs"
35
+ },
36
+ "files": [
37
+ "bin",
38
+ "src",
39
+ "docs",
40
+ "README.md",
41
+ "SECURITY.md",
42
+ "SECURITY.zh-CN.md"
43
+ ],
44
+ "keywords": [
45
+ "opencode",
46
+ "skills",
47
+ "agent-skills",
48
+ "mcp",
49
+ "lint",
50
+ "fix",
51
+ "cli",
52
+ "plugin"
53
+ ],
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "https://github.com/wangyuchen5110/opencodeplugginprogram.git"
57
+ },
58
+ "license": "MIT"
59
+ }
package/src/catalog.mjs CHANGED
@@ -68,6 +68,41 @@ function lockEntryVersion(entry) {
68
68
  return null
69
69
  }
70
70
 
71
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
72
+
73
+ /** fetch + 有限重试(5xx 与网络错误),避免 catalog 抖动直接失败。 */
74
+ async function fetchWithRetry(fetchImpl, url, timeoutMs, attempts = 3) {
75
+ let lastError
76
+ for (let attempt = 0; attempt < attempts; attempt++) {
77
+ try {
78
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) })
79
+ if (res.status >= 500 && attempt < attempts - 1) {
80
+ await sleep(200 * 2 ** attempt)
81
+ continue
82
+ }
83
+ return res
84
+ } catch (err) {
85
+ lastError = err
86
+ if (attempt < attempts - 1) await sleep(200 * 2 ** attempt)
87
+ }
88
+ }
89
+ throw lastError ?? new Error("fetch failed")
90
+ }
91
+
92
+ /** 先写临时文件再原子替换,避免中途失败留下半截文件。 */
93
+ function writeFileAtomic(target, data) {
94
+ const tmp = `${target}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`
95
+ fs.writeFileSync(tmp, data)
96
+ try {
97
+ fs.renameSync(tmp, target)
98
+ } catch (err) {
99
+ try {
100
+ fs.rmSync(tmp, { force: true })
101
+ } catch {}
102
+ throw err
103
+ }
104
+ }
105
+
71
106
  function pruneEmptyDirs(dir, stopDir) {
72
107
  const stop = path.resolve(stopDir)
73
108
  let current = path.resolve(dir)
@@ -96,7 +131,7 @@ function filesMatch(into, name, files, t) {
96
131
  /** 抓取 <url>/index.json。未知字段一律忽略(向上兼容)。 */
97
132
  export async function fetchCatalogIndex(url, { fetchImpl = fetch, timeoutMs = 15000, t = translator("en") } = {}) {
98
133
  const base = url.replace(/\/+$/, "")
99
- const res = await fetchImpl(`${base}/index.json`, { signal: AbortSignal.timeout(timeoutMs) })
134
+ const res = await fetchWithRetry(fetchImpl, `${base}/index.json`, timeoutMs)
100
135
  if (!res.ok) throw new Error(t("catalog.indexHttp", { status: res.status }))
101
136
  const json = await res.json()
102
137
  if (!json || !Array.isArray(json.skills)) throw new Error(t("catalog.indexShape"))
@@ -162,12 +197,12 @@ export async function updateCatalogs(
162
197
  const encodedPath = file.split("/").map(encodeURIComponent).join("/")
163
198
  const fileUrl = new URL(`${encodeURIComponent(entry.name)}/${encodedPath}`, baseUrl)
164
199
  if (fileUrl.origin !== baseUrl.origin) throw new Error(t("catalog.crossOrigin", { file }))
165
- const res = await fetchImpl(fileUrl, { signal: AbortSignal.timeout(30000) })
200
+ const res = await fetchWithRetry(fetchImpl, fileUrl, 30000)
166
201
  if (!res.ok) throw new Error(t("catalog.fileHttp", { name: entry.name, file, status: res.status }))
167
202
  const buffer = Buffer.from(await res.arrayBuffer())
168
203
  const target = resolveInside(path.join(into, entry.name), file, t)
169
204
  fs.mkdirSync(path.dirname(target), { recursive: true })
170
- fs.writeFileSync(target, buffer)
205
+ writeFileAtomic(target, buffer)
171
206
  hashes[file] = sha1Buffer(buffer)
172
207
  result.files++
173
208
  }
@@ -219,7 +254,7 @@ export async function updateCatalogs(
219
254
  lock.version = LOCK_VERSION
220
255
  lock.generatedAt = new Date().toISOString()
221
256
  fs.mkdirSync(path.dirname(lockPath), { recursive: true })
222
- fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + "\n", "utf8")
257
+ writeFileAtomic(lockPath, JSON.stringify(lock, null, 2) + "\n")
223
258
  }
224
259
 
225
260
  return { lockPath, lockTooNew, results }
package/src/lint.mjs CHANGED
@@ -157,16 +157,20 @@ export function lint(skills, options = {}) {
157
157
  })
158
158
  }
159
159
 
160
- // 5) 描述近似
161
- const withDesc = described
160
+ // 5) 描述近似(token 集合只算一次;先按集合大小做 0.75 上界预筛)
161
+ const withDesc = described.map((skill) => ({ skill, tokens: tokenSet(skill.description) }))
162
162
  const pairs = []
163
163
  for (let i = 0; i < withDesc.length; i++) {
164
164
  for (let j = i + 1; j < withDesc.length; j++) {
165
165
  const a = withDesc[i]
166
166
  const b = withDesc[j]
167
- if (a.id === b.id) continue
168
- const score = jaccard(tokenSet(a.description), tokenSet(b.description))
169
- if (score >= NEAR_DUPLICATE_THRESHOLD) pairs.push({ a: a.id, b: b.id, score })
167
+ if (a.skill.id === b.skill.id) continue
168
+ const min = Math.min(a.tokens.size, b.tokens.size)
169
+ const max = Math.max(a.tokens.size, b.tokens.size)
170
+ // Jaccard ≤ min/max,所以比值低于阈值时不可能达到阈值
171
+ if (!min || min / max < NEAR_DUPLICATE_THRESHOLD) continue
172
+ const score = jaccard(a.tokens, b.tokens)
173
+ if (score >= NEAR_DUPLICATE_THRESHOLD) pairs.push({ a: a.skill.id, b: b.skill.id, score })
170
174
  }
171
175
  }
172
176
  for (const pair of pairs.slice(0, 15)) {