@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.
- package/.github/dependabot.yml +42 -0
- package/.github/skills/code-review/SKILL.md +5 -1
- package/.github/skills/debug/SKILL.md +1 -1
- package/.github/skills/ponytail/SKILL.md +83 -0
- package/.github/skills/qa-browse/SKILL.md +125 -0
- package/.github/skills/ralplan/SKILL.md +4 -1
- package/.github/skills/tdd/SKILL.md +21 -6
- package/.github/skills/ultraqa/SKILL.md +1 -1
- package/.github/skills/verify/SKILL.md +1 -1
- package/.github/workflows/ci.yml +67 -0
- package/.github/workflows/security.yml +157 -0
- package/README.md +2 -0
- package/catalog/capabilities.json +46 -0
- package/catalog/skills-general.json +26 -0
- package/dist/src/cli.js +25 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/env/init.js +1 -1
- package/dist/src/env/init.js.map +1 -1
- package/dist/src/memory-review/transcript.js +1 -1
- package/dist/src/memory-review/transcript.js.map +1 -1
- package/dist/src/mode-state/index.d.ts +1 -0
- package/dist/src/mode-state/index.js +1 -0
- package/dist/src/mode-state/index.js.map +1 -1
- package/dist/src/mode-state/paths.d.ts +5 -4
- package/dist/src/mode-state/paths.js.map +1 -1
- package/dist/src/mode-state/ponytail.d.ts +11 -0
- package/dist/src/mode-state/ponytail.js +22 -0
- package/dist/src/mode-state/ponytail.js.map +1 -0
- package/docs/security-pipeline.md +101 -0
- package/package.json +13 -6
- package/plugin.json +1 -1
- package/scripts/prompt-submit.mjs +10 -0
- package/scripts/skills-safety-scan.mjs +231 -0
|
@@ -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.
|
|
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
|
-
"@
|
|
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": "^
|
|
63
|
-
"tsx": "^4.
|
|
64
|
-
"typescript": "^
|
|
65
|
-
"vitest": "^
|
|
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.
|
|
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);
|