claudecode-linter 2.1.144 → 2.1.148
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/.claudecode-lint.defaults.yaml +14 -1
- package/README.md +24 -6
- package/contracts/agent-frontmatter.schema.json +147 -0
- package/contracts/command-frontmatter.schema.json +93 -0
- package/contracts/settings.schema.json +2212 -0
- package/contracts/skill-frontmatter.schema.json +182 -0
- package/dist/contracts.js +64 -1
- package/dist/discovery.js +26 -2
- package/dist/fixers/settings-json.js +7 -6
- package/dist/index.js +23 -7
- package/dist/linters/agent-md.js +43 -8
- package/dist/linters/claude-md.js +15 -3
- package/dist/linters/command-md.js +31 -5
- package/dist/linters/mcp-json.js +9 -2
- package/dist/linters/settings-json.js +316 -29
- package/dist/linters/skill-md.js +101 -11
- package/dist/plugin-schema.js +12 -0
- package/dist/utils/frontmatter-keys.js +38 -0
- package/package.json +7 -3
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import { SETTINGS_USER_FIELDS, SETTINGS_PROJECT_FIELDS, TOOLS } from "../contracts.js";
|
|
2
|
+
import { SETTINGS_USER_FIELDS, SETTINGS_PROJECT_FIELDS, TOOLS, PERMISSIONS_FIELDS, PERMISSION_MODES, SANDBOX_FIELDS, SANDBOX_NETWORK_FIELDS, SANDBOX_FILESYSTEM_FIELDS, } from "../contracts.js";
|
|
3
|
+
import { formatAjvError, loadSettingsSchema, summarizeErrors, } from "../plugin-schema.js";
|
|
3
4
|
import { isRuleEnabled, getRuleSeverity } from "../types.js";
|
|
4
5
|
const RULES = [
|
|
5
6
|
{ id: "settings-json/valid-json", defaultSeverity: "error" },
|
|
7
|
+
{ id: "settings-json/schema-valid", defaultSeverity: "error" },
|
|
6
8
|
{ id: "settings-json/scope-file-name", defaultSeverity: "error" },
|
|
7
9
|
{ id: "settings-json/scope-field", defaultSeverity: "warning" },
|
|
8
10
|
{ id: "settings-json/no-unknown-fields", defaultSeverity: "warning" },
|
|
9
11
|
{ id: "settings-json/permissions-object", defaultSeverity: "error" },
|
|
12
|
+
{ id: "settings-json/permissions-unknown-field", defaultSeverity: "warning" },
|
|
13
|
+
{ id: "settings-json/permissions-default-mode", defaultSeverity: "warning" },
|
|
14
|
+
{ id: "settings-json/permissions-disable-bypass", defaultSeverity: "warning" },
|
|
15
|
+
{ id: "settings-json/permissions-field-type", defaultSeverity: "warning" },
|
|
10
16
|
{ id: "settings-json/allow-array", defaultSeverity: "error" },
|
|
11
17
|
{ id: "settings-json/allow-known-tools", defaultSeverity: "warning" },
|
|
12
18
|
{ id: "settings-json/deny-array", defaultSeverity: "error" },
|
|
19
|
+
{ id: "settings-json/ask-array", defaultSeverity: "error" },
|
|
20
|
+
{ id: "settings-json/permission-rule-syntax", defaultSeverity: "error" },
|
|
21
|
+
{ id: "settings-json/permission-rule-pattern", defaultSeverity: "error" },
|
|
22
|
+
{ id: "settings-json/sandbox-object", defaultSeverity: "error" },
|
|
23
|
+
{ id: "settings-json/sandbox-unknown-field", defaultSeverity: "warning" },
|
|
24
|
+
{ id: "settings-json/sandbox-field-type", defaultSeverity: "warning" },
|
|
13
25
|
{ id: "settings-json/env-object", defaultSeverity: "error" },
|
|
14
26
|
{ id: "settings-json/env-string-values", defaultSeverity: "warning" },
|
|
15
27
|
{ id: "settings-json/plugins-object", defaultSeverity: "error" },
|
|
@@ -17,6 +29,154 @@ const RULES = [
|
|
|
17
29
|
{ id: "settings-json/plugins-format", defaultSeverity: "warning" },
|
|
18
30
|
{ id: "settings-json/skip-prompt-boolean", defaultSeverity: "error" },
|
|
19
31
|
];
|
|
32
|
+
// Expected type for each known sandbox sub-key, and for the nested
|
|
33
|
+
// network/filesystem objects. Mirrors Claude Code's Zod schema.
|
|
34
|
+
const SANDBOX_FIELD_TYPES = {
|
|
35
|
+
enabled: "boolean",
|
|
36
|
+
failIfUnavailable: "boolean",
|
|
37
|
+
autoAllowBashIfSandboxed: "boolean",
|
|
38
|
+
allowUnsandboxedCommands: "boolean",
|
|
39
|
+
enableWeakerNestedSandbox: "boolean",
|
|
40
|
+
enableWeakerNetworkIsolation: "boolean",
|
|
41
|
+
excludedCommands: "string-array",
|
|
42
|
+
network: "object",
|
|
43
|
+
filesystem: "object",
|
|
44
|
+
ripgrep: "object",
|
|
45
|
+
ignoreViolations: "object",
|
|
46
|
+
bwrapPath: "string",
|
|
47
|
+
};
|
|
48
|
+
const NETWORK_FIELD_TYPES = {
|
|
49
|
+
allowedDomains: "string-array",
|
|
50
|
+
deniedDomains: "string-array",
|
|
51
|
+
allowManagedDomainsOnly: "boolean",
|
|
52
|
+
allowUnixSockets: "string-array",
|
|
53
|
+
allowAllUnixSockets: "boolean",
|
|
54
|
+
allowLocalBinding: "boolean",
|
|
55
|
+
allowMachLookup: "string-array",
|
|
56
|
+
httpProxyPort: "number",
|
|
57
|
+
socksProxyPort: "number",
|
|
58
|
+
tlsTerminate: "object",
|
|
59
|
+
};
|
|
60
|
+
const FILESYSTEM_FIELD_TYPES = {
|
|
61
|
+
allowWrite: "string-array",
|
|
62
|
+
denyWrite: "string-array",
|
|
63
|
+
denyRead: "string-array",
|
|
64
|
+
allowRead: "string-array",
|
|
65
|
+
allowManagedReadPathsOnly: "boolean",
|
|
66
|
+
};
|
|
67
|
+
// sandbox.network.tlsTerminate — a small nested object of optional paths.
|
|
68
|
+
const TLS_TERMINATE_FIELD_TYPES = {
|
|
69
|
+
caCertPath: "string",
|
|
70
|
+
caKeyPath: "string",
|
|
71
|
+
};
|
|
72
|
+
// Tool classes that drive per-rule pattern validation (Claude Code's `Fx$`).
|
|
73
|
+
const FILE_PATTERN_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "NotebookRead", "NotebookEdit"]);
|
|
74
|
+
const BASH_PREFIX_TOOLS = new Set(["Bash"]);
|
|
75
|
+
function isStringArray(v) {
|
|
76
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
77
|
+
}
|
|
78
|
+
function isPlainObject(v) {
|
|
79
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
80
|
+
}
|
|
81
|
+
function typeMatches(expected, val) {
|
|
82
|
+
switch (expected) {
|
|
83
|
+
case "boolean": return typeof val === "boolean";
|
|
84
|
+
case "string": return typeof val === "string";
|
|
85
|
+
case "number": return typeof val === "number";
|
|
86
|
+
case "string-array": return isStringArray(val);
|
|
87
|
+
case "object": return isPlainObject(val);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function articleType(expected) {
|
|
91
|
+
switch (expected) {
|
|
92
|
+
case "boolean": return "a boolean";
|
|
93
|
+
case "string": return "a string";
|
|
94
|
+
case "number": return "a number";
|
|
95
|
+
case "string-array": return "an array of strings";
|
|
96
|
+
case "object": return "an object";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Lightweight permission-rule syntax check, mirroring the syntactic rejections
|
|
101
|
+
* of Claude Code's runtime rule validator (the per-tool pattern grammar is
|
|
102
|
+
* deliberately not reproduced). Returns an error description, or null if the
|
|
103
|
+
* rule is syntactically well-formed.
|
|
104
|
+
*/
|
|
105
|
+
function ruleSyntaxError(rule) {
|
|
106
|
+
if (rule.trim() === "")
|
|
107
|
+
return "permission rule cannot be empty";
|
|
108
|
+
const opens = (rule.match(/\(/g) ?? []).length;
|
|
109
|
+
const closes = (rule.match(/\)/g) ?? []).length;
|
|
110
|
+
if (opens !== closes)
|
|
111
|
+
return "mismatched parentheses";
|
|
112
|
+
if (/\(\s*\)/.test(rule)) {
|
|
113
|
+
const before = rule.slice(0, rule.indexOf("(")).trim();
|
|
114
|
+
return before === ""
|
|
115
|
+
? "empty parentheses with no tool name"
|
|
116
|
+
: `empty parentheses — use "${before}" alone, or specify a pattern`;
|
|
117
|
+
}
|
|
118
|
+
const parenIdx = rule.indexOf("(");
|
|
119
|
+
const toolName = (parenIdx === -1 ? rule : rule.slice(0, parenIdx)).trim();
|
|
120
|
+
if (toolName === "")
|
|
121
|
+
return "tool name cannot be empty";
|
|
122
|
+
if (rule.startsWith("mcp__")) {
|
|
123
|
+
return parenIdx === -1
|
|
124
|
+
? null
|
|
125
|
+
: "MCP rules do not support patterns in parentheses";
|
|
126
|
+
}
|
|
127
|
+
if (!toolName.includes("_") && toolName[0] !== toolName[0].toUpperCase()) {
|
|
128
|
+
return "tool names must start with an uppercase letter";
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Split a syntactically valid rule into its tool name and parenthesized
|
|
134
|
+
* pattern. `content` is null for a bare tool or a `()` / `(*)` wildcard.
|
|
135
|
+
*/
|
|
136
|
+
function parseRule(rule) {
|
|
137
|
+
const open = rule.indexOf("(");
|
|
138
|
+
if (open === -1)
|
|
139
|
+
return { toolName: rule.trim(), content: null };
|
|
140
|
+
const inner = rule.slice(open + 1, rule.lastIndexOf(")"));
|
|
141
|
+
return {
|
|
142
|
+
toolName: rule.slice(0, open).trim(),
|
|
143
|
+
content: inner === "" || inner === "*" ? null : inner,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Per-tool pattern validation, mirroring Claude Code's `Fx$` rule table:
|
|
148
|
+
* WebSearch/WebFetch custom validators, the Bash `:*` prefix marker, and the
|
|
149
|
+
* file-pattern tools that reject `:*`. Returns an error description or null.
|
|
150
|
+
*/
|
|
151
|
+
function ruleContentError(toolName, content) {
|
|
152
|
+
if (toolName === "WebSearch") {
|
|
153
|
+
if (content.includes("*") || content.includes("?")) {
|
|
154
|
+
return "WebSearch rules do not support wildcards (* or ?)";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (toolName === "WebFetch") {
|
|
158
|
+
if (content.includes("://") || content.startsWith("http")) {
|
|
159
|
+
return "WebFetch rules use \"domain:hostname\" format, not URLs";
|
|
160
|
+
}
|
|
161
|
+
if (!content.startsWith("domain:")) {
|
|
162
|
+
return "WebFetch rules must use the \"domain:\" prefix";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else if (BASH_PREFIX_TOOLS.has(toolName)) {
|
|
166
|
+
if (content.includes(":*") && !content.endsWith(":*")) {
|
|
167
|
+
return "the \":*\" prefix marker must be at the end of the pattern";
|
|
168
|
+
}
|
|
169
|
+
if (content === ":*") {
|
|
170
|
+
return "the command prefix before \":*\" cannot be empty";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else if (FILE_PATTERN_TOOLS.has(toolName)) {
|
|
174
|
+
if (content.includes(":*")) {
|
|
175
|
+
return "the \":*\" syntax is Bash-only — use glob patterns (* or **) for files";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
20
180
|
function findKeyPosition(content, key) {
|
|
21
181
|
const re = new RegExp(`"${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`);
|
|
22
182
|
const match = re.exec(content);
|
|
@@ -60,6 +220,28 @@ export const settingsJsonLinter = {
|
|
|
60
220
|
push(diag(config, filePath, "settings-json/valid-json", "error", "settings.json must be a JSON object"));
|
|
61
221
|
return diagnostics;
|
|
62
222
|
}
|
|
223
|
+
// schema-valid — defer to the JSON Schema auto-extracted from Claude Code's
|
|
224
|
+
// settings validator for the ~113 structurally-unchecked top-level fields.
|
|
225
|
+
// The hand-written rules below cover Claude-Code-specific advice the schema
|
|
226
|
+
// can't express (permission-rule syntax, scope semantics, …) with friendlier
|
|
227
|
+
// messages. The extracted schema is intentionally permissive at the top
|
|
228
|
+
// level (Claude Code uses .passthrough()), so unknown keys are NOT reported
|
|
229
|
+
// here — settings-json/no-unknown-fields handles those. Skipped silently if
|
|
230
|
+
// the schema bundle isn't shipped with this install.
|
|
231
|
+
if (isRuleEnabled(config, "settings-json/schema-valid")) {
|
|
232
|
+
const compiled = loadSettingsSchema();
|
|
233
|
+
if (compiled) {
|
|
234
|
+
const ok = compiled.validate(parsed);
|
|
235
|
+
if (!ok && compiled.validate.errors) {
|
|
236
|
+
const filtered = summarizeErrors(compiled.validate.errors);
|
|
237
|
+
for (const err of filtered) {
|
|
238
|
+
const firstSeg = err.instancePath.split("/").filter(Boolean)[0];
|
|
239
|
+
const p = firstSeg ? findKeyPosition(content, firstSeg) : undefined;
|
|
240
|
+
push(diag(config, filePath, "settings-json/schema-valid", "error", formatAjvError(err), p?.line, p?.column));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
63
245
|
// Scope-aware: settings.json (non-local) should only be at user level
|
|
64
246
|
if (!isLocal && scope && scope !== "user") {
|
|
65
247
|
push(diag(config, filePath, "settings-json/scope-file-name", "error", `"settings.json" should only exist at user level (~/.claude/). Use "settings.local.json" for project-level settings`));
|
|
@@ -78,46 +260,151 @@ export const settingsJsonLinter = {
|
|
|
78
260
|
}
|
|
79
261
|
}
|
|
80
262
|
}
|
|
263
|
+
// Validate the sub-keys of a nested object against a known-field set and a
|
|
264
|
+
// type map (used for sandbox.network and sandbox.filesystem).
|
|
265
|
+
const checkNested = (obj, prefix, known, types) => {
|
|
266
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
267
|
+
const kp = findKeyPosition(content, key);
|
|
268
|
+
if (!known.has(key)) {
|
|
269
|
+
push(diag(config, filePath, "settings-json/sandbox-unknown-field", "warning", `Unknown field "${prefix}.${key}"`, kp?.line, kp?.column));
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const expected = types[key];
|
|
273
|
+
if (expected && !typeMatches(expected, val)) {
|
|
274
|
+
push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", `"${prefix}.${key}" should be ${articleType(expected)}`, kp?.line, kp?.column));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
81
278
|
// permissions
|
|
82
279
|
if ("permissions" in parsed) {
|
|
83
280
|
const perms = parsed.permissions;
|
|
84
281
|
const pp = findKeyPosition(content, "permissions");
|
|
85
|
-
if (
|
|
282
|
+
if (!isPlainObject(perms)) {
|
|
86
283
|
push(diag(config, filePath, "settings-json/permissions-object", "error", "\"permissions\" must be an object", pp?.line, pp?.column));
|
|
87
284
|
}
|
|
88
285
|
else {
|
|
89
286
|
const p = perms;
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
push(diag(config, filePath, "settings-json/
|
|
287
|
+
// unknown permissions sub-keys
|
|
288
|
+
for (const key of Object.keys(p)) {
|
|
289
|
+
if (!PERMISSIONS_FIELDS.has(key)) {
|
|
290
|
+
const kp = findKeyPosition(content, key);
|
|
291
|
+
push(diag(config, filePath, "settings-json/permissions-unknown-field", "warning", `Unknown field "permissions.${key}"`, kp?.line, kp?.column));
|
|
95
292
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
293
|
+
}
|
|
294
|
+
// allow / deny / ask — each an array of permission-rule strings
|
|
295
|
+
const checkRuleArray = (name, arrayRuleId) => {
|
|
296
|
+
if (!(name in p))
|
|
297
|
+
return;
|
|
298
|
+
const kp = findKeyPosition(content, name);
|
|
299
|
+
if (!Array.isArray(p[name])) {
|
|
300
|
+
push(diag(config, filePath, arrayRuleId, "error", `"permissions.${name}" must be an array of strings`, kp?.line, kp?.column));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
for (const entry of p[name]) {
|
|
304
|
+
if (typeof entry !== "string") {
|
|
305
|
+
push(diag(config, filePath, arrayRuleId, "error", `"permissions.${name}" entries must be strings (got ${typeof entry})`, kp?.line, kp?.column));
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// Syntax first — a malformed rule string is rejected at runtime.
|
|
309
|
+
const syntaxErr = ruleSyntaxError(entry);
|
|
310
|
+
if (syntaxErr) {
|
|
311
|
+
push(diag(config, filePath, "settings-json/permission-rule-syntax", "error", `Invalid permission rule "${entry}" in permissions.${name}: ${syntaxErr}`, kp?.line, kp?.column));
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
// Per-tool pattern grammar (Bash :* placement, WebFetch domain:, …).
|
|
315
|
+
const { toolName, content: ruleContent } = parseRule(entry);
|
|
316
|
+
if (ruleContent !== null) {
|
|
317
|
+
const patternErr = ruleContentError(toolName, ruleContent);
|
|
318
|
+
if (patternErr) {
|
|
319
|
+
push(diag(config, filePath, "settings-json/permission-rule-pattern", "error", `Invalid permission rule "${entry}" in permissions.${name}: ${patternErr}`, kp?.line, kp?.column));
|
|
100
320
|
continue;
|
|
101
321
|
}
|
|
102
|
-
// Extract base tool name from scoped pattern like "Bash(cmd:*)"
|
|
103
|
-
const toolMatch = entry.match(/^([A-Za-z]+)(\(.*\))?$/);
|
|
104
|
-
if (toolMatch) {
|
|
105
|
-
const toolName = toolMatch[1];
|
|
106
|
-
if (!TOOLS.has(toolName)) {
|
|
107
|
-
// Allow MCP tool patterns (mcp__*)
|
|
108
|
-
if (!entry.startsWith("mcp__")) {
|
|
109
|
-
push(diag(config, filePath, "settings-json/allow-known-tools", "warning", `Unknown tool "${toolName}" in permissions.allow`, ap?.line, ap?.column));
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
322
|
}
|
|
323
|
+
// Rules are "Tool" or "Tool(specifier)". Warn on a base tool name
|
|
324
|
+
// Claude Code does not expose (mcp__* is dynamic).
|
|
325
|
+
const toolMatch = entry.match(/^([A-Za-z]+)(\(.*\))?$/);
|
|
326
|
+
if (toolMatch && !TOOLS.has(toolMatch[1]) && !entry.startsWith("mcp__")) {
|
|
327
|
+
push(diag(config, filePath, "settings-json/allow-known-tools", "warning", `Unknown tool "${toolMatch[1]}" in permissions.${name}`, kp?.line, kp?.column));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
checkRuleArray("allow", "settings-json/allow-array");
|
|
332
|
+
checkRuleArray("deny", "settings-json/deny-array");
|
|
333
|
+
checkRuleArray("ask", "settings-json/ask-array");
|
|
334
|
+
// defaultMode — must be a known permission mode
|
|
335
|
+
if ("defaultMode" in p) {
|
|
336
|
+
const mp = findKeyPosition(content, "defaultMode");
|
|
337
|
+
if (typeof p.defaultMode !== "string" || !PERMISSION_MODES.has(p.defaultMode)) {
|
|
338
|
+
push(diag(config, filePath, "settings-json/permissions-default-mode", "warning", `"permissions.defaultMode" should be one of: ${[...PERMISSION_MODES].join(", ")}`, mp?.line, mp?.column));
|
|
114
339
|
}
|
|
115
340
|
}
|
|
116
|
-
//
|
|
117
|
-
if ("
|
|
118
|
-
const dp = findKeyPosition(content, "
|
|
119
|
-
|
|
120
|
-
|
|
341
|
+
// disableBypassPermissionsMode — the only accepted value is "disable"
|
|
342
|
+
if ("disableBypassPermissionsMode" in p && p.disableBypassPermissionsMode !== "disable") {
|
|
343
|
+
const dp = findKeyPosition(content, "disableBypassPermissionsMode");
|
|
344
|
+
push(diag(config, filePath, "settings-json/permissions-disable-bypass", "warning", "\"permissions.disableBypassPermissionsMode\" only accepts the string \"disable\"", dp?.line, dp?.column));
|
|
345
|
+
}
|
|
346
|
+
// additionalDirectories — array of strings
|
|
347
|
+
if ("additionalDirectories" in p && !isStringArray(p.additionalDirectories)) {
|
|
348
|
+
const ap = findKeyPosition(content, "additionalDirectories");
|
|
349
|
+
push(diag(config, filePath, "settings-json/permissions-field-type", "warning", "\"permissions.additionalDirectories\" should be an array of strings", ap?.line, ap?.column));
|
|
350
|
+
}
|
|
351
|
+
// disableAutoMode — boolean
|
|
352
|
+
if ("disableAutoMode" in p && typeof p.disableAutoMode !== "boolean") {
|
|
353
|
+
const ap = findKeyPosition(content, "disableAutoMode");
|
|
354
|
+
push(diag(config, filePath, "settings-json/permissions-field-type", "warning", "\"permissions.disableAutoMode\" should be a boolean", ap?.line, ap?.column));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// sandbox
|
|
359
|
+
if ("sandbox" in parsed) {
|
|
360
|
+
const sandbox = parsed.sandbox;
|
|
361
|
+
const sp = findKeyPosition(content, "sandbox");
|
|
362
|
+
if (!isPlainObject(sandbox)) {
|
|
363
|
+
push(diag(config, filePath, "settings-json/sandbox-object", "error", "\"sandbox\" must be an object", sp?.line, sp?.column));
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
for (const [key, val] of Object.entries(sandbox)) {
|
|
367
|
+
const kp = findKeyPosition(content, key);
|
|
368
|
+
if (!SANDBOX_FIELDS.has(key)) {
|
|
369
|
+
push(diag(config, filePath, "settings-json/sandbox-unknown-field", "warning", `Unknown field "sandbox.${key}"`, kp?.line, kp?.column));
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const expected = SANDBOX_FIELD_TYPES[key];
|
|
373
|
+
if (expected && !typeMatches(expected, val)) {
|
|
374
|
+
push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", `"sandbox.${key}" should be ${articleType(expected)}`, kp?.line, kp?.column));
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
// Nested object validation
|
|
378
|
+
if (key === "network" && isPlainObject(val)) {
|
|
379
|
+
checkNested(val, "sandbox.network", SANDBOX_NETWORK_FIELDS, NETWORK_FIELD_TYPES);
|
|
380
|
+
if (isPlainObject(val.tlsTerminate)) {
|
|
381
|
+
checkNested(val.tlsTerminate, "sandbox.network.tlsTerminate", new Set(Object.keys(TLS_TERMINATE_FIELD_TYPES)), TLS_TERMINATE_FIELD_TYPES);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
else if (key === "filesystem" && isPlainObject(val)) {
|
|
385
|
+
checkNested(val, "sandbox.filesystem", SANDBOX_FILESYSTEM_FIELDS, FILESYSTEM_FIELD_TYPES);
|
|
386
|
+
}
|
|
387
|
+
else if (key === "ripgrep" && isPlainObject(val)) {
|
|
388
|
+
for (const rk of Object.keys(val)) {
|
|
389
|
+
if (rk !== "command" && rk !== "args") {
|
|
390
|
+
const rp = findKeyPosition(content, rk);
|
|
391
|
+
push(diag(config, filePath, "settings-json/sandbox-unknown-field", "warning", `Unknown field "sandbox.ripgrep.${rk}"`, rp?.line, rp?.column));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (typeof val.command !== "string") {
|
|
395
|
+
push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", "\"sandbox.ripgrep.command\" is required and must be a string", kp?.line, kp?.column));
|
|
396
|
+
}
|
|
397
|
+
if ("args" in val && !isStringArray(val.args)) {
|
|
398
|
+
push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", "\"sandbox.ripgrep.args\" should be an array of strings", kp?.line, kp?.column));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
else if (key === "ignoreViolations" && isPlainObject(val)) {
|
|
402
|
+
for (const [vk, vv] of Object.entries(val)) {
|
|
403
|
+
if (!isStringArray(vv)) {
|
|
404
|
+
const vp = findKeyPosition(content, vk);
|
|
405
|
+
push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", `"sandbox.ignoreViolations.${vk}" should be an array of strings`, vp?.line, vp?.column));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
121
408
|
}
|
|
122
409
|
}
|
|
123
410
|
}
|
|
@@ -126,7 +413,7 @@ export const settingsJsonLinter = {
|
|
|
126
413
|
if ("env" in parsed) {
|
|
127
414
|
const env = parsed.env;
|
|
128
415
|
const envp = findKeyPosition(content, "env");
|
|
129
|
-
if (
|
|
416
|
+
if (!isPlainObject(env)) {
|
|
130
417
|
push(diag(config, filePath, "settings-json/env-object", "error", "\"env\" must be an object of string key-value pairs", envp?.line, envp?.column));
|
|
131
418
|
}
|
|
132
419
|
else {
|
|
@@ -142,7 +429,7 @@ export const settingsJsonLinter = {
|
|
|
142
429
|
if ("enabledPlugins" in parsed) {
|
|
143
430
|
const plugins = parsed.enabledPlugins;
|
|
144
431
|
const plp = findKeyPosition(content, "enabledPlugins");
|
|
145
|
-
if (
|
|
432
|
+
if (!isPlainObject(plugins)) {
|
|
146
433
|
push(diag(config, filePath, "settings-json/plugins-object", "error", "\"enabledPlugins\" must be an object", plp?.line, plp?.column));
|
|
147
434
|
}
|
|
148
435
|
else {
|
package/dist/linters/skill-md.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { formatAjvError, loadSkillFrontmatterSchema, summarizeErrors, } from "../plugin-schema.js";
|
|
2
2
|
import { isRuleEnabled, getRuleSeverity } from "../types.js";
|
|
3
3
|
import { parseFrontmatter } from "../utils/frontmatter.js";
|
|
4
|
+
import { artifactLabel, classifyUnknownFrontmatterKey, } from "../utils/frontmatter-keys.js";
|
|
4
5
|
import { isKebabCase } from "../utils/kebab-case.js";
|
|
5
6
|
const RULES = [
|
|
6
7
|
{ id: "skill-md/valid-frontmatter", defaultSeverity: "error" },
|
|
8
|
+
{ id: "skill-md/schema-valid", defaultSeverity: "error" },
|
|
7
9
|
{ id: "skill-md/name-required", defaultSeverity: "error" },
|
|
8
10
|
{ id: "skill-md/name-kebab-case", defaultSeverity: "error" },
|
|
9
11
|
{ id: "skill-md/name-max-length", defaultSeverity: "error" },
|
|
@@ -11,10 +13,72 @@ const RULES = [
|
|
|
11
13
|
{ id: "skill-md/description-max-length", defaultSeverity: "error" },
|
|
12
14
|
{ id: "skill-md/description-no-angle-brackets", defaultSeverity: "error" },
|
|
13
15
|
{ id: "skill-md/description-trigger-phrases", defaultSeverity: "warning" },
|
|
14
|
-
{ id: "skill-md/no-unknown-frontmatter", defaultSeverity: "
|
|
15
|
-
{ id: "skill-md/body-word-count", defaultSeverity: "
|
|
16
|
+
{ id: "skill-md/no-unknown-frontmatter", defaultSeverity: "info" },
|
|
17
|
+
{ id: "skill-md/body-word-count", defaultSeverity: "info" },
|
|
16
18
|
{ id: "skill-md/body-has-headers", defaultSeverity: "info" },
|
|
17
19
|
];
|
|
20
|
+
/**
|
|
21
|
+
* Decide whether a SKILL.md `description` communicates *when* the skill
|
|
22
|
+
* applies. A description that gives Claude Code any concrete applicability
|
|
23
|
+
* cue is fine — only purely declarative descriptions (a flat statement of
|
|
24
|
+
* what the skill is, with no routing signal at all) should be flagged.
|
|
25
|
+
*
|
|
26
|
+
* Recognized signals, in rough order of how common they are in real skills:
|
|
27
|
+
* - explicit trigger sections: "Trigger on …", "Trigger when/whenever …",
|
|
28
|
+
* "Triggers: …", "TRIGGER when:", routing-marker comments
|
|
29
|
+
* - "use when / use for / use whenever / use this skill when / use to"
|
|
30
|
+
* - "should be used when …", "applies when …", "invoke when …"
|
|
31
|
+
* - "when the user asks/wants/mentions/needs …" and bare "when <verb>ing"
|
|
32
|
+
* - imperative-verb openers ("Diagnose …", "Deploy …", "Author …") — an
|
|
33
|
+
* imperative description states the task the skill performs, which is
|
|
34
|
+
* itself an applicability cue
|
|
35
|
+
* - gerund openers ("Building …", "Searching …")
|
|
36
|
+
*/
|
|
37
|
+
function hasTriggerSignal(desc) {
|
|
38
|
+
const d = desc.trim();
|
|
39
|
+
if (!d)
|
|
40
|
+
return false;
|
|
41
|
+
// Routing-marker comments authors drop in to delimit trigger lists.
|
|
42
|
+
if (/BEGIN ROUTING TRIGGERS|ROUTING TRIGGERS/i.test(d))
|
|
43
|
+
return true;
|
|
44
|
+
// Explicit trigger / use-when phrasing anywhere in the description.
|
|
45
|
+
// "Trigger" only counts as a routing cue when used as a directive verb
|
|
46
|
+
// ("Trigger on …", "Trigger when/whenever/if …", "Triggers: …", "TRIGGER
|
|
47
|
+
// when:") — not when "trigger" merely appears as a noun (e.g. the phrase
|
|
48
|
+
// "no trigger phrases").
|
|
49
|
+
const triggerPhrases = /\btrigger(s)?\b\s*(on|when|whenever|if|upon|for)\b|\btriggers?\s*:|\buse (this skill |it )?(when|whenever|for|to|on|if)\b|\bshould be used (when|whenever|for|to|if)\b|\b(applies|invoke|reach for|call this|run this|use this) (when|whenever|if|to|for)\b|\bwhen (the user|you|asked|working|debugging|diagnosing|investigating|reviewing|writing|building|creating|editing|setting up|deploying|the task|a request|a question|you need|there|something|anything)\b|\bwhen [a-z]+ing\b|\bfor (debugging|diagnosing|tasks|requests|questions|when|any)\b/i;
|
|
50
|
+
if (triggerPhrases.test(d))
|
|
51
|
+
return true;
|
|
52
|
+
// First "sentence-ish" chunk — used to detect imperative / gerund openers.
|
|
53
|
+
const opener = d.split(/[.\n—:(]/, 1)[0].trim();
|
|
54
|
+
const firstWord = opener.split(/\s+/, 1)[0].replace(/[^A-Za-z-]/g, "");
|
|
55
|
+
// Gerund opener ("Building a …", "Searching the …").
|
|
56
|
+
if (/^[A-Z][a-z]+ing\b/.test(opener))
|
|
57
|
+
return true;
|
|
58
|
+
// Imperative-verb opener. A skill description that starts with a bare
|
|
59
|
+
// verb is describing the action the skill performs, which signals when
|
|
60
|
+
// to route to it. Accept a curated set of common imperative verbs so we
|
|
61
|
+
// don't accidentally pass a noun-opener declarative description.
|
|
62
|
+
const imperativeVerbs = new Set([
|
|
63
|
+
"add", "analyze", "audit", "author", "automate", "build", "check",
|
|
64
|
+
"clean", "configure", "convert", "create", "debug", "delete", "deploy",
|
|
65
|
+
"diagnose", "drive", "edit", "enforce", "ensure", "estimate",
|
|
66
|
+
"evaluate", "execute", "extract", "fetch", "find", "fix", "format",
|
|
67
|
+
"generate", "guide", "handle", "help", "identify", "implement",
|
|
68
|
+
"initialize", "inspect", "install", "investigate", "lint", "list",
|
|
69
|
+
"manage", "migrate", "mint", "monitor", "open", "optimize", "parse",
|
|
70
|
+
"perform", "pin", "plan", "prepare", "preview", "produce", "profile",
|
|
71
|
+
"publish", "pull", "push", "query", "reclaim", "refactor", "render",
|
|
72
|
+
"report", "resolve", "restore", "review", "rollout", "run", "scaffold",
|
|
73
|
+
"scan", "search", "set", "ship", "size", "summarize", "sweep", "sync",
|
|
74
|
+
"test", "trace", "track", "transform", "transition", "translate",
|
|
75
|
+
"troubleshoot", "tune", "update", "upgrade", "validate", "verify",
|
|
76
|
+
"watch", "write",
|
|
77
|
+
]);
|
|
78
|
+
if (imperativeVerbs.has(firstWord.toLowerCase()))
|
|
79
|
+
return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
18
82
|
function diag(config, filePath, ruleId, defaultSeverity, message, line, column) {
|
|
19
83
|
if (!isRuleEnabled(config, ruleId))
|
|
20
84
|
return null;
|
|
@@ -38,6 +102,25 @@ export const skillMdLinter = {
|
|
|
38
102
|
push(diag(config, filePath, "skill-md/valid-frontmatter", "error", fm.error ?? "Invalid frontmatter"));
|
|
39
103
|
return diagnostics;
|
|
40
104
|
}
|
|
105
|
+
// schema-valid — structural validation against the JSON Schema
|
|
106
|
+
// auto-extracted from Claude Code's skill frontmatter Zod validator. The
|
|
107
|
+
// hand-written rules below add Claude-Code-specific advice the schema
|
|
108
|
+
// can't express (kebab-case names, trigger phrasing, …). The extracted
|
|
109
|
+
// schema is intentionally permissive about unknown frontmatter keys
|
|
110
|
+
// (Claude Code still loads the skill), so those are NOT reported here —
|
|
111
|
+
// skill-md/no-unknown-frontmatter handles them. Skipped silently if the
|
|
112
|
+
// schema bundle isn't shipped with this install.
|
|
113
|
+
if (isRuleEnabled(config, "skill-md/schema-valid")) {
|
|
114
|
+
const compiled = loadSkillFrontmatterSchema();
|
|
115
|
+
if (compiled) {
|
|
116
|
+
const ok = compiled.validate(fm.data);
|
|
117
|
+
if (!ok && compiled.validate.errors) {
|
|
118
|
+
for (const err of summarizeErrors(compiled.validate.errors)) {
|
|
119
|
+
push(diag(config, filePath, "skill-md/schema-valid", "error", formatAjvError(err)));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
41
124
|
// name
|
|
42
125
|
if (!("name" in fm.data) || typeof fm.data.name !== "string") {
|
|
43
126
|
push(diag(config, filePath, "skill-md/name-required", "error", "\"name\" is required in frontmatter"));
|
|
@@ -63,25 +146,32 @@ export const skillMdLinter = {
|
|
|
63
146
|
if (/<|>/.test(desc)) {
|
|
64
147
|
push(diag(config, filePath, "skill-md/description-no-angle-brackets", "error", "\"description\" must not contain angle brackets (< or >)"));
|
|
65
148
|
}
|
|
66
|
-
if (
|
|
67
|
-
push(diag(config, filePath, "skill-md/description-trigger-phrases", "warning", "Description
|
|
149
|
+
if (!hasTriggerSignal(desc)) {
|
|
150
|
+
push(diag(config, filePath, "skill-md/description-trigger-phrases", "warning", "Description has no applicability signal — say when the skill applies " +
|
|
151
|
+
"(e.g., \"Use when…\", \"Trigger on…\", \"when the user asks to…\", or an imperative verb)"));
|
|
68
152
|
}
|
|
69
153
|
}
|
|
70
|
-
//
|
|
154
|
+
// Frontmatter keys: only flag cross-artifact misplacement. A key that is
|
|
155
|
+
// valid for a *different* markdown artifact (e.g. agent's "effort" on a
|
|
156
|
+
// skill) gets an info; a key valid for no artifact stays silent.
|
|
71
157
|
for (const key of Object.keys(fm.data)) {
|
|
72
|
-
|
|
73
|
-
|
|
158
|
+
const cls = classifyUnknownFrontmatterKey(key, "skill");
|
|
159
|
+
if (cls?.kind === "owned-by-other" && cls.owner) {
|
|
160
|
+
push(diag(config, filePath, "skill-md/no-unknown-frontmatter", "info", `"${key}" is ${artifactLabel(cls.owner)} frontmatter — Claude Code ignores it on a skill`));
|
|
74
161
|
}
|
|
75
162
|
}
|
|
76
163
|
// body checks
|
|
77
164
|
const body = fm.body.trim();
|
|
78
165
|
if (body) {
|
|
166
|
+
// Word-count is a soft style hint: a concise, well-scoped skill body is
|
|
167
|
+
// legitimate. Only flag genuinely thin bodies (< 150 words) or very
|
|
168
|
+
// large ones (> 5000) that likely belong split into references/.
|
|
79
169
|
const words = body.split(/\s+/).length;
|
|
80
|
-
if (words <
|
|
81
|
-
push(diag(config, filePath, "skill-md/body-word-count", "
|
|
170
|
+
if (words < 150) {
|
|
171
|
+
push(diag(config, filePath, "skill-md/body-word-count", "info", `Body has ${words} words — consider adding more detail (recommended: 150-5000)`, fm.bodyStartLine));
|
|
82
172
|
}
|
|
83
173
|
else if (words > 5000) {
|
|
84
|
-
push(diag(config, filePath, "skill-md/body-word-count", "
|
|
174
|
+
push(diag(config, filePath, "skill-md/body-word-count", "info", `Body has ${words} words — consider moving detail to references/ (recommended: 150-5000)`, fm.bodyStartLine));
|
|
85
175
|
}
|
|
86
176
|
if (!/^##\s/m.test(body)) {
|
|
87
177
|
push(diag(config, filePath, "skill-md/body-has-headers", "info", "Body should use H2 (##) sections for organization", fm.bodyStartLine));
|
package/dist/plugin-schema.js
CHANGED
|
@@ -62,6 +62,18 @@ export function loadLspSchema() {
|
|
|
62
62
|
export function loadMonitorsSchema() {
|
|
63
63
|
return loadCompiledSchema("monitors.schema.json");
|
|
64
64
|
}
|
|
65
|
+
export function loadSettingsSchema() {
|
|
66
|
+
return loadCompiledSchema("settings.schema.json");
|
|
67
|
+
}
|
|
68
|
+
export function loadSkillFrontmatterSchema() {
|
|
69
|
+
return loadCompiledSchema("skill-frontmatter.schema.json");
|
|
70
|
+
}
|
|
71
|
+
export function loadAgentFrontmatterSchema() {
|
|
72
|
+
return loadCompiledSchema("agent-frontmatter.schema.json");
|
|
73
|
+
}
|
|
74
|
+
export function loadCommandFrontmatterSchema() {
|
|
75
|
+
return loadCompiledSchema("command-frontmatter.schema.json");
|
|
76
|
+
}
|
|
65
77
|
export function loadPluginSchema() {
|
|
66
78
|
if (cached)
|
|
67
79
|
return cached;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { SKILL_FRONTMATTER, AGENT_FRONTMATTER, COMMAND_FRONTMATTER, } from "../contracts.js";
|
|
2
|
+
const KEY_SETS = {
|
|
3
|
+
skill: SKILL_FRONTMATTER,
|
|
4
|
+
agent: AGENT_FRONTMATTER,
|
|
5
|
+
command: COMMAND_FRONTMATTER,
|
|
6
|
+
};
|
|
7
|
+
const ARTIFACT_LABEL = {
|
|
8
|
+
skill: "skill",
|
|
9
|
+
agent: "agent",
|
|
10
|
+
command: "command",
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Classify `key` relative to `self` (the artifact being linted).
|
|
14
|
+
*
|
|
15
|
+
* `extraKnown` lets a caller treat a few extra keys as known-for-self (some
|
|
16
|
+
* linters historically accept hyphenated aliases the contract set lists in a
|
|
17
|
+
* canonicalized form, e.g. `allowed-tools` / `argument-hint`).
|
|
18
|
+
*/
|
|
19
|
+
export function classifyUnknownFrontmatterKey(key, self, extraKnown = new Set()) {
|
|
20
|
+
// Known for the current artifact — not unknown at all.
|
|
21
|
+
if (KEY_SETS[self].has(key) || extraKnown.has(key))
|
|
22
|
+
return null;
|
|
23
|
+
// Valid for some *other* markdown artifact → misplacement.
|
|
24
|
+
for (const kind of ["skill", "agent", "command"]) {
|
|
25
|
+
if (kind === self)
|
|
26
|
+
continue;
|
|
27
|
+
if (KEY_SETS[kind].has(key)) {
|
|
28
|
+
return { kind: "owned-by-other", owner: kind };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Valid nowhere → genuinely unfamiliar; callers stay silent.
|
|
32
|
+
return { kind: "unknown-everywhere" };
|
|
33
|
+
}
|
|
34
|
+
/** Human-readable label for an artifact kind ("skill", "agent", "command"). */
|
|
35
|
+
export function artifactLabel(kind) {
|
|
36
|
+
return ARTIFACT_LABEL[kind];
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=frontmatter-keys.js.map
|