opencode-skills-collection 3.0.51 → 3.1.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 CHANGED
@@ -21,7 +21,7 @@
21
21
 
22
22
  ## Overview
23
23
 
24
- **OpenCode Skills Collection** ships a pre-bundled snapshot of 1000+ universal skills for the OpenCode CLI.
24
+ **OpenCode Skills Collection** ships a pre-bundled snapshot of 1595+ universal skills for the OpenCode CLI.
25
25
 
26
26
  Instead of loading every skill into the AI context at startup — which would consume ~80k tokens and cause compaction
27
27
  loops — the plugin uses a **SkillPointer** architecture: skills are organized into categories inside a hidden vault and
@@ -45,7 +45,10 @@ bundled-skills/ (npm package)
45
45
 
46
46
  └── SkillPointer pipeline
47
47
 
48
- ├─ vault-manager moves raw skills to the vault
48
+ ├─ risk-filter excludes skills by risk level or ID
49
+ ├─ content-scanner → quarantines skills with dangerous patterns
50
+ ├─ vault-manager → moves safe skills to the vault
51
+ ├─ skill-patcher → applies config-driven content patches
49
52
  └─ pointer-generator → writes ~35 lightweight pointer files
50
53
  ```
51
54
 
@@ -61,24 +64,19 @@ The full skill content is only injected into context when the AI actually needs
61
64
 
62
65
  After the first startup, your `~/.config/opencode/` directory looks like this:
63
66
 
64
- ```
65
67
  ~/.config/opencode/
66
68
  ├── opencode.json
67
- ├── skills/ pointer folders (active, read by OpenCode)
69
+ ├── skill-filter.jsonc optional: risk filter + patcher config
70
+ ├── skills/ ← pointer folders (active, read by OpenCode)
68
71
  │ ├── backend-dev-category-pointer/
69
72
  │ │ └── SKILL.md
70
- │ ├── devops-category-pointer/
71
- │ │ └── SKILL.md
72
73
  │ └── ...
73
- └── skill-libraries/ ← vault with all raw skills (hidden from startup context)
74
+ └── skill-libraries/ ← vault with all raw skills
74
75
  ├── backend-dev/
75
76
  │ ├── laravel-expert/
76
77
  │ │ └── SKILL.md
77
- │ └── wordpress-core/
78
- │ └── SKILL.md
79
- ├── devops/
78
+ │ └── ...
80
79
  └── ...
81
- ```
82
80
 
83
81
  ---
84
82
 
@@ -138,7 +136,7 @@ opencode run /refactor clean up this function
138
136
 
139
137
  ---
140
138
 
141
- ## Skill Risk Filter
139
+ ## Skill Safety & Filtering
142
140
 
143
141
  The plugin supports configurable risk-based filtering of skills. By default, **all skills are loaded** — filtering is
144
142
  opt-in.
@@ -169,6 +167,40 @@ Create a `~/.config/opencode/skill-filter.jsonc` file:
169
167
 
170
168
  Blocked skills are excluded from both the vault and the generated pointers — they are never loaded into context.
171
169
 
170
+ ### Content Safety Scanner (CI)
171
+
172
+ Dangerous skills are automatically detected and removed **at build time** — before the npm package is published.
173
+ The nightly sync workflow scans every SKILL.md for recursive loop patterns and strips matching skills from
174
+ `bundled-skills/` and `skills_index.json`, so they never reach end users.
175
+
176
+ **Built-in patterns detect:**
177
+ - Recursive skill invocation loops ("invoke skills before any response")
178
+ - Aggressive match thresholds ("even a 1% chance")
179
+ - Mandatory pre-response skill checks ("you must invoke the skill")
180
+
181
+ ### Skill Patcher
182
+
183
+ The plugin can modify skill content after installation via config-driven patches. This allows neutralizing
184
+ problematic instructions without forking upstream skills.
185
+
186
+ Add patches in `skill-filter.jsonc`:
187
+
188
+ ```jsonc
189
+ {
190
+ "skillPatches": [
191
+ {
192
+ "skillId": "some-skill-name",
193
+ "find": "regex-pattern-to-match",
194
+ "replace": "replacement-text",
195
+ "description": "Why this patch exists"
196
+ }
197
+ ]
198
+ }
199
+ ```
200
+
201
+ Patches are applied in order, case-insensitive, and globally (all occurrences). Invalid regex patterns are
202
+ skipped silently. Re-running the pipeline with the same patches is idempotent.
203
+
172
204
  ---
173
205
 
174
206
  ## Development
@@ -1,7 +1,21 @@
1
1
  import type { RiskLevel } from "./risk-level.js";
2
+ export interface ScanPattern {
3
+ id: string;
4
+ pattern: string;
5
+ description: string;
6
+ severity: "block" | "warn";
7
+ }
8
+ export interface SkillPatch {
9
+ skillId: string;
10
+ find: string;
11
+ replace: string;
12
+ description?: string;
13
+ }
2
14
  export interface SkillRiskFilterConfig {
3
15
  excludedRiskLevels?: RiskLevel[];
4
16
  excludedSkills?: string[];
17
+ scanPatterns?: ScanPattern[];
18
+ skillPatches?: SkillPatch[];
5
19
  }
6
20
  export declare const DEFAULT_FILTER_CONFIG_PATH: string;
7
21
  /**
@@ -1,13 +1,34 @@
1
- import os from "os";
2
- import path from "path";
3
- import fs from "fs";
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import * as fs from "node:fs";
4
4
  import stripJsonComments from "strip-json-comments";
5
5
  const DEFAULT_CONFIG = {
6
6
  excludedRiskLevels: [],
7
7
  excludedSkills: [],
8
+ scanPatterns: [],
9
+ skillPatches: [],
8
10
  };
9
11
  export const DEFAULT_FILTER_CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "skill-filter.jsonc");
10
12
  const VALID_RISK_LEVELS = ["none", "safe", "critical", "offensive", "unknown"];
13
+ const VALID_SEVERITIES = ["block", "warn"];
14
+ function isValidScanPattern(entry) {
15
+ if (typeof entry !== "object" || entry === null)
16
+ return false;
17
+ const obj = entry;
18
+ return (typeof obj.id === "string" &&
19
+ typeof obj.pattern === "string" &&
20
+ typeof obj.description === "string" &&
21
+ typeof obj.severity === "string" &&
22
+ VALID_SEVERITIES.includes(obj.severity));
23
+ }
24
+ function isValidSkillPatch(entry) {
25
+ if (typeof entry !== "object" || entry === null)
26
+ return false;
27
+ const obj = entry;
28
+ return (typeof obj.skillId === "string" &&
29
+ typeof obj.find === "string" &&
30
+ typeof obj.replace === "string");
31
+ }
11
32
  /**
12
33
  * Loads filter config from skill-filter.jsonc. Missing file or section returns defaults.
13
34
  * @param configPath Optional override (for testing).
@@ -28,6 +49,12 @@ export function loadFilterConfig(configPath) {
28
49
  excludedSkills: Array.isArray(parsed.excludedSkills)
29
50
  ? parsed.excludedSkills
30
51
  : DEFAULT_CONFIG.excludedSkills,
52
+ scanPatterns: Array.isArray(parsed.scanPatterns)
53
+ ? parsed.scanPatterns.filter(isValidScanPattern)
54
+ : DEFAULT_CONFIG.scanPatterns,
55
+ skillPatches: Array.isArray(parsed.skillPatches)
56
+ ? parsed.skillPatches.filter(isValidSkillPatch)
57
+ : DEFAULT_CONFIG.skillPatches,
31
58
  };
32
59
  }
33
60
  catch {
@@ -0,0 +1,38 @@
1
+ import type { ScanPattern } from "./config-loader.js";
2
+ import type { SkillIndexEntry } from "./vault-installer.js";
3
+ export interface ScanResult {
4
+ skillId: string;
5
+ matchedPatterns: {
6
+ id: string;
7
+ severity: "block" | "warn";
8
+ }[];
9
+ blocked: boolean;
10
+ }
11
+ export interface ScanOutput {
12
+ passed: SkillIndexEntry[];
13
+ quarantined: ScanResult[];
14
+ warned: ScanResult[];
15
+ }
16
+ /** Default patterns that detect recursive loop triggers in skill content. */
17
+ export declare const DEFAULT_SCAN_PATTERNS: ScanPattern[];
18
+ /**
19
+ * Merges user-supplied scan patterns with built-in defaults.
20
+ * Config patterns with the same `id` override the default entry,
21
+ * but only if the config pattern has a valid regex. Invalid overrides
22
+ * are rejected and the default is preserved to prevent silently
23
+ * disabling built-in protection.
24
+ */
25
+ export declare function mergePatterns(configPatterns: ScanPattern[]): ScanPattern[];
26
+ /**
27
+ * Tests skill content against a list of scan patterns.
28
+ * Returns the list of matched pattern ids and severities.
29
+ */
30
+ export declare function scanSkillContent(content: string, patterns: ScanPattern[]): {
31
+ id: string;
32
+ severity: "block" | "warn";
33
+ }[];
34
+ /**
35
+ * Scans every skill's SKILL.md for dangerous content patterns.
36
+ * Returns lists of passed, quarantined (blocked), and warned entries.
37
+ */
38
+ export declare function scanSkills(bundledSkillsPath: string, index: SkillIndexEntry[], configPatterns?: ScanPattern[]): ScanOutput;
@@ -0,0 +1,118 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { SKILL_FILENAME } from "../constants/constants.js";
4
+ /** Default patterns that detect recursive loop triggers in skill content. */
5
+ export const DEFAULT_SCAN_PATTERNS = [
6
+ {
7
+ id: "recursive-skill-invocation",
8
+ pattern: String.raw `(invoke|use|check|load)\b.*?\bskills?\b.*?\bbefore\s+(any|every|all)\s+(response|action|message)`,
9
+ description: "Detects instructions mandating skill invocation before every response",
10
+ severity: "block",
11
+ },
12
+ {
13
+ id: "aggressive-match-threshold",
14
+ pattern: String.raw `(even\s+)?(a\s+)?1%\s+chance.*skill`,
15
+ description: "Detects extremely low probability thresholds causing over-triggering",
16
+ severity: "block",
17
+ },
18
+ {
19
+ id: "mandatory-pre-response-skill-check",
20
+ pattern: String.raw `you\s+(absolutely\s+)?must.*invoke.*skill`,
21
+ description: "Detects mandatory skill invocation directives",
22
+ severity: "block",
23
+ },
24
+ ];
25
+ /**
26
+ * Merges user-supplied scan patterns with built-in defaults.
27
+ * Config patterns with the same `id` override the default entry,
28
+ * but only if the config pattern has a valid regex. Invalid overrides
29
+ * are rejected and the default is preserved to prevent silently
30
+ * disabling built-in protection.
31
+ */
32
+ export function mergePatterns(configPatterns) {
33
+ const merged = new Map();
34
+ for (const p of DEFAULT_SCAN_PATTERNS) {
35
+ merged.set(p.id, p);
36
+ }
37
+ for (const p of configPatterns) {
38
+ // Validate regex before allowing override
39
+ try {
40
+ new RegExp(p.pattern, "is");
41
+ merged.set(p.id, p);
42
+ }
43
+ catch {
44
+ // If this would override a default, keep the default — don't silently disable protection
45
+ if (!merged.has(p.id)) {
46
+ // New pattern with invalid regex — just skip it
47
+ console.warn(`Invalid regex for pattern '${p.id}': ${p.pattern}`);
48
+ }
49
+ // Existing default stays in place
50
+ }
51
+ }
52
+ return [...merged.values()];
53
+ }
54
+ /**
55
+ * Tests skill content against a list of scan patterns.
56
+ * Returns the list of matched pattern ids and severities.
57
+ */
58
+ export function scanSkillContent(content, patterns) {
59
+ const matches = [];
60
+ for (const p of patterns) {
61
+ try {
62
+ const re = new RegExp(p.pattern, "is");
63
+ if (re.test(content)) {
64
+ matches.push({ id: p.id, severity: p.severity });
65
+ }
66
+ }
67
+ catch {
68
+ // Invalid regex — skip silently
69
+ }
70
+ }
71
+ return matches;
72
+ }
73
+ /**
74
+ * Scans every skill's SKILL.md for dangerous content patterns.
75
+ * Returns lists of passed, quarantined (blocked), and warned entries.
76
+ */
77
+ export function scanSkills(bundledSkillsPath, index, configPatterns) {
78
+ const patterns = mergePatterns(configPatterns ?? []);
79
+ const passed = [];
80
+ const quarantined = [];
81
+ const warned = [];
82
+ for (const entry of index) {
83
+ // Path traversal guard: quarantine entries whose id escapes the bundled-skills directory
84
+ if (entry.id.includes("..") || entry.id.includes(path.sep) || entry.id.includes("/")) {
85
+ quarantined.push({
86
+ skillId: entry.id,
87
+ matchedPatterns: [{ id: "path-traversal", severity: "block" }],
88
+ blocked: true,
89
+ });
90
+ continue;
91
+ }
92
+ const skillFile = path.join(bundledSkillsPath, entry.id, SKILL_FILENAME);
93
+ if (!fs.existsSync(skillFile)) {
94
+ passed.push(entry);
95
+ continue;
96
+ }
97
+ const content = fs.readFileSync(skillFile, "utf-8");
98
+ const matches = scanSkillContent(content, patterns);
99
+ if (matches.length === 0) {
100
+ passed.push(entry);
101
+ continue;
102
+ }
103
+ const hasBlock = matches.some((m) => m.severity === "block");
104
+ const result = {
105
+ skillId: entry.id,
106
+ matchedPatterns: matches,
107
+ blocked: hasBlock,
108
+ };
109
+ if (hasBlock) {
110
+ quarantined.push(result);
111
+ }
112
+ else {
113
+ warned.push(result);
114
+ passed.push(entry);
115
+ }
116
+ }
117
+ return { passed, quarantined, warned };
118
+ }
@@ -17,10 +17,15 @@ export interface SkillPointerOptions {
17
17
  * Orchestrates the full SkillPointer pipeline:
18
18
  *
19
19
  * 1. Reads skills_index.json bundled alongside the skills snapshot.
20
- * 2. Copies bundled skills directly into the vault, categorised by the index.
21
- * 3. Generates pointer SKILL.md files in activeSkillsDir with full skill
20
+ * 2. Filters by risk level / excluded skills.
21
+ * 3. Copies skills into the vault, categorised by the index.
22
+ * 4. Applies config-driven content patches to installed skills.
23
+ * 5. Generates pointer SKILL.md files in activeSkillsDir with full skill
22
24
  * listings so keyword searches (e.g. "laravel") resolve out of the box.
23
25
  *
26
+ * Content safety scanning is performed at CI time (sync-skills.yml),
27
+ * not at runtime — dangerous skills are removed before npm publish.
28
+ *
24
29
  * The activeSkillsDir is never used as a staging area — user custom
25
30
  * skills already present there are never moved or overwritten.
26
31
  */
@@ -1,11 +1,12 @@
1
- import os from "os";
2
- import path from "path";
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
3
  import { VAULT_DIR_NAME } from "../constants/constants.js";
4
4
  import { ensureDir } from "../utils/fs.utils.js";
5
5
  import { generatePointers } from "./pointer-generator.js";
6
6
  import { installSkillsToVault, loadSkillsIndex } from "./vault-installer.js";
7
7
  import { filterIndex } from "./skill-risk-filter.js";
8
8
  import { loadFilterConfig, DEFAULT_FILTER_CONFIG_PATH } from "./config-loader.js";
9
+ import { applySkillPatches } from "./skill-patcher.js";
9
10
  function resolveDefaultVaultDir() {
10
11
  return path.join(os.homedir(), ".config", "opencode", VAULT_DIR_NAME);
11
12
  }
@@ -13,10 +14,15 @@ function resolveDefaultVaultDir() {
13
14
  * Orchestrates the full SkillPointer pipeline:
14
15
  *
15
16
  * 1. Reads skills_index.json bundled alongside the skills snapshot.
16
- * 2. Copies bundled skills directly into the vault, categorised by the index.
17
- * 3. Generates pointer SKILL.md files in activeSkillsDir with full skill
17
+ * 2. Filters by risk level / excluded skills.
18
+ * 3. Copies skills into the vault, categorised by the index.
19
+ * 4. Applies config-driven content patches to installed skills.
20
+ * 5. Generates pointer SKILL.md files in activeSkillsDir with full skill
18
21
  * listings so keyword searches (e.g. "laravel") resolve out of the box.
19
22
  *
23
+ * Content safety scanning is performed at CI time (sync-skills.yml),
24
+ * not at runtime — dangerous skills are removed before npm publish.
25
+ *
20
26
  * The activeSkillsDir is never used as a staging area — user custom
21
27
  * skills already present there are never moved or overwritten.
22
28
  */
@@ -29,5 +35,9 @@ export function runSkillPointer(options) {
29
35
  const config = loadFilterConfig(configPath);
30
36
  const filteredIndex = filterIndex(index, config);
31
37
  installSkillsToVault(options.bundledSkillsPath, vaultDir, filteredIndex);
38
+ // Apply content patches
39
+ if (config.skillPatches && config.skillPatches.length > 0) {
40
+ applySkillPatches(vaultDir, filteredIndex, config.skillPatches);
41
+ }
32
42
  generatePointers(options.activeSkillsDir, vaultDir, filteredIndex);
33
43
  }
@@ -35,6 +35,8 @@ ${skillList}
35
35
  **Vault path:** \`${normalizedPath}\`
36
36
 
37
37
  > Do not guess best practices — always read from the vault first.
38
+
39
+ > ⚠️ **Anti-loop guard**: Do NOT invoke skills recursively or check for applicable skills before every response. Each skill should be loaded at most once per user request. If you have already identified and loaded the relevant skill for this task, proceed with execution — do not re-scan for skills.
38
40
  `;
39
41
  }
40
42
  /**
@@ -0,0 +1,13 @@
1
+ import type { SkillPatch } from "./config-loader.js";
2
+ import type { SkillIndexEntry } from "./vault-installer.js";
3
+ /**
4
+ * Applies regex-based content patches to installed skill files in the vault.
5
+ *
6
+ * Each patch targets a `skillId`, performing a case-insensitive global regex
7
+ * replacement on the skill's SKILL.md content. Multiple patches for the same
8
+ * skill are applied in order. Invalid regex patterns are silently skipped.
9
+ *
10
+ * This function is idempotent — re-running with the same patches on already-patched
11
+ * content produces no further changes (the regex simply won't match).
12
+ */
13
+ export declare function applySkillPatches(vaultDir: string, index: SkillIndexEntry[], patches: SkillPatch[]): void;
@@ -0,0 +1,99 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { SKILL_FILENAME } from "../constants/constants.js";
4
+ /**
5
+ * Checks whether a path component is safe (no traversal sequences).
6
+ */
7
+ function isSafePath(component) {
8
+ return !component.includes("..") && !component.includes("/") && !component.includes(path.sep);
9
+ }
10
+ /**
11
+ * Resolves the SKILL.md file path for a given index entry within the vault.
12
+ * Returns `undefined` if the path is unsafe or the file does not exist.
13
+ */
14
+ function resolveSkillFile(vaultDir, entry) {
15
+ if (!isSafePath(entry.id) || !isSafePath(entry.category))
16
+ return undefined;
17
+ const skillFile = path.join(vaultDir, entry.category, entry.id, SKILL_FILENAME);
18
+ if (!fs.existsSync(skillFile))
19
+ return undefined;
20
+ const resolvedVault = fs.realpathSync(vaultDir);
21
+ const realSkillFile = fs.realpathSync(skillFile);
22
+ if (!realSkillFile.startsWith(resolvedVault + path.sep))
23
+ return undefined;
24
+ return skillFile;
25
+ }
26
+ /**
27
+ * Applies a single patch to content, returning the modified content.
28
+ * Returns `undefined` if the patch could not be applied (invalid regex, empty find, no match).
29
+ */
30
+ function applySinglePatch(content, patch) {
31
+ if (!patch.find)
32
+ return undefined;
33
+ try {
34
+ const re = new RegExp(patch.find, "gis");
35
+ // Use function replacement to treat patch.replace as a literal string
36
+ // (avoids $& $1 $` $' being interpreted as special replacement patterns)
37
+ const replacement = patch.replace;
38
+ const newContent = content.replace(re, () => replacement);
39
+ return newContent === content ? undefined : newContent;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ /**
46
+ * Groups patches by skillId, preserving order within each group.
47
+ */
48
+ function groupPatchesBySkill(patches) {
49
+ const grouped = new Map();
50
+ for (const patch of patches) {
51
+ const group = grouped.get(patch.skillId);
52
+ if (group) {
53
+ group.push(patch);
54
+ }
55
+ else {
56
+ grouped.set(patch.skillId, [patch]);
57
+ }
58
+ }
59
+ return grouped;
60
+ }
61
+ /**
62
+ * Applies regex-based content patches to installed skill files in the vault.
63
+ *
64
+ * Each patch targets a `skillId`, performing a case-insensitive global regex
65
+ * replacement on the skill's SKILL.md content. Multiple patches for the same
66
+ * skill are applied in order. Invalid regex patterns are silently skipped.
67
+ *
68
+ * This function is idempotent — re-running with the same patches on already-patched
69
+ * content produces no further changes (the regex simply won't match).
70
+ */
71
+ export function applySkillPatches(vaultDir, index, patches) {
72
+ if (patches.length === 0)
73
+ return;
74
+ const entryById = new Map();
75
+ for (const entry of index) {
76
+ entryById.set(entry.id, entry);
77
+ }
78
+ const patchesBySkill = groupPatchesBySkill(patches);
79
+ for (const [skillId, skillPatches] of patchesBySkill) {
80
+ const entry = entryById.get(skillId);
81
+ if (!entry)
82
+ continue;
83
+ const skillFile = resolveSkillFile(vaultDir, entry);
84
+ if (!skillFile)
85
+ continue;
86
+ let content = fs.readFileSync(skillFile, "utf-8");
87
+ let modified = false;
88
+ for (const patch of skillPatches) {
89
+ const result = applySinglePatch(content, patch);
90
+ if (result !== undefined) {
91
+ content = result;
92
+ modified = true;
93
+ }
94
+ }
95
+ if (modified) {
96
+ fs.writeFileSync(skillFile, content, "utf-8");
97
+ }
98
+ }
99
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-skills-collection",
3
- "version": "3.0.51",
3
+ "version": "3.1.0",
4
4
  "description": "OpenCode CLI plugin that automatically downloads and keeps skills up to date.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",