@studiomeyer-io/skilldoctor 0.1.0 โ†’ 0.1.1

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
@@ -1,10 +1,15 @@
1
+ <!-- studiomeyer-mcp-stack-banner:start -->
2
+ > **Part of the [StudioMeyer MCP Stack](https://studiomeyer.io)** โ€” Built in Mallorca ๐ŸŒด ยท โญ if you use it
3
+ <!-- studiomeyer-mcp-stack-banner:end -->
4
+
1
5
  # skilldoctor
2
6
 
3
7
  **A linter and security scanner for AI-agent skill & instruction files.** Think `eslint`, but for the `SKILL.md`, `AGENTS.md`, and subagent files that agents now install like packages.
4
8
 
5
9
  [![CI](https://github.com/studiomeyer-io/skilldoctor/actions/workflows/ci.yml/badge.svg)](https://github.com/studiomeyer-io/skilldoctor/actions/workflows/ci.yml)
6
- [![npm](https://img.shields.io/npm/v/skilldoctor.svg)](https://www.npmjs.com/package/skilldoctor)
7
- [![license](https://img.shields.io/npm/l/skilldoctor.svg)](./LICENSE)
10
+ [![npm](https://img.shields.io/npm/v/@studiomeyer-io/skilldoctor.svg)](https://www.npmjs.com/package/@studiomeyer-io/skilldoctor)
11
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/studiomeyer-io/skilldoctor/badge)](https://scorecard.dev/viewer/?uri=github.com/studiomeyer-io/skilldoctor)
12
+ [![license](https://img.shields.io/npm/l/@studiomeyer-io/skilldoctor.svg)](./LICENSE)
8
13
 
9
14
  ```bash
10
15
  npx @studiomeyer-io/skilldoctor check .claude/skills
@@ -92,7 +97,7 @@ It understands three file kinds:
92
97
  | `skill/invalid-name` | error | no | `name` must be 1-64 lowercase chars (`a-z 0-9 -`), no leading/trailing/consecutive hyphens. |
93
98
  | `skill/name-dir-mismatch` | warning | no | `name` must match the parent directory (spec). |
94
99
  | `skill/missing-description` | error | yes | `description` is required (it's how agents decide when to load a skill). |
95
- | `skill/empty-description` | error | yes | `description` is blank. |
100
+ | `skill/empty-description` | error | no | `description` is blank. |
96
101
  | `skill/description-too-short` | warning | no | Too short to convey what/when. |
97
102
  | `skill/description-too-long` | warning | no | Over the 1024-char spec limit. |
98
103
  | `skill/vague-description` | info | no | Generic phrasing with no trigger keywords. |
@@ -112,9 +117,9 @@ Run over the **description + body**, treated as untrusted input:
112
117
 
113
118
  | Rule | Default severity | Fixable | What it detects |
114
119
  | --- | --- | --- | --- |
115
- | `sec/prompt-injection` | error | no | "ignore previous instructions", "disregard your system prompt", role-override/jailbreak personas, injected "new instructions:". |
120
+ | `sec/prompt-injection` | error | no | "ignore previous instructions", "disregard your system prompt", role-override/jailbreak personas, injected "new instructions:" โ€” including phrases split across a line break. |
116
121
  | `sec/disable-safety` | error | no | Instructions to disable safety/guardrails/hooks/approval, or `--dangerously-skip-permissions`. |
117
- | `sec/data-exfiltration` | error | no | An outbound call (curl/POST/fetch to an external URL) **near** secrets/env โ€” the exfil shape. |
122
+ | `sec/data-exfiltration` | error | no | An outbound call (curl/POST/fetch to an external URL) **near** secrets/env โ€” including a secret in a curl auth header, a named secret env var, or a `$VAR` / `${VAR}` reference. The exfil shape. |
118
123
  | `sec/env-base64` | warning | no | base64/encode of `env`/secrets (covert exfil precursor). |
119
124
  | `sec/secret-access` | warning | no | Reads `~/.ssh`, `.aws/credentials`, `.env`, known secret env vars, โ€ฆ |
120
125
  | `sec/suspicious-tool-combo` | warning | no | A "read-only/docs" skill that grants **Bash + network** โ€” exfil-enabling combo. |
@@ -141,12 +146,12 @@ jobs:
141
146
  lint-skills:
142
147
  runs-on: ubuntu-latest
143
148
  steps:
144
- - uses: actions/checkout@v4
145
- - uses: actions/setup-node@v4
149
+ - uses: actions/checkout@v7
150
+ - uses: actions/setup-node@v6
146
151
  with: { node-version: 20 }
147
152
  - run: npx @studiomeyer-io/skilldoctor check ".claude/skills" "**/AGENTS.md" --sarif skilldoctor.sarif --fail-on warning
148
153
  - if: always()
149
- uses: github/codeql-action/upload-sarif@v3
154
+ uses: github/codeql-action/upload-sarif@v4
150
155
  with:
151
156
  sarif_file: skilldoctor.sarif
152
157
  ```
@@ -182,6 +187,16 @@ skilldoctor's rules are grounded in the actual current specs (checked while buil
182
187
 
183
188
  When a field's meaning is uncertain, skilldoctor **warns leniently rather than inventing a hard rule**.
184
189
 
190
+ ## Part of the StudioMeyer MCP toolkit
191
+
192
+ A small family of focused, production-grade tools for building and operating MCP servers & agents โ€” mix and match:
193
+
194
+ - [mcp-armor](https://github.com/studiomeyer-io/mcp-armor) โ€” runtime defense sidecar: scans tool calls, verifies signed manifests, blocks known-bad CVEs
195
+ - [mcp-gauntlet](https://github.com/studiomeyer-io/mcp-gauntlet) โ€” pre-deploy `mcp-fuzz` (schema-aware fuzzer) + `mcp-storm` (load tester)
196
+ - [mcp-otel](https://github.com/studiomeyer-io/mcp-otel) โ€” W3C Trace Context โ†’ OpenTelemetry bridge
197
+ - [mcp-cache-kit](https://github.com/studiomeyer-io/mcp-cache-kit) โ€” leak-safe SEP-2549 caching (`ttlMs` + `cacheScope`)
198
+ - **skilldoctor** *(this one)* โ€” linter + security scanner for agent skill files
199
+
185
200
  ## License
186
201
 
187
202
  [MIT](./LICENSE) ยฉ 2026 StudioMeyer. See [SECURITY.md](./SECURITY.md) for the security policy and the threat-model boundaries.
package/dist/cli.cjs CHANGED
@@ -157,8 +157,8 @@ var RULES = [
157
157
  title: "Empty description",
158
158
  category: "lint",
159
159
  defaultSeverity: "error",
160
- description: "`description` is present but blank. It must be non-empty.",
161
- fixable: true
160
+ description: "`description` is present but blank. It must be non-empty. The fixer deliberately does NOT auto-overwrite an existing (even empty) description with a stub, so this is reported but not auto-fixed.",
161
+ fixable: false
162
162
  },
163
163
  {
164
164
  ruleId: "skill/description-too-short",
@@ -800,6 +800,7 @@ var INJECTION_PATTERNS = [
800
800
  message: () => 'Injects a "new instructions:" block (prompt-override pattern).'
801
801
  }
802
802
  ];
803
+ var MULTILINE_INJECTION_RE = /\b(?:ignore|disregard|forget|override|bypass)\b[^.!?\n]{0,40}\b(?:previous|prior|above|earlier|preceding|all|the|your|system)\b[^.!?\n]{0,40}\b(?:instruction|prompt|message|context|rule|guideline|directive)s?\b/gi;
803
804
  var SAFETY_PATTERNS = [
804
805
  {
805
806
  ruleId: "sec/disable-safety",
@@ -876,7 +877,11 @@ var DESTRUCTIVE_PATTERNS = [
876
877
  }
877
878
  ];
878
879
  var OUTBOUND_RE = /\b(?:curl|wget|fetch|axios|http(?:s)?\.request|requests\.(?:post|get)|invoke-webrequest|Invoke-RestMethod)\b[^\n]{0,200}https?:\/\/[^\s"'`]+/gi;
879
- var SECRET_NEAR_RE = /\b(?:env|environ|process\.env|secret|token|api[_-]?key|password|credential|\$[A-Z_]{3,})\b/i;
880
+ var SECRET_WORD_RE = /\b(?:env|environ|process\.env|secret|token|api[_-]?key|password|passwd|credential|bearer)\b/i;
881
+ var SECRET_REF_RE = /\$\{?[A-Z][A-Z0-9_]{2,}\}?|\bprocess\.env\.[A-Za-z_]|\b(?:AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|GITHUB_TOKEN|GH_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY|NPM_TOKEN|DATABASE_URL)\b|\bSTRIPE_[A-Z_]*KEY\b/;
882
+ function hasSecretNear(window) {
883
+ return SECRET_WORD_RE.test(window) || SECRET_REF_RE.test(window);
884
+ }
880
885
  var SINGLE_PATTERN_GROUPS = [
881
886
  INJECTION_PATTERNS,
882
887
  SAFETY_PATTERNS,
@@ -919,11 +924,23 @@ function scanFile(file) {
919
924
  }
920
925
  for (const seg of segments) {
921
926
  runSinglePatterns(seg.text, seg.baseLine, findings);
927
+ runMultilineInjection(seg.text, seg.baseLine, findings);
922
928
  runExfilCheck(seg.text, seg.baseLine, findings);
923
929
  runHiddenUnicode(seg.text, seg.baseLine, findings);
924
930
  }
925
931
  runSuspiciousToolCombo(file, findings);
926
- return findings;
932
+ return dedupeFindings(findings);
933
+ }
934
+ function dedupeFindings(findings) {
935
+ const seen = /* @__PURE__ */ new Set();
936
+ const out = [];
937
+ for (const f of findings) {
938
+ const key = `${f.ruleId}\0${f.line}\0${f.column}`;
939
+ if (seen.has(key)) continue;
940
+ seen.add(key);
941
+ out.push(f);
942
+ }
943
+ return out;
927
944
  }
928
945
  function locInSegment(text, index, baseLine) {
929
946
  const { line, column } = offsetToLineCol(text, index);
@@ -946,6 +963,53 @@ function runSinglePatterns(text, baseLine, findings) {
946
963
  }
947
964
  }
948
965
  }
966
+ function runMultilineInjection(text, baseLine, findings) {
967
+ if (text.indexOf("\n") === -1) return;
968
+ let flat = "";
969
+ const map = [];
970
+ for (let i = 0; i < text.length; ) {
971
+ const ch = text[i];
972
+ if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
973
+ let j = i;
974
+ let sawNewline = false;
975
+ while (j < text.length) {
976
+ const cj = text[j];
977
+ if (cj === "\n" || cj === "\r") sawNewline = true;
978
+ else if (cj !== " " && cj !== " ") break;
979
+ j++;
980
+ }
981
+ if (sawNewline) {
982
+ map.push(i);
983
+ flat += " ";
984
+ i = j;
985
+ continue;
986
+ }
987
+ }
988
+ map.push(i);
989
+ flat += ch;
990
+ i++;
991
+ }
992
+ MULTILINE_INJECTION_RE.lastIndex = 0;
993
+ let m;
994
+ let guard = 0;
995
+ while ((m = MULTILINE_INJECTION_RE.exec(flat)) !== null) {
996
+ const origIndex = map[m.index] ?? 0;
997
+ const { line, column } = locInSegment(text, origIndex, baseLine);
998
+ findings.push(
999
+ finding2(
1000
+ "sec/prompt-injection",
1001
+ 'Contains "ignore previous instructions"-style injection (split across lines).',
1002
+ line,
1003
+ column,
1004
+ makeEvidence(m[0])
1005
+ )
1006
+ );
1007
+ if (m.index === MULTILINE_INJECTION_RE.lastIndex) {
1008
+ MULTILINE_INJECTION_RE.lastIndex++;
1009
+ }
1010
+ if (++guard > 1e3) break;
1011
+ }
1012
+ }
949
1013
  function runExfilCheck(text, baseLine, findings) {
950
1014
  OUTBOUND_RE.lastIndex = 0;
951
1015
  let m;
@@ -954,7 +1018,7 @@ function runExfilCheck(text, baseLine, findings) {
954
1018
  const start = Math.max(0, m.index - 160);
955
1019
  const end = Math.min(text.length, m.index + m[0].length + 160);
956
1020
  const window = text.slice(start, end);
957
- if (SECRET_NEAR_RE.test(window)) {
1021
+ if (hasSecretNear(window)) {
958
1022
  const { line, column } = locInSegment(text, m.index, baseLine);
959
1023
  findings.push(
960
1024
  finding2(
@@ -1453,6 +1517,27 @@ function toPascalName(ruleId) {
1453
1517
 
1454
1518
  // src/fix.ts
1455
1519
  var DESCRIPTION_STUB = "TODO describe what this skill does and when to use it.";
1520
+ function originalBodySuffix(raw) {
1521
+ let offset = 0;
1522
+ let lineNo = 0;
1523
+ while (offset <= raw.length) {
1524
+ let nl = raw.indexOf("\n", offset);
1525
+ const hasNl = nl !== -1;
1526
+ if (!hasNl) nl = raw.length;
1527
+ let lineEnd = nl;
1528
+ if (lineEnd > offset && raw.charCodeAt(lineEnd - 1) === 13) {
1529
+ lineEnd -= 1;
1530
+ }
1531
+ const line = raw.slice(offset, lineEnd);
1532
+ if (lineNo >= 1 && /^---[ \t]*$/.test(line)) {
1533
+ return hasNl ? raw.slice(nl + 1) : "";
1534
+ }
1535
+ if (!hasNl) break;
1536
+ offset = nl + 1;
1537
+ lineNo += 1;
1538
+ }
1539
+ return null;
1540
+ }
1456
1541
  function fixFile(file) {
1457
1542
  const applied = [];
1458
1543
  if (file.kind === "agents-md" || file.kind === "unknown") {
@@ -1520,9 +1605,10 @@ function fixFile(file) {
1520
1605
  if (applied.length === 0) {
1521
1606
  return { output: file.raw, changed: false, applied };
1522
1607
  }
1523
- const newFrontmatter = fmLines.join("\n");
1524
1608
  const eol = file.raw.includes("\r\n") ? "\r\n" : "\n";
1525
- const rebuilt = "---" + eol + newFrontmatter.split("\n").join(eol) + eol + "---" + eol + file.body.split("\n").join(eol);
1609
+ const newFrontmatter = fmLines.join(eol);
1610
+ const bodySuffix = originalBodySuffix(file.raw);
1611
+ const rebuilt = "---" + eol + newFrontmatter + eol + "---" + eol + (bodySuffix ?? "");
1526
1612
  return { output: rebuilt, changed: true, applied };
1527
1613
  }
1528
1614
 
@@ -1534,7 +1620,7 @@ var SEVERITY_RANK = {
1534
1620
  };
1535
1621
 
1536
1622
  // src/version.ts
1537
- var VERSION = "0.1.0" ;
1623
+ var VERSION = "0.1.1" ;
1538
1624
 
1539
1625
  // src/cli.ts
1540
1626
  var HELP = `skilldoctor v${VERSION}
package/dist/cli.js CHANGED
@@ -155,8 +155,8 @@ var RULES = [
155
155
  title: "Empty description",
156
156
  category: "lint",
157
157
  defaultSeverity: "error",
158
- description: "`description` is present but blank. It must be non-empty.",
159
- fixable: true
158
+ description: "`description` is present but blank. It must be non-empty. The fixer deliberately does NOT auto-overwrite an existing (even empty) description with a stub, so this is reported but not auto-fixed.",
159
+ fixable: false
160
160
  },
161
161
  {
162
162
  ruleId: "skill/description-too-short",
@@ -798,6 +798,7 @@ var INJECTION_PATTERNS = [
798
798
  message: () => 'Injects a "new instructions:" block (prompt-override pattern).'
799
799
  }
800
800
  ];
801
+ var MULTILINE_INJECTION_RE = /\b(?:ignore|disregard|forget|override|bypass)\b[^.!?\n]{0,40}\b(?:previous|prior|above|earlier|preceding|all|the|your|system)\b[^.!?\n]{0,40}\b(?:instruction|prompt|message|context|rule|guideline|directive)s?\b/gi;
801
802
  var SAFETY_PATTERNS = [
802
803
  {
803
804
  ruleId: "sec/disable-safety",
@@ -874,7 +875,11 @@ var DESTRUCTIVE_PATTERNS = [
874
875
  }
875
876
  ];
876
877
  var OUTBOUND_RE = /\b(?:curl|wget|fetch|axios|http(?:s)?\.request|requests\.(?:post|get)|invoke-webrequest|Invoke-RestMethod)\b[^\n]{0,200}https?:\/\/[^\s"'`]+/gi;
877
- var SECRET_NEAR_RE = /\b(?:env|environ|process\.env|secret|token|api[_-]?key|password|credential|\$[A-Z_]{3,})\b/i;
878
+ var SECRET_WORD_RE = /\b(?:env|environ|process\.env|secret|token|api[_-]?key|password|passwd|credential|bearer)\b/i;
879
+ var SECRET_REF_RE = /\$\{?[A-Z][A-Z0-9_]{2,}\}?|\bprocess\.env\.[A-Za-z_]|\b(?:AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|GITHUB_TOKEN|GH_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY|NPM_TOKEN|DATABASE_URL)\b|\bSTRIPE_[A-Z_]*KEY\b/;
880
+ function hasSecretNear(window) {
881
+ return SECRET_WORD_RE.test(window) || SECRET_REF_RE.test(window);
882
+ }
878
883
  var SINGLE_PATTERN_GROUPS = [
879
884
  INJECTION_PATTERNS,
880
885
  SAFETY_PATTERNS,
@@ -917,11 +922,23 @@ function scanFile(file) {
917
922
  }
918
923
  for (const seg of segments) {
919
924
  runSinglePatterns(seg.text, seg.baseLine, findings);
925
+ runMultilineInjection(seg.text, seg.baseLine, findings);
920
926
  runExfilCheck(seg.text, seg.baseLine, findings);
921
927
  runHiddenUnicode(seg.text, seg.baseLine, findings);
922
928
  }
923
929
  runSuspiciousToolCombo(file, findings);
924
- return findings;
930
+ return dedupeFindings(findings);
931
+ }
932
+ function dedupeFindings(findings) {
933
+ const seen = /* @__PURE__ */ new Set();
934
+ const out = [];
935
+ for (const f of findings) {
936
+ const key = `${f.ruleId}\0${f.line}\0${f.column}`;
937
+ if (seen.has(key)) continue;
938
+ seen.add(key);
939
+ out.push(f);
940
+ }
941
+ return out;
925
942
  }
926
943
  function locInSegment(text, index, baseLine) {
927
944
  const { line, column } = offsetToLineCol(text, index);
@@ -944,6 +961,53 @@ function runSinglePatterns(text, baseLine, findings) {
944
961
  }
945
962
  }
946
963
  }
964
+ function runMultilineInjection(text, baseLine, findings) {
965
+ if (text.indexOf("\n") === -1) return;
966
+ let flat = "";
967
+ const map = [];
968
+ for (let i = 0; i < text.length; ) {
969
+ const ch = text[i];
970
+ if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
971
+ let j = i;
972
+ let sawNewline = false;
973
+ while (j < text.length) {
974
+ const cj = text[j];
975
+ if (cj === "\n" || cj === "\r") sawNewline = true;
976
+ else if (cj !== " " && cj !== " ") break;
977
+ j++;
978
+ }
979
+ if (sawNewline) {
980
+ map.push(i);
981
+ flat += " ";
982
+ i = j;
983
+ continue;
984
+ }
985
+ }
986
+ map.push(i);
987
+ flat += ch;
988
+ i++;
989
+ }
990
+ MULTILINE_INJECTION_RE.lastIndex = 0;
991
+ let m;
992
+ let guard = 0;
993
+ while ((m = MULTILINE_INJECTION_RE.exec(flat)) !== null) {
994
+ const origIndex = map[m.index] ?? 0;
995
+ const { line, column } = locInSegment(text, origIndex, baseLine);
996
+ findings.push(
997
+ finding2(
998
+ "sec/prompt-injection",
999
+ 'Contains "ignore previous instructions"-style injection (split across lines).',
1000
+ line,
1001
+ column,
1002
+ makeEvidence(m[0])
1003
+ )
1004
+ );
1005
+ if (m.index === MULTILINE_INJECTION_RE.lastIndex) {
1006
+ MULTILINE_INJECTION_RE.lastIndex++;
1007
+ }
1008
+ if (++guard > 1e3) break;
1009
+ }
1010
+ }
947
1011
  function runExfilCheck(text, baseLine, findings) {
948
1012
  OUTBOUND_RE.lastIndex = 0;
949
1013
  let m;
@@ -952,7 +1016,7 @@ function runExfilCheck(text, baseLine, findings) {
952
1016
  const start = Math.max(0, m.index - 160);
953
1017
  const end = Math.min(text.length, m.index + m[0].length + 160);
954
1018
  const window = text.slice(start, end);
955
- if (SECRET_NEAR_RE.test(window)) {
1019
+ if (hasSecretNear(window)) {
956
1020
  const { line, column } = locInSegment(text, m.index, baseLine);
957
1021
  findings.push(
958
1022
  finding2(
@@ -1451,6 +1515,27 @@ function toPascalName(ruleId) {
1451
1515
 
1452
1516
  // src/fix.ts
1453
1517
  var DESCRIPTION_STUB = "TODO describe what this skill does and when to use it.";
1518
+ function originalBodySuffix(raw) {
1519
+ let offset = 0;
1520
+ let lineNo = 0;
1521
+ while (offset <= raw.length) {
1522
+ let nl = raw.indexOf("\n", offset);
1523
+ const hasNl = nl !== -1;
1524
+ if (!hasNl) nl = raw.length;
1525
+ let lineEnd = nl;
1526
+ if (lineEnd > offset && raw.charCodeAt(lineEnd - 1) === 13) {
1527
+ lineEnd -= 1;
1528
+ }
1529
+ const line = raw.slice(offset, lineEnd);
1530
+ if (lineNo >= 1 && /^---[ \t]*$/.test(line)) {
1531
+ return hasNl ? raw.slice(nl + 1) : "";
1532
+ }
1533
+ if (!hasNl) break;
1534
+ offset = nl + 1;
1535
+ lineNo += 1;
1536
+ }
1537
+ return null;
1538
+ }
1454
1539
  function fixFile(file) {
1455
1540
  const applied = [];
1456
1541
  if (file.kind === "agents-md" || file.kind === "unknown") {
@@ -1518,9 +1603,10 @@ function fixFile(file) {
1518
1603
  if (applied.length === 0) {
1519
1604
  return { output: file.raw, changed: false, applied };
1520
1605
  }
1521
- const newFrontmatter = fmLines.join("\n");
1522
1606
  const eol = file.raw.includes("\r\n") ? "\r\n" : "\n";
1523
- const rebuilt = "---" + eol + newFrontmatter.split("\n").join(eol) + eol + "---" + eol + file.body.split("\n").join(eol);
1607
+ const newFrontmatter = fmLines.join(eol);
1608
+ const bodySuffix = originalBodySuffix(file.raw);
1609
+ const rebuilt = "---" + eol + newFrontmatter + eol + "---" + eol + (bodySuffix ?? "");
1524
1610
  return { output: rebuilt, changed: true, applied };
1525
1611
  }
1526
1612
 
@@ -1532,7 +1618,7 @@ var SEVERITY_RANK = {
1532
1618
  };
1533
1619
 
1534
1620
  // src/version.ts
1535
- var VERSION = "0.1.0" ;
1621
+ var VERSION = "0.1.1" ;
1536
1622
 
1537
1623
  // src/cli.ts
1538
1624
  var HELP = `skilldoctor v${VERSION}
package/dist/index.cjs CHANGED
@@ -162,8 +162,8 @@ var RULES = [
162
162
  title: "Empty description",
163
163
  category: "lint",
164
164
  defaultSeverity: "error",
165
- description: "`description` is present but blank. It must be non-empty.",
166
- fixable: true
165
+ description: "`description` is present but blank. It must be non-empty. The fixer deliberately does NOT auto-overwrite an existing (even empty) description with a stub, so this is reported but not auto-fixed.",
166
+ fixable: false
167
167
  },
168
168
  {
169
169
  ruleId: "skill/description-too-short",
@@ -808,6 +808,7 @@ var INJECTION_PATTERNS = [
808
808
  message: () => 'Injects a "new instructions:" block (prompt-override pattern).'
809
809
  }
810
810
  ];
811
+ var MULTILINE_INJECTION_RE = /\b(?:ignore|disregard|forget|override|bypass)\b[^.!?\n]{0,40}\b(?:previous|prior|above|earlier|preceding|all|the|your|system)\b[^.!?\n]{0,40}\b(?:instruction|prompt|message|context|rule|guideline|directive)s?\b/gi;
811
812
  var SAFETY_PATTERNS = [
812
813
  {
813
814
  ruleId: "sec/disable-safety",
@@ -884,7 +885,11 @@ var DESTRUCTIVE_PATTERNS = [
884
885
  }
885
886
  ];
886
887
  var OUTBOUND_RE = /\b(?:curl|wget|fetch|axios|http(?:s)?\.request|requests\.(?:post|get)|invoke-webrequest|Invoke-RestMethod)\b[^\n]{0,200}https?:\/\/[^\s"'`]+/gi;
887
- var SECRET_NEAR_RE = /\b(?:env|environ|process\.env|secret|token|api[_-]?key|password|credential|\$[A-Z_]{3,})\b/i;
888
+ var SECRET_WORD_RE = /\b(?:env|environ|process\.env|secret|token|api[_-]?key|password|passwd|credential|bearer)\b/i;
889
+ var SECRET_REF_RE = /\$\{?[A-Z][A-Z0-9_]{2,}\}?|\bprocess\.env\.[A-Za-z_]|\b(?:AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|GITHUB_TOKEN|GH_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY|NPM_TOKEN|DATABASE_URL)\b|\bSTRIPE_[A-Z_]*KEY\b/;
890
+ function hasSecretNear(window) {
891
+ return SECRET_WORD_RE.test(window) || SECRET_REF_RE.test(window);
892
+ }
888
893
  var SINGLE_PATTERN_GROUPS = [
889
894
  INJECTION_PATTERNS,
890
895
  SAFETY_PATTERNS,
@@ -927,11 +932,23 @@ function scanFile(file) {
927
932
  }
928
933
  for (const seg of segments) {
929
934
  runSinglePatterns(seg.text, seg.baseLine, findings);
935
+ runMultilineInjection(seg.text, seg.baseLine, findings);
930
936
  runExfilCheck(seg.text, seg.baseLine, findings);
931
937
  runHiddenUnicode(seg.text, seg.baseLine, findings);
932
938
  }
933
939
  runSuspiciousToolCombo(file, findings);
934
- return findings;
940
+ return dedupeFindings(findings);
941
+ }
942
+ function dedupeFindings(findings) {
943
+ const seen = /* @__PURE__ */ new Set();
944
+ const out = [];
945
+ for (const f of findings) {
946
+ const key = `${f.ruleId}\0${f.line}\0${f.column}`;
947
+ if (seen.has(key)) continue;
948
+ seen.add(key);
949
+ out.push(f);
950
+ }
951
+ return out;
935
952
  }
936
953
  function locInSegment(text, index, baseLine) {
937
954
  const { line, column } = offsetToLineCol(text, index);
@@ -954,6 +971,53 @@ function runSinglePatterns(text, baseLine, findings) {
954
971
  }
955
972
  }
956
973
  }
974
+ function runMultilineInjection(text, baseLine, findings) {
975
+ if (text.indexOf("\n") === -1) return;
976
+ let flat = "";
977
+ const map = [];
978
+ for (let i = 0; i < text.length; ) {
979
+ const ch = text[i];
980
+ if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
981
+ let j = i;
982
+ let sawNewline = false;
983
+ while (j < text.length) {
984
+ const cj = text[j];
985
+ if (cj === "\n" || cj === "\r") sawNewline = true;
986
+ else if (cj !== " " && cj !== " ") break;
987
+ j++;
988
+ }
989
+ if (sawNewline) {
990
+ map.push(i);
991
+ flat += " ";
992
+ i = j;
993
+ continue;
994
+ }
995
+ }
996
+ map.push(i);
997
+ flat += ch;
998
+ i++;
999
+ }
1000
+ MULTILINE_INJECTION_RE.lastIndex = 0;
1001
+ let m;
1002
+ let guard = 0;
1003
+ while ((m = MULTILINE_INJECTION_RE.exec(flat)) !== null) {
1004
+ const origIndex = map[m.index] ?? 0;
1005
+ const { line, column } = locInSegment(text, origIndex, baseLine);
1006
+ findings.push(
1007
+ finding2(
1008
+ "sec/prompt-injection",
1009
+ 'Contains "ignore previous instructions"-style injection (split across lines).',
1010
+ line,
1011
+ column,
1012
+ makeEvidence(m[0])
1013
+ )
1014
+ );
1015
+ if (m.index === MULTILINE_INJECTION_RE.lastIndex) {
1016
+ MULTILINE_INJECTION_RE.lastIndex++;
1017
+ }
1018
+ if (++guard > 1e3) break;
1019
+ }
1020
+ }
957
1021
  function runExfilCheck(text, baseLine, findings) {
958
1022
  OUTBOUND_RE.lastIndex = 0;
959
1023
  let m;
@@ -962,7 +1026,7 @@ function runExfilCheck(text, baseLine, findings) {
962
1026
  const start = Math.max(0, m.index - 160);
963
1027
  const end = Math.min(text.length, m.index + m[0].length + 160);
964
1028
  const window = text.slice(start, end);
965
- if (SECRET_NEAR_RE.test(window)) {
1029
+ if (hasSecretNear(window)) {
966
1030
  const { line, column } = locInSegment(text, m.index, baseLine);
967
1031
  findings.push(
968
1032
  finding2(
@@ -1139,6 +1203,27 @@ function parseForFix(filePath, content) {
1139
1203
 
1140
1204
  // src/fix.ts
1141
1205
  var DESCRIPTION_STUB = "TODO describe what this skill does and when to use it.";
1206
+ function originalBodySuffix(raw) {
1207
+ let offset = 0;
1208
+ let lineNo = 0;
1209
+ while (offset <= raw.length) {
1210
+ let nl = raw.indexOf("\n", offset);
1211
+ const hasNl = nl !== -1;
1212
+ if (!hasNl) nl = raw.length;
1213
+ let lineEnd = nl;
1214
+ if (lineEnd > offset && raw.charCodeAt(lineEnd - 1) === 13) {
1215
+ lineEnd -= 1;
1216
+ }
1217
+ const line = raw.slice(offset, lineEnd);
1218
+ if (lineNo >= 1 && /^---[ \t]*$/.test(line)) {
1219
+ return hasNl ? raw.slice(nl + 1) : "";
1220
+ }
1221
+ if (!hasNl) break;
1222
+ offset = nl + 1;
1223
+ lineNo += 1;
1224
+ }
1225
+ return null;
1226
+ }
1142
1227
  function fixFile(file) {
1143
1228
  const applied = [];
1144
1229
  if (file.kind === "agents-md" || file.kind === "unknown") {
@@ -1206,9 +1291,10 @@ function fixFile(file) {
1206
1291
  if (applied.length === 0) {
1207
1292
  return { output: file.raw, changed: false, applied };
1208
1293
  }
1209
- const newFrontmatter = fmLines.join("\n");
1210
1294
  const eol = file.raw.includes("\r\n") ? "\r\n" : "\n";
1211
- const rebuilt = "---" + eol + newFrontmatter.split("\n").join(eol) + eol + "---" + eol + file.body.split("\n").join(eol);
1295
+ const newFrontmatter = fmLines.join(eol);
1296
+ const bodySuffix = originalBodySuffix(file.raw);
1297
+ const rebuilt = "---" + eol + newFrontmatter + eol + "---" + eol + (bodySuffix ?? "");
1212
1298
  return { output: rebuilt, changed: true, applied };
1213
1299
  }
1214
1300
  var IGNORED_DIRS = /* @__PURE__ */ new Set([
package/dist/index.d.cts CHANGED
@@ -102,7 +102,10 @@ declare function allRuleIds(): string[];
102
102
  * 2. add a missing `description:` stub with a TODO marker
103
103
  * 3. de-duplicate tools within `allowed-tools` / `tools` (preserving order)
104
104
  *
105
- * The fixer is idempotent: running it twice produces identical output.
105
+ * The body is re-attached BYTE-FOR-BYTE from the original source โ€” including its
106
+ * original line endings โ€” so a fix to the frontmatter can never alter, reflow,
107
+ * or re-encode a single character of the (untrusted) body. The fixer is
108
+ * idempotent: running it twice produces identical output.
106
109
  */
107
110
 
108
111
  /** Marker used for an inserted stub so humans (and tests) can find it. */
package/dist/index.d.ts CHANGED
@@ -102,7 +102,10 @@ declare function allRuleIds(): string[];
102
102
  * 2. add a missing `description:` stub with a TODO marker
103
103
  * 3. de-duplicate tools within `allowed-tools` / `tools` (preserving order)
104
104
  *
105
- * The fixer is idempotent: running it twice produces identical output.
105
+ * The body is re-attached BYTE-FOR-BYTE from the original source โ€” including its
106
+ * original line endings โ€” so a fix to the frontmatter can never alter, reflow,
107
+ * or re-encode a single character of the (untrusted) body. The fixer is
108
+ * idempotent: running it twice produces identical output.
106
109
  */
107
110
 
108
111
  /** Marker used for an inserted stub so humans (and tests) can find it. */
package/dist/index.js CHANGED
@@ -160,8 +160,8 @@ var RULES = [
160
160
  title: "Empty description",
161
161
  category: "lint",
162
162
  defaultSeverity: "error",
163
- description: "`description` is present but blank. It must be non-empty.",
164
- fixable: true
163
+ description: "`description` is present but blank. It must be non-empty. The fixer deliberately does NOT auto-overwrite an existing (even empty) description with a stub, so this is reported but not auto-fixed.",
164
+ fixable: false
165
165
  },
166
166
  {
167
167
  ruleId: "skill/description-too-short",
@@ -806,6 +806,7 @@ var INJECTION_PATTERNS = [
806
806
  message: () => 'Injects a "new instructions:" block (prompt-override pattern).'
807
807
  }
808
808
  ];
809
+ var MULTILINE_INJECTION_RE = /\b(?:ignore|disregard|forget|override|bypass)\b[^.!?\n]{0,40}\b(?:previous|prior|above|earlier|preceding|all|the|your|system)\b[^.!?\n]{0,40}\b(?:instruction|prompt|message|context|rule|guideline|directive)s?\b/gi;
809
810
  var SAFETY_PATTERNS = [
810
811
  {
811
812
  ruleId: "sec/disable-safety",
@@ -882,7 +883,11 @@ var DESTRUCTIVE_PATTERNS = [
882
883
  }
883
884
  ];
884
885
  var OUTBOUND_RE = /\b(?:curl|wget|fetch|axios|http(?:s)?\.request|requests\.(?:post|get)|invoke-webrequest|Invoke-RestMethod)\b[^\n]{0,200}https?:\/\/[^\s"'`]+/gi;
885
- var SECRET_NEAR_RE = /\b(?:env|environ|process\.env|secret|token|api[_-]?key|password|credential|\$[A-Z_]{3,})\b/i;
886
+ var SECRET_WORD_RE = /\b(?:env|environ|process\.env|secret|token|api[_-]?key|password|passwd|credential|bearer)\b/i;
887
+ var SECRET_REF_RE = /\$\{?[A-Z][A-Z0-9_]{2,}\}?|\bprocess\.env\.[A-Za-z_]|\b(?:AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|GITHUB_TOKEN|GH_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY|NPM_TOKEN|DATABASE_URL)\b|\bSTRIPE_[A-Z_]*KEY\b/;
888
+ function hasSecretNear(window) {
889
+ return SECRET_WORD_RE.test(window) || SECRET_REF_RE.test(window);
890
+ }
886
891
  var SINGLE_PATTERN_GROUPS = [
887
892
  INJECTION_PATTERNS,
888
893
  SAFETY_PATTERNS,
@@ -925,11 +930,23 @@ function scanFile(file) {
925
930
  }
926
931
  for (const seg of segments) {
927
932
  runSinglePatterns(seg.text, seg.baseLine, findings);
933
+ runMultilineInjection(seg.text, seg.baseLine, findings);
928
934
  runExfilCheck(seg.text, seg.baseLine, findings);
929
935
  runHiddenUnicode(seg.text, seg.baseLine, findings);
930
936
  }
931
937
  runSuspiciousToolCombo(file, findings);
932
- return findings;
938
+ return dedupeFindings(findings);
939
+ }
940
+ function dedupeFindings(findings) {
941
+ const seen = /* @__PURE__ */ new Set();
942
+ const out = [];
943
+ for (const f of findings) {
944
+ const key = `${f.ruleId}\0${f.line}\0${f.column}`;
945
+ if (seen.has(key)) continue;
946
+ seen.add(key);
947
+ out.push(f);
948
+ }
949
+ return out;
933
950
  }
934
951
  function locInSegment(text, index, baseLine) {
935
952
  const { line, column } = offsetToLineCol(text, index);
@@ -952,6 +969,53 @@ function runSinglePatterns(text, baseLine, findings) {
952
969
  }
953
970
  }
954
971
  }
972
+ function runMultilineInjection(text, baseLine, findings) {
973
+ if (text.indexOf("\n") === -1) return;
974
+ let flat = "";
975
+ const map = [];
976
+ for (let i = 0; i < text.length; ) {
977
+ const ch = text[i];
978
+ if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
979
+ let j = i;
980
+ let sawNewline = false;
981
+ while (j < text.length) {
982
+ const cj = text[j];
983
+ if (cj === "\n" || cj === "\r") sawNewline = true;
984
+ else if (cj !== " " && cj !== " ") break;
985
+ j++;
986
+ }
987
+ if (sawNewline) {
988
+ map.push(i);
989
+ flat += " ";
990
+ i = j;
991
+ continue;
992
+ }
993
+ }
994
+ map.push(i);
995
+ flat += ch;
996
+ i++;
997
+ }
998
+ MULTILINE_INJECTION_RE.lastIndex = 0;
999
+ let m;
1000
+ let guard = 0;
1001
+ while ((m = MULTILINE_INJECTION_RE.exec(flat)) !== null) {
1002
+ const origIndex = map[m.index] ?? 0;
1003
+ const { line, column } = locInSegment(text, origIndex, baseLine);
1004
+ findings.push(
1005
+ finding2(
1006
+ "sec/prompt-injection",
1007
+ 'Contains "ignore previous instructions"-style injection (split across lines).',
1008
+ line,
1009
+ column,
1010
+ makeEvidence(m[0])
1011
+ )
1012
+ );
1013
+ if (m.index === MULTILINE_INJECTION_RE.lastIndex) {
1014
+ MULTILINE_INJECTION_RE.lastIndex++;
1015
+ }
1016
+ if (++guard > 1e3) break;
1017
+ }
1018
+ }
955
1019
  function runExfilCheck(text, baseLine, findings) {
956
1020
  OUTBOUND_RE.lastIndex = 0;
957
1021
  let m;
@@ -960,7 +1024,7 @@ function runExfilCheck(text, baseLine, findings) {
960
1024
  const start = Math.max(0, m.index - 160);
961
1025
  const end = Math.min(text.length, m.index + m[0].length + 160);
962
1026
  const window = text.slice(start, end);
963
- if (SECRET_NEAR_RE.test(window)) {
1027
+ if (hasSecretNear(window)) {
964
1028
  const { line, column } = locInSegment(text, m.index, baseLine);
965
1029
  findings.push(
966
1030
  finding2(
@@ -1137,6 +1201,27 @@ function parseForFix(filePath, content) {
1137
1201
 
1138
1202
  // src/fix.ts
1139
1203
  var DESCRIPTION_STUB = "TODO describe what this skill does and when to use it.";
1204
+ function originalBodySuffix(raw) {
1205
+ let offset = 0;
1206
+ let lineNo = 0;
1207
+ while (offset <= raw.length) {
1208
+ let nl = raw.indexOf("\n", offset);
1209
+ const hasNl = nl !== -1;
1210
+ if (!hasNl) nl = raw.length;
1211
+ let lineEnd = nl;
1212
+ if (lineEnd > offset && raw.charCodeAt(lineEnd - 1) === 13) {
1213
+ lineEnd -= 1;
1214
+ }
1215
+ const line = raw.slice(offset, lineEnd);
1216
+ if (lineNo >= 1 && /^---[ \t]*$/.test(line)) {
1217
+ return hasNl ? raw.slice(nl + 1) : "";
1218
+ }
1219
+ if (!hasNl) break;
1220
+ offset = nl + 1;
1221
+ lineNo += 1;
1222
+ }
1223
+ return null;
1224
+ }
1140
1225
  function fixFile(file) {
1141
1226
  const applied = [];
1142
1227
  if (file.kind === "agents-md" || file.kind === "unknown") {
@@ -1204,9 +1289,10 @@ function fixFile(file) {
1204
1289
  if (applied.length === 0) {
1205
1290
  return { output: file.raw, changed: false, applied };
1206
1291
  }
1207
- const newFrontmatter = fmLines.join("\n");
1208
1292
  const eol = file.raw.includes("\r\n") ? "\r\n" : "\n";
1209
- const rebuilt = "---" + eol + newFrontmatter.split("\n").join(eol) + eol + "---" + eol + file.body.split("\n").join(eol);
1293
+ const newFrontmatter = fmLines.join(eol);
1294
+ const bodySuffix = originalBodySuffix(file.raw);
1295
+ const rebuilt = "---" + eol + newFrontmatter + eol + "---" + eol + (bodySuffix ?? "");
1210
1296
  return { output: rebuilt, changed: true, applied };
1211
1297
  }
1212
1298
  var IGNORED_DIRS = /* @__PURE__ */ new Set([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studiomeyer-io/skilldoctor",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Linter and security scanner for AI-agent skill and instruction files (Claude Code SKILL.md, AGENTS.md, subagents). Prints an A-F grade, supports --fix, emits JSON + SARIF, ships as a GitHub Action.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -62,7 +62,8 @@
62
62
  "typecheck": "tsc --noEmit",
63
63
  "attw": "attw --pack .",
64
64
  "clean": "rm -rf dist",
65
- "prepublishOnly": "npm run clean && npm run build && npm test && npm run typecheck && npm run attw"
65
+ "prepublishOnly": "npm run clean && npm run build && npm test && npm run typecheck && npm run attw",
66
+ "test:coverage": "vitest run --coverage"
66
67
  },
67
68
  "dependencies": {
68
69
  "yaml": "^2.9.0"
@@ -70,6 +71,7 @@
70
71
  "devDependencies": {
71
72
  "@arethetypeswrong/cli": "^0.18.3",
72
73
  "@types/node": "^20.14.0",
74
+ "@vitest/coverage-v8": "^4.1.9",
73
75
  "tsup": "^8.5.0",
74
76
  "typescript": "^5.5.0",
75
77
  "vitest": "^4.1.0"