@damian87/omp 0.16.0 → 0.19.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.
@@ -0,0 +1,11 @@
1
+ export type PonytailLevel = "lite" | "full" | "ultra";
2
+ export interface PonytailState {
3
+ active: boolean;
4
+ level: PonytailLevel;
5
+ startedAt: string;
6
+ projectPath: string;
7
+ }
8
+ export declare function normalizeLevel(input?: string): PonytailLevel;
9
+ export declare function startPonytail(cwd?: string, level?: string): PonytailState;
10
+ export declare function readPonytail(cwd?: string): PonytailState | undefined;
11
+ export declare function cancelPonytail(cwd?: string): void;
@@ -0,0 +1,22 @@
1
+ import { clearModeState, readModeStateJson, writeModeStateJson } from "./paths.js";
2
+ export function normalizeLevel(input) {
3
+ const v = String(input ?? "").trim().toLowerCase();
4
+ return v === "lite" || v === "ultra" ? v : "full";
5
+ }
6
+ export function startPonytail(cwd = process.cwd(), level) {
7
+ const state = {
8
+ active: true,
9
+ level: normalizeLevel(level),
10
+ startedAt: new Date().toISOString(),
11
+ projectPath: cwd,
12
+ };
13
+ writeModeStateJson(cwd, "ponytail", state);
14
+ return state;
15
+ }
16
+ export function readPonytail(cwd = process.cwd()) {
17
+ return readModeStateJson(cwd, "ponytail");
18
+ }
19
+ export function cancelPonytail(cwd = process.cwd()) {
20
+ clearModeState(cwd, "ponytail");
21
+ }
22
+ //# sourceMappingURL=ponytail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ponytail.js","sourceRoot":"","sources":["../../../src/mode-state/ponytail.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAWnF,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,MAAM,CAAC,GAAG,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACnD,OAAO,CAAC,KAAK,MAAM,IAAI,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;AACpD,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,MAAc,OAAO,CAAC,GAAG,EAAE,EAAE,KAAc;IACvE,MAAM,KAAK,GAAkB;QAC3B,MAAM,EAAE,IAAI;QACZ,KAAK,EAAE,cAAc,CAAC,KAAK,CAAC;QAC5B,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QACnC,WAAW,EAAE,GAAG;KACjB,CAAC;IACF,kBAAkB,CAAC,GAAG,EAAE,UAAU,EAAE,KAAK,CAAC,CAAC;IAC3C,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,MAAc,OAAO,CAAC,GAAG,EAAE;IACtD,OAAO,iBAAiB,CAAgB,GAAG,EAAE,UAAU,CAAC,CAAC;AAC3D,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,MAAc,OAAO,CAAC,GAAG,EAAE;IACxD,cAAc,CAAC,GAAG,EAAE,UAAU,CAAC,CAAC;AAClC,CAAC"}
@@ -0,0 +1,101 @@
1
+ # Security & quality pipeline
2
+
3
+ This repo ships two GitHub Actions workflows plus Dependabot. Together they cover
4
+ dependency vulnerabilities, supply-chain/malicious-package risk, static code
5
+ analysis, and **AI-skill-specific safety** (the same classes of issue that the
6
+ [skills.sh](https://www.skills.sh) audits — Agent Trust Hub, Socket, Snyk —
7
+ flag for Agent Skills).
8
+
9
+ ## Workflows
10
+
11
+ ### `.github/workflows/ci.yml` — build, test, lint, skills validation
12
+
13
+ | Job | What it runs |
14
+ | --- | --- |
15
+ | `build-test` | `npm ci` → `npm run build` (tsc) → `npm run lint` (eslint) → `npm test` (vitest), on Node 20 & 22 |
16
+ | `skills` | `npm run lint:skills` (omp's own validator) + `npm run check:catalog` |
17
+
18
+ ### `.github/workflows/security.yml` — security scanners
19
+
20
+ | Job | Tool | Secret required | Gate |
21
+ | --- | --- | --- | --- |
22
+ | `npm-audit` | `npm audit` | none | fails on **high+** in **production** deps |
23
+ | `skills-safety` | `scripts/skills-safety-scan.mjs` | none | fails on any **HIGH** finding |
24
+ | `codeql` | GitHub CodeQL (JS/TS) | none | results in Security tab |
25
+ | `dependency-review` | GitHub Dependency Review | none (PRs only) | fails on **high** severity |
26
+ | `socket` | Socket CLI | `SOCKET_SECURITY_API_KEY` | skipped if secret unset |
27
+ | `snyk` | Snyk Open Source + Snyk Code | `SNYK_TOKEN` | SARIF → Security tab |
28
+
29
+ Runs on every push/PR to `main`, plus a weekly scheduled full scan (Mondays).
30
+ Socket and Snyk jobs **self-skip with a notice** if their secret isn't set, so
31
+ the pipeline is green out of the box and lights up as you add tokens.
32
+
33
+ ## Required secrets (optional but recommended)
34
+
35
+ Add these under **Settings → Secrets and variables → Actions → New repository secret**:
36
+
37
+ | Secret | Where to get it | Free tier |
38
+ | --- | --- | --- |
39
+ | `SNYK_TOKEN` | [snyk.io](https://snyk.io) → Account settings → Auth Token | Yes |
40
+ | `SOCKET_SECURITY_API_KEY` | [socket.dev](https://socket.dev) → Settings → API Tokens | Yes |
41
+
42
+ `GITHUB_TOKEN` is provided automatically — no setup needed for CodeQL,
43
+ Dependency Review, or SARIF upload.
44
+
45
+ ## GitHub repo settings to flip on (one-time, free)
46
+
47
+ These complement the workflows and live in **Settings → Code security**:
48
+
49
+ - **Dependabot alerts** + **security updates** — `.github/dependabot.yml` already
50
+ schedules weekly version bumps for npm and Actions.
51
+ - **Secret scanning** + **push protection** — blocks committed credentials.
52
+ - **Code scanning** — surfaces CodeQL/Snyk SARIF in the Security tab.
53
+
54
+ ## The skills safety scanner
55
+
56
+ `scripts/skills-safety-scan.mjs` statically audits `.github/skills/**`,
57
+ `.github/agents/**`, and `catalog/**` — including SKILL.md docs and bundled
58
+ helper scripts (`*.sh`, `*.py`, `*.mjs`, …) — for the risk classes those
59
+ external audits care about. To avoid false positives on documentation, the
60
+ command-style rules (S001–S003, S005–S007) only match inside fenced code blocks
61
+ in markdown (and the full body of script files); only the prompt-injection rule
62
+ (S004) scans prose. Multi-line commands joined with `\` are merged before
63
+ matching so a wrapped `curl … | sh` can't slip through.
64
+
65
+ | Rule | Severity | Detects |
66
+ | --- | --- | --- |
67
+ | S001 | HIGH | `curl … | sh` remote code execution |
68
+ | S002 | MEDIUM | Unpinned remote install / `npx <pkg> add` |
69
+ | S003 | LOW | Global `-g` installs |
70
+ | S004 | MEDIUM | Indirect prompt-injection surface (fetch + act on untrusted/third-party content — cf. Snyk W011) |
71
+ | S005 | HIGH | Credential/secret exfiltration |
72
+ | S006 | MEDIUM | Obfuscation (`base64 -d | sh`, `eval(`, `Function(`) |
73
+ | S007 | HIGH | Destructive shell (`rm -rf /`, `dd`, `mkfs`, `chmod 777`) |
74
+ | S100/S101 | MEDIUM | SKILL.md missing `name` / `description` frontmatter |
75
+
76
+ Run locally:
77
+
78
+ ```bash
79
+ npm run scan:skills # human-readable, fails on HIGH
80
+ node scripts/skills-safety-scan.mjs --json # machine-readable
81
+ node scripts/skills-safety-scan.mjs --strict # also fail on MEDIUM
82
+ node scripts/skills-safety-scan.mjs --allow-empty # don't fail when no skills exist
83
+ ```
84
+
85
+ ## Local commands
86
+
87
+ ```bash
88
+ npm run lint # eslint over src + scripts
89
+ npm run lint:fix # auto-fix
90
+ npm run lint:skills # omp's own SKILL.md validator
91
+ npm run check:catalog # catalog schema validation
92
+ npm run scan:skills # AI-skill safety scan
93
+ npm run audit:ci # prod-dep vulnerability gate (high+)
94
+ ```
95
+
96
+ ## Notes
97
+
98
+ - ESLint is scoped to `src/**` and `scripts/**`. Tests (`test/**`) are covered
99
+ by vitest and kept out of the lint gate to avoid a large up-front refactor.
100
+ - `npm audit` gates on **production** dependencies only (`--omit=dev`); dev-only
101
+ vulns (vitest, vite, etc.) are handled by Dependabot PRs rather than blocking CI.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@damian87/omp",
3
- "version": "0.16.0",
3
+ "version": "0.19.0",
4
4
  "description": "Multi-agent orchestration for GitHub Copilot CLI — autonomous loops (Autopilot, Ralph, UltraQA, Ultrawork), parallel tmux agent teams, a weighted-consensus model council, a Slack chat bridge, durable scheduled jobs, and in-session skills + custom agents. Zero learning curve.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -30,7 +30,11 @@
30
30
  "catalog:list": "npm run build && node dist/src/cli.js catalog list",
31
31
  "project:inspect": "npm run build && node dist/src/cli.js project inspect",
32
32
  "test": "vitest run",
33
+ "lint": "eslint .",
34
+ "lint:fix": "eslint . --fix",
33
35
  "lint:skills": "npm run build && node dist/src/cli.js lint:skills --root .",
36
+ "scan:skills": "node scripts/skills-safety-scan.mjs --root .",
37
+ "audit:ci": "npm audit --audit-level=high --omit=dev",
34
38
  "sync:dry-run": "npm run build && node dist/src/cli.js sync:dry-run --root .",
35
39
  "jira:dry-run": "npm run build && node dist/src/cli.js jira:dry-run --root .",
36
40
  "omp:version": "npm run build && node dist/src/cli.js version",
@@ -57,12 +61,15 @@
57
61
  "node": ">=20"
58
62
  },
59
63
  "devDependencies": {
60
- "@types/node": "^22.15.29",
64
+ "@eslint/js": "^10.0.1",
65
+ "eslint": "^10.5.0",
66
+ "typescript-eslint": "^8.11.0",
67
+ "@types/node": "^26.0.1",
61
68
  "@types/node-notifier": "^8.0.5",
62
- "@vitest/coverage-v8": "^3.2.4",
63
- "tsx": "^4.19.4",
64
- "typescript": "^5.8.3",
65
- "vitest": "^3.1.4"
69
+ "@vitest/coverage-v8": "^4.1.9",
70
+ "tsx": "^4.22.4",
71
+ "typescript": "^6.0.3",
72
+ "vitest": "^4.1.9"
66
73
  },
67
74
  "dependencies": {
68
75
  "@modelcontextprotocol/sdk": "^1.29.0",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "oh-my-copilot",
3
3
  "description": "Multi-agent orchestration skills for GitHub Copilot CLI — autopilot, ralph, ultrawork, ultraqa, team, council, code-review and more as in-session slash skills + custom agents.",
4
- "version": "0.16.0",
4
+ "version": "0.19.0",
5
5
  "author": {
6
6
  "name": "Damian Borek",
7
7
  "email": "borekdamian@yahoo.pl"
@@ -35,6 +35,16 @@ function buildContinuationContext(directory) {
35
35
  parts.push(
36
36
  `[ULTRAQA ACTIVE: cycle ${ultraqa.cycleCount}/${ultraqa.maxCycles}]\nGoal: ${ultraqa.goal}\nRun tests → verify → fix. Iterate.`,
37
37
  );
38
+ const ponytail = readModeState(directory, "ponytail");
39
+ if (ponytail?.active)
40
+ parts.push(
41
+ `[PONYTAIL ACTIVE: ${ponytail.level}]\n` +
42
+ "Lazy senior dev mode. After understanding the problem, stop at the first rung that holds: " +
43
+ "1 needed at all? (YAGNI) 2 already here? reuse 3 stdlib? use it 4 native platform? use it " +
44
+ "5 installed dep? use it 6 one line? one line 7 only then the minimum that works. " +
45
+ "Never lazy about validation at trust boundaries, data-loss handling, security, accessibility, " +
46
+ "or anything requested; non-trivial logic leaves one runnable check behind.",
47
+ );
38
48
  return parts.join("\n\n---\n\n");
39
49
  }
40
50
 
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * skills-safety-scan — static safety audit for Agent Skills (SKILL.md) and agents.
4
+ *
5
+ * Mirrors the kinds of issues that Agent Trust Hub / Socket / Snyk flag for
6
+ * AI skills (see skills.sh audits), but runs locally with no network and no
7
+ * secrets so it can gate every PR:
8
+ *
9
+ * - Untrusted install / remote-code execution surfaces (curl | sh, npx <pkg>, -g, npm i remote)
10
+ * - Indirect prompt-injection surfaces (fetching + acting on third-party/user content)
11
+ * - Credential / secret exfiltration patterns
12
+ * - Obfuscation (base64 decode + exec, eval, large encoded blobs)
13
+ * - Destructive shell (rm -rf, dd, mkfs, chmod 777)
14
+ * - Frontmatter hygiene (missing name/description)
15
+ *
16
+ * Exit codes:
17
+ * 0 = no HIGH findings (warnings allowed)
18
+ * 1 = at least one HIGH finding, with --strict any MEDIUM finding, or no files
19
+ * scanned (target moved) unless --allow-empty is passed
20
+ *
21
+ * Usage:
22
+ * node scripts/skills-safety-scan.mjs [--root .] [--strict] [--json] [--allow-empty]
23
+ */
24
+ import { readFileSync, readdirSync, statSync } from "node:fs";
25
+ import { join, relative, sep } from "node:path";
26
+
27
+ const args = process.argv.slice(2);
28
+ const opt = (flag, def = null) => {
29
+ const i = args.indexOf(flag);
30
+ return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : def;
31
+ };
32
+ const ROOT = typeof opt("--root") === "string" ? opt("--root") : ".";
33
+ const STRICT = !!opt("--strict", false);
34
+ const JSON_OUT = !!opt("--json", false);
35
+ const ALLOW_EMPTY = !!opt("--allow-empty", false);
36
+
37
+ // Where skills/agents live in this repo.
38
+ const SCAN_DIRS = [".github/skills", ".github/agents", "catalog"];
39
+ // Markdown/JSON docs plus bundled executable helpers — dangerous shell often
40
+ // lives in a skill's `scripts/*.sh`, not just its prose.
41
+ const SCAN_EXT = [".md", ".json", ".sh", ".bash", ".zsh", ".py", ".ps1", ".mjs", ".cjs", ".js"];
42
+
43
+ /** @type {{severity:'HIGH'|'MEDIUM'|'LOW',rule:string,file:string,line:number,match:string,why:string}[]} */
44
+ const findings = [];
45
+
46
+ const RULES = [
47
+ {
48
+ rule: "S001 remote-code-execution",
49
+ severity: "HIGH",
50
+ re: /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba)?sh\b/i,
51
+ why: "Pipes a downloaded script straight into a shell (untrusted remote code execution).",
52
+ },
53
+ {
54
+ rule: "S002 unpinned-remote-install",
55
+ severity: "MEDIUM",
56
+ re: /\bnpx\s+(?:-y\s+|--yes\s+)?(?!tsc\b|vitest\b|eslint\b)[a-z@][\w@/.-]*\s+add\b|\bnpm\s+i(nstall)?\s+(-g\s+)?https?:\/\//i,
57
+ why: "Installs/executes packages from an external source at runtime (supply-chain + untrusted install surface).",
58
+ },
59
+ {
60
+ rule: "S003 global-install",
61
+ severity: "LOW",
62
+ re: /\bnpm\s+i(nstall)?\s+-g\b|\bnpx\b[^\n]*\s-g\b/i,
63
+ why: "Global install persists tooling at user/system level.",
64
+ },
65
+ {
66
+ rule: "S004 prompt-injection-surface",
67
+ severity: "MEDIUM",
68
+ // Natural-language instruction, not a shell command — scan prose, not just code.
69
+ context: "prose",
70
+ re: /\b(fetch|read|download|ingest|browse)\b[^\n]*\b(untrusted|third-?party|user-generated|external (registry|source|content|repos?))\b/i,
71
+ why: "Fetches and may act on third-party/untrusted content — indirect prompt-injection risk (cf. Snyk W011).",
72
+ },
73
+ {
74
+ rule: "S005 credential-exfiltration",
75
+ severity: "HIGH",
76
+ // The token keyword may be a suffix/component of the var name
77
+ // (`$GITHUB_TOKEN`, `$NPM_TOKEN`, `$AWS_SECRET_ACCESS_KEY`), not just a prefix.
78
+ re: /\b(env|printenv|cat)\b[^\n]*\b(\.env|secret|token|api[_-]?key|password|credential)\b[^\n]*\|\s*(curl|wget|nc)\b|\b(curl|wget)\b[^\n]*\$\{?\s*[A-Z0-9_]*(SECRET|TOKEN|API[_-]?KEY|PASSWORD)/i,
79
+ why: "Reads secrets/credentials and sends them off the machine.",
80
+ },
81
+ {
82
+ rule: "S006 obfuscation",
83
+ severity: "MEDIUM",
84
+ re: /\bbase64\b\s+(-d|--decode)\b[^\n]*\|\s*(ba)?sh|\beval\b\s*\(|\bFunction\s*\(\s*['"`]/i,
85
+ why: "Decodes/evaluates hidden code at runtime (obfuscation).",
86
+ },
87
+ {
88
+ rule: "S007 destructive-shell",
89
+ severity: "HIGH",
90
+ // `chmod 777` and `chmod -R 777` (and `0777`) are all world-writable.
91
+ re: /\brm\s+-rf\s+[/~]|\bdd\s+if=|\bmkfs\b|\bchmod\s+(-R\s+)?0?777\b|>\s*\/dev\/sd/i,
92
+ why: "Destructive or overly-permissive filesystem operation.",
93
+ },
94
+ ];
95
+
96
+ function walk(dir) {
97
+ let out = [];
98
+ let entries;
99
+ try {
100
+ entries = readdirSync(dir);
101
+ } catch {
102
+ return out; // dir may not exist in every repo
103
+ }
104
+ for (const name of entries) {
105
+ const p = join(dir, name);
106
+ let s;
107
+ try {
108
+ s = statSync(p);
109
+ } catch {
110
+ continue;
111
+ }
112
+ if (s.isDirectory()) out = out.concat(walk(p));
113
+ else if (SCAN_EXT.some((e) => name.endsWith(e))) out.push(p);
114
+ }
115
+ return out;
116
+ }
117
+
118
+ // Logical "code" lines for command-style rules:
119
+ // - Markdown (.md): only lines inside fenced code blocks (``` or ~~~), so
120
+ // documentation that merely *mentions* a dangerous command in prose can't
121
+ // trip a HIGH rule and block every PR.
122
+ // - Scripts/JSON: the whole file is code.
123
+ // Backslash line-continuations are merged into one logical line so a command
124
+ // wrapped across multiple lines can't slip past single-line regexes.
125
+ function codeLines(text, file) {
126
+ const isMarkdown = file.endsWith(".md");
127
+ const raw = text.split(/\r?\n/);
128
+ const out = [];
129
+ let inFence = false;
130
+ let fenceChar = "";
131
+ for (let i = 0; i < raw.length; i++) {
132
+ let line = raw[i];
133
+ if (isMarkdown) {
134
+ const fence = line.match(/^\s*(`{3,}|~{3,})/);
135
+ if (fence) {
136
+ const ch = fence[1][0];
137
+ if (!inFence) {
138
+ inFence = true;
139
+ fenceChar = ch;
140
+ } else if (ch === fenceChar) {
141
+ inFence = false;
142
+ }
143
+ continue; // never scan the fence marker line itself
144
+ }
145
+ if (!inFence) continue; // skip prose
146
+ }
147
+ const startLine = i + 1;
148
+ while (/\\[ \t]*$/.test(line) && i + 1 < raw.length) {
149
+ i += 1;
150
+ line = line.replace(/\\[ \t]*$/, " ") + raw[i];
151
+ }
152
+ out.push({ line: startLine, text: line });
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function scanFile(file) {
158
+ const rel = relative(ROOT, file).split(sep).join("/");
159
+ const text = readFileSync(file, "utf8");
160
+
161
+ // Frontmatter hygiene for SKILL.md
162
+ if (file.endsWith("SKILL.md")) {
163
+ const fmEnd = text.indexOf("\n---", 3);
164
+ const fm = text.startsWith("---") && fmEnd !== -1 ? text.slice(3, fmEnd) : "";
165
+ if (!/\bname\s*:/.test(fm))
166
+ findings.push({ severity: "MEDIUM", rule: "S100 missing-name", file: rel, line: 1, match: "frontmatter", why: "SKILL.md is missing a `name` in frontmatter." });
167
+ if (!/\bdescription\s*:/.test(fm))
168
+ findings.push({ severity: "MEDIUM", rule: "S101 missing-description", file: rel, line: 1, match: "frontmatter", why: "SKILL.md is missing a `description` in frontmatter." });
169
+ }
170
+
171
+ const push = (r, line, m) =>
172
+ findings.push({ severity: r.severity, rule: r.rule, file: rel, line, match: m[0].slice(0, 120), why: r.why });
173
+
174
+ // Command-style rules over code context (fenced blocks / whole scripts).
175
+ const cmdRules = RULES.filter((r) => r.context !== "prose");
176
+ for (const { line, text: lt } of codeLines(text, file)) {
177
+ for (const r of cmdRules) {
178
+ const m = r.re.exec(lt);
179
+ if (m) push(r, line, m);
180
+ }
181
+ }
182
+
183
+ // Prose rules (e.g. prompt-injection) scan the full natural-language text.
184
+ const proseRules = RULES.filter((r) => r.context === "prose");
185
+ if (proseRules.length) {
186
+ text.split(/\r?\n/).forEach((line, i) => {
187
+ for (const r of proseRules) {
188
+ const m = r.re.exec(line);
189
+ if (m) push(r, i + 1, m);
190
+ }
191
+ });
192
+ }
193
+ }
194
+
195
+ // Run
196
+ const files = SCAN_DIRS.flatMap((d) => walk(join(ROOT, d)));
197
+ files.forEach(scanFile);
198
+
199
+ const counts = { HIGH: 0, MEDIUM: 0, LOW: 0 };
200
+ findings.forEach((f) => (counts[f.severity] += 1));
201
+
202
+ if (JSON_OUT) {
203
+ console.log(JSON.stringify({ scanned: files.length, counts, findings }, null, 2));
204
+ } else {
205
+ const C = { HIGH: "\x1b[31m", MEDIUM: "\x1b[33m", LOW: "\x1b[36m", reset: "\x1b[0m" };
206
+ console.log(`\nskills-safety-scan — scanned ${files.length} file(s) in ${SCAN_DIRS.join(", ")}\n`);
207
+ if (findings.length === 0) {
208
+ console.log(" ✓ No issues found.\n");
209
+ } else {
210
+ for (const f of findings.sort((a, b) => ("HML".indexOf(a.severity[0]) - "HML".indexOf(b.severity[0])))) {
211
+ console.log(` ${C[f.severity]}${f.severity}${C.reset} ${f.rule}`);
212
+ console.log(` ${f.file}:${f.line}`);
213
+ console.log(` match: ${f.match}`);
214
+ console.log(` why: ${f.why}\n`);
215
+ }
216
+ }
217
+ console.log(`Summary: ${counts.HIGH} high, ${counts.MEDIUM} medium, ${counts.LOW} low\n`);
218
+ }
219
+
220
+ // Scanning zero files almost always means the target moved (renamed skill dir,
221
+ // wrong --root) rather than a clean repo — fail loudly instead of passing green.
222
+ const emptyScan = files.length === 0 && !ALLOW_EMPTY;
223
+ if (emptyScan) {
224
+ console.error(
225
+ `skills-safety-scan: ERROR — scanned 0 files under ${SCAN_DIRS.join(", ")}. ` +
226
+ `The scan target may have moved. Pass --allow-empty if this is intentional.`,
227
+ );
228
+ }
229
+
230
+ const fail = counts.HIGH > 0 || (STRICT && counts.MEDIUM > 0) || emptyScan;
231
+ process.exit(fail ? 1 : 0);