@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 +23 -8
- package/dist/cli.cjs +94 -8
- package/dist/cli.js +94 -8
- package/dist/index.cjs +93 -7
- package/dist/index.d.cts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +93 -7
- package/package.json +4 -2
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
|
[](https://github.com/studiomeyer-io/skilldoctor/actions/workflows/ci.yml)
|
|
6
|
-
[](https://www.npmjs.com/package/skilldoctor)
|
|
7
|
-
[](https://www.npmjs.com/package/@studiomeyer-io/skilldoctor)
|
|
11
|
+
[](https://scorecard.dev/viewer/?uri=github.com/studiomeyer-io/skilldoctor)
|
|
12
|
+
[](./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 |
|
|
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 โ
|
|
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@
|
|
145
|
-
- uses: actions/setup-node@
|
|
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@
|
|
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:
|
|
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
|
|
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 (
|
|
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
|
|
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.
|
|
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:
|
|
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
|
|
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 (
|
|
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
|
|
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.
|
|
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:
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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 (
|
|
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
|
|
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.
|
|
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"
|