agentsafe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +5 -0
- package/.github/workflows/ci.yml +34 -0
- package/CHANGELOG.md +13 -0
- package/CONTRIBUTING.md +23 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/SECURITY.md +22 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +77 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/rules/report.d.ts +4 -0
- package/dist/rules/report.js +71 -0
- package/dist/rules/report.js.map +1 -0
- package/dist/rules/scan.d.ts +2 -0
- package/dist/rules/scan.js +459 -0
- package/dist/rules/scan.js.map +1 -0
- package/dist/rules/types.d.ts +27 -0
- package/dist/rules/types.js +2 -0
- package/dist/rules/types.js.map +1 -0
- package/examples/safe-rules/.cursor/rules/typescript.mdc +15 -0
- package/examples/safe-rules/.cursorrules +1 -0
- package/examples/safe-rules/AGENTS.md +3 -0
- package/examples/safe-rules/CLAUDE.md +3 -0
- package/examples/safe-rules/GEMINI.md +3 -0
- package/examples/unsafe-rules/.cursor/rules/always-1.mdc +6 -0
- package/examples/unsafe-rules/.cursor/rules/always-2.mdc +6 -0
- package/examples/unsafe-rules/.cursor/rules/always-3.mdc +6 -0
- package/examples/unsafe-rules/.cursor/rules/always-4.mdc +6 -0
- package/examples/unsafe-rules/.cursor/rules/always-5.mdc +6 -0
- package/examples/unsafe-rules/.cursor/rules/always-6.mdc +6 -0
- package/examples/unsafe-rules/.cursor/rules/duplicate-a.mdc +6 -0
- package/examples/unsafe-rules/.cursor/rules/duplicate-b.mdc +7 -0
- package/examples/unsafe-rules/.cursor/rules/empty.mdc +5 -0
- package/examples/unsafe-rules/.cursor/rules/invalid-fields.mdc +7 -0
- package/examples/unsafe-rules/.cursor/rules/invalid-yaml.mdc +6 -0
- package/examples/unsafe-rules/.cursor/rules/no-frontmatter.mdc +1 -0
- package/examples/unsafe-rules/.cursorrules +1 -0
- package/examples/unsafe-rules/AGENTS.md +4 -0
- package/examples/unsafe-rules/CLAUDE.md +13 -0
- package/examples/unsafe-rules/GEMINI.md +3 -0
- package/package.json +26 -0
- package/src/cli.ts +104 -0
- package/src/index.ts +5 -0
- package/src/rules/report.ts +83 -0
- package/src/rules/scan.ts +544 -0
- package/src/rules/types.ts +36 -0
- package/test/scanner.test.mjs +136 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import { lstat, readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve, join, relative, sep } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
|
|
5
|
+
import type { Finding, ScanReport } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const ROOT_RULE_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", ".cursorrules"];
|
|
8
|
+
const MAX_ALWAYS_APPLY_LINES = 500;
|
|
9
|
+
const MAX_ALWAYS_APPLY_RULES = 5;
|
|
10
|
+
|
|
11
|
+
interface ParsedRule {
|
|
12
|
+
path: string;
|
|
13
|
+
content: string;
|
|
14
|
+
body: string;
|
|
15
|
+
bodyStartLine: number;
|
|
16
|
+
globs: string[];
|
|
17
|
+
alwaysApply: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PatternDefinition {
|
|
21
|
+
code: string;
|
|
22
|
+
message: string;
|
|
23
|
+
pattern: RegExp;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const suspiciousPatterns: PatternDefinition[] = [
|
|
27
|
+
{
|
|
28
|
+
code: "ignore-previous-instructions",
|
|
29
|
+
message: "Instruction asks to ignore previous instructions.",
|
|
30
|
+
pattern: /\bignore\s+(?:all\s+)?previous\s+instructions\b/i,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
code: "reveal-secrets",
|
|
34
|
+
message: "Instruction asks to reveal secrets.",
|
|
35
|
+
pattern: /\breveal\s+(?:the\s+)?secrets?\b/i,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
code: "read-dot-env",
|
|
39
|
+
message: "Instruction asks to read a .env file.",
|
|
40
|
+
pattern: /\bread\s+(?:the\s+)?(?:file\s+)?\.env\b/i,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
code: "print-environment-variables",
|
|
44
|
+
message: "Instruction asks to print environment variables.",
|
|
45
|
+
pattern: /\bprint\s+(?:all\s+)?environment\s+variables\b/i,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
code: "send-tokens",
|
|
49
|
+
message: "Instruction asks to send tokens.",
|
|
50
|
+
pattern: /\bsend\s+(?:the\s+)?tokens?\b/i,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
code: "exfiltrate",
|
|
54
|
+
message: "Instruction contains an exfiltration request.",
|
|
55
|
+
pattern: /\bexfiltrat(?:e|es|ed|ing|ion)\b/i,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
code: "disable-safety",
|
|
59
|
+
message: "Instruction asks to disable safety controls.",
|
|
60
|
+
pattern: /\bdisable\s+(?:all\s+)?safety\b/i,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const secretPatterns: PatternDefinition[] = [
|
|
65
|
+
{
|
|
66
|
+
code: "anthropic-key",
|
|
67
|
+
message: "Possible Anthropic API key detected; value omitted.",
|
|
68
|
+
pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
code: "openai-key",
|
|
72
|
+
message: "Possible OpenAI API key detected; value omitted.",
|
|
73
|
+
pattern: /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}\b/g,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
code: "github-token",
|
|
77
|
+
message: "Possible GitHub token detected; value omitted.",
|
|
78
|
+
pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
code: "aws-access-key",
|
|
82
|
+
message: "Possible AWS access key ID detected; value omitted.",
|
|
83
|
+
pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
code: "generic-api-key",
|
|
87
|
+
message: "Possible API key or token assignment detected; value omitted.",
|
|
88
|
+
pattern:
|
|
89
|
+
/\b(?:api[\s_-]?key|access[\s_-]?token|auth[\s_-]?token|token|secret)\b\s*[:=]\s*["']?[A-Za-z0-9][A-Za-z0-9._/-]{15,}/gi,
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const vagueInstructionPattern =
|
|
94
|
+
/\b(?:write clean code|follow best practices|use meaningful (?:variable )?names|make (?:the )?code readable|write maintainable code|keep (?:the )?code simple)\b/i;
|
|
95
|
+
const concreteExamplePattern =
|
|
96
|
+
/(?:^|\n)\s*#{1,6}\s+examples?\b|\b(?:for example|e\.g\.)\b|```[\s\S]*?```/i;
|
|
97
|
+
|
|
98
|
+
export async function scanRules(rootInput: string): Promise<ScanReport> {
|
|
99
|
+
const root = resolve(rootInput);
|
|
100
|
+
await assertDirectory(root, rootInput);
|
|
101
|
+
|
|
102
|
+
const absoluteFiles = await discoverRuleFiles(root);
|
|
103
|
+
const findings: Finding[] = [];
|
|
104
|
+
const rules: ParsedRule[] = [];
|
|
105
|
+
|
|
106
|
+
for (const absolutePath of absoluteFiles) {
|
|
107
|
+
const relativePath = toReportPath(relative(root, absolutePath));
|
|
108
|
+
let content: string;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
content = stripBom(await readFile(absolutePath, "utf8"));
|
|
112
|
+
} catch {
|
|
113
|
+
throw new Error(`Unable to read rule file: ${relativePath}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const parsed = absolutePath.toLowerCase().endsWith(".mdc")
|
|
117
|
+
? parseMdc(relativePath, content, findings)
|
|
118
|
+
: {
|
|
119
|
+
path: relativePath,
|
|
120
|
+
content,
|
|
121
|
+
body: content,
|
|
122
|
+
bodyStartLine: 1,
|
|
123
|
+
globs: [],
|
|
124
|
+
alwaysApply: false,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
rules.push(parsed);
|
|
128
|
+
inspectBody(parsed, findings);
|
|
129
|
+
inspectSuspiciousInstructions(parsed, findings);
|
|
130
|
+
inspectSecrets(parsed, findings);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
inspectRuleSet(rules, findings);
|
|
134
|
+
findings.sort(compareFindings);
|
|
135
|
+
|
|
136
|
+
const high = findings.filter((finding) => finding.severity === "high").length;
|
|
137
|
+
const warnings = findings.length - high;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
schemaVersion: 1,
|
|
141
|
+
tool: { name: "AgentSafe", version: "0.1.0" },
|
|
142
|
+
target: ".",
|
|
143
|
+
files: rules.map((rule) => rule.path),
|
|
144
|
+
findings,
|
|
145
|
+
summary: {
|
|
146
|
+
files: rules.length,
|
|
147
|
+
high,
|
|
148
|
+
warnings,
|
|
149
|
+
total: findings.length,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function assertDirectory(root: string, input: string): Promise<void> {
|
|
155
|
+
try {
|
|
156
|
+
const stats = await lstat(root);
|
|
157
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
158
|
+
throw new Error();
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
throw new Error(`Scan target is not a directory: ${input}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function discoverRuleFiles(root: string): Promise<string[]> {
|
|
166
|
+
const files: string[] = [];
|
|
167
|
+
|
|
168
|
+
for (const fileName of ROOT_RULE_FILES) {
|
|
169
|
+
const candidate = join(root, fileName);
|
|
170
|
+
if (await isRegularFile(candidate)) {
|
|
171
|
+
files.push(candidate);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const cursorRulesDirectory = join(root, ".cursor", "rules");
|
|
176
|
+
try {
|
|
177
|
+
const entries = await readdir(cursorRulesDirectory, { withFileTypes: true });
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
if (entry.isFile() && !entry.isSymbolicLink() && entry.name.toLowerCase().endsWith(".mdc")) {
|
|
180
|
+
files.push(join(cursorRulesDirectory, entry.name));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (!isMissingPathError(error)) {
|
|
185
|
+
throw new Error("Unable to read .cursor/rules directory.");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return files.sort((left, right) => compareText(toReportPath(left), toReportPath(right)));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function isRegularFile(path: string): Promise<boolean> {
|
|
193
|
+
try {
|
|
194
|
+
const stats = await lstat(path);
|
|
195
|
+
return stats.isFile() && !stats.isSymbolicLink();
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (isMissingPathError(error)) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isMissingPathError(error: unknown): boolean {
|
|
205
|
+
return (
|
|
206
|
+
typeof error === "object" &&
|
|
207
|
+
error !== null &&
|
|
208
|
+
"code" in error &&
|
|
209
|
+
(error as { code?: unknown }).code === "ENOENT"
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseMdc(path: string, content: string, findings: Finding[]): ParsedRule {
|
|
214
|
+
const lines = content.split(/\r?\n/);
|
|
215
|
+
const emptyResult = {
|
|
216
|
+
path,
|
|
217
|
+
content,
|
|
218
|
+
body: content,
|
|
219
|
+
bodyStartLine: 1,
|
|
220
|
+
globs: [],
|
|
221
|
+
alwaysApply: false,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
if (lines[0]?.trim() !== "---") {
|
|
225
|
+
findings.push({
|
|
226
|
+
code: "missing-frontmatter",
|
|
227
|
+
category: "frontmatter",
|
|
228
|
+
severity: "warning",
|
|
229
|
+
message: "Cursor rule has no YAML frontmatter.",
|
|
230
|
+
path,
|
|
231
|
+
line: 1,
|
|
232
|
+
});
|
|
233
|
+
return emptyResult;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const closingIndex = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
237
|
+
if (closingIndex === -1) {
|
|
238
|
+
findings.push({
|
|
239
|
+
code: "invalid-frontmatter",
|
|
240
|
+
category: "frontmatter",
|
|
241
|
+
severity: "warning",
|
|
242
|
+
message: "YAML frontmatter has no closing delimiter.",
|
|
243
|
+
path,
|
|
244
|
+
line: 1,
|
|
245
|
+
});
|
|
246
|
+
return { ...emptyResult, body: "" };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const frontmatterSource = lines.slice(1, closingIndex).join("\n");
|
|
250
|
+
const body = lines.slice(closingIndex + 1).join("\n");
|
|
251
|
+
let frontmatter: unknown;
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
frontmatter = parse(frontmatterSource, { schema: "core" });
|
|
255
|
+
} catch {
|
|
256
|
+
findings.push({
|
|
257
|
+
code: "invalid-frontmatter",
|
|
258
|
+
category: "frontmatter",
|
|
259
|
+
severity: "warning",
|
|
260
|
+
message: "YAML frontmatter could not be parsed.",
|
|
261
|
+
path,
|
|
262
|
+
line: 1,
|
|
263
|
+
});
|
|
264
|
+
return { ...emptyResult, body };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!isRecord(frontmatter)) {
|
|
268
|
+
findings.push({
|
|
269
|
+
code: "invalid-frontmatter",
|
|
270
|
+
category: "frontmatter",
|
|
271
|
+
severity: "warning",
|
|
272
|
+
message: "YAML frontmatter must be a mapping.",
|
|
273
|
+
path,
|
|
274
|
+
line: 1,
|
|
275
|
+
});
|
|
276
|
+
return { ...emptyResult, body };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
validateOptionalStringField(frontmatter, "description", path, lines, closingIndex, findings);
|
|
280
|
+
|
|
281
|
+
let globs: string[] = [];
|
|
282
|
+
if (!("globs" in frontmatter)) {
|
|
283
|
+
addMissingFieldFinding("globs", path, findings);
|
|
284
|
+
} else if (frontmatter.globs === null || frontmatter.globs === "") {
|
|
285
|
+
globs = [];
|
|
286
|
+
} else if (typeof frontmatter.globs === "string") {
|
|
287
|
+
globs = splitGlobList(frontmatter.globs);
|
|
288
|
+
} else if (
|
|
289
|
+
Array.isArray(frontmatter.globs) &&
|
|
290
|
+
frontmatter.globs.every((glob): glob is string => typeof glob === "string")
|
|
291
|
+
) {
|
|
292
|
+
globs = frontmatter.globs.flatMap(splitGlobList);
|
|
293
|
+
} else {
|
|
294
|
+
addInvalidFieldFinding("globs", "a string, string array, or empty value", path, lines, closingIndex, findings);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let alwaysApply = false;
|
|
298
|
+
if (!("alwaysApply" in frontmatter)) {
|
|
299
|
+
addMissingFieldFinding("alwaysApply", path, findings);
|
|
300
|
+
} else if (typeof frontmatter.alwaysApply === "boolean") {
|
|
301
|
+
alwaysApply = frontmatter.alwaysApply;
|
|
302
|
+
} else {
|
|
303
|
+
addInvalidFieldFinding("alwaysApply", "a boolean", path, lines, closingIndex, findings);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { path, content, body, bodyStartLine: closingIndex + 2, globs, alwaysApply };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function validateOptionalStringField(
|
|
310
|
+
frontmatter: Record<string, unknown>,
|
|
311
|
+
field: string,
|
|
312
|
+
path: string,
|
|
313
|
+
lines: string[],
|
|
314
|
+
closingIndex: number,
|
|
315
|
+
findings: Finding[],
|
|
316
|
+
): void {
|
|
317
|
+
if (!(field in frontmatter)) {
|
|
318
|
+
addMissingFieldFinding(field, path, findings);
|
|
319
|
+
} else if (frontmatter[field] !== null && typeof frontmatter[field] !== "string") {
|
|
320
|
+
addInvalidFieldFinding(field, "a string or empty value", path, lines, closingIndex, findings);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function addMissingFieldFinding(field: string, path: string, findings: Finding[]): void {
|
|
325
|
+
findings.push({
|
|
326
|
+
code: `missing-${toKebabCase(field)}`,
|
|
327
|
+
category: "validation",
|
|
328
|
+
severity: "warning",
|
|
329
|
+
message: `Cursor frontmatter is missing the ${field} field.`,
|
|
330
|
+
path,
|
|
331
|
+
line: 1,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function addInvalidFieldFinding(
|
|
336
|
+
field: string,
|
|
337
|
+
expected: string,
|
|
338
|
+
path: string,
|
|
339
|
+
lines: string[],
|
|
340
|
+
closingIndex: number,
|
|
341
|
+
findings: Finding[],
|
|
342
|
+
): void {
|
|
343
|
+
findings.push({
|
|
344
|
+
code: `invalid-${toKebabCase(field)}`,
|
|
345
|
+
category: "validation",
|
|
346
|
+
severity: "warning",
|
|
347
|
+
message: `Cursor frontmatter field ${field} must be ${expected}.`,
|
|
348
|
+
path,
|
|
349
|
+
line: findFieldLine(lines, closingIndex, field),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function findFieldLine(lines: string[], closingIndex: number, field: string): number {
|
|
354
|
+
const fieldPattern = new RegExp(`^\\s*${escapeRegExp(field)}\\s*:`);
|
|
355
|
+
const index = lines.slice(1, closingIndex).findIndex((line) => fieldPattern.test(line));
|
|
356
|
+
return index === -1 ? 1 : index + 2;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function splitGlobList(value: string): string[] {
|
|
360
|
+
const globs: string[] = [];
|
|
361
|
+
let current = "";
|
|
362
|
+
let depth = 0;
|
|
363
|
+
|
|
364
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
365
|
+
const character = value[index];
|
|
366
|
+
if (character === undefined) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if ("{[(".includes(character)) {
|
|
371
|
+
depth += 1;
|
|
372
|
+
} else if ("}])".includes(character) && depth > 0) {
|
|
373
|
+
depth -= 1;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (character === "," && depth === 0 && value[index - 1] !== "\\") {
|
|
377
|
+
if (current.trim()) {
|
|
378
|
+
globs.push(current.trim());
|
|
379
|
+
}
|
|
380
|
+
current = "";
|
|
381
|
+
} else {
|
|
382
|
+
current += character;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (current.trim()) {
|
|
387
|
+
globs.push(current.trim());
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return globs;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function inspectBody(rule: ParsedRule, findings: Finding[]): void {
|
|
394
|
+
if (!rule.body.trim()) {
|
|
395
|
+
findings.push({
|
|
396
|
+
code: "empty-rule-body",
|
|
397
|
+
category: "quality",
|
|
398
|
+
severity: "warning",
|
|
399
|
+
message: "Rule body is empty.",
|
|
400
|
+
path: rule.path,
|
|
401
|
+
line: rule.bodyStartLine,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (
|
|
406
|
+
vagueInstructionPattern.test(rule.body) &&
|
|
407
|
+
!concreteExamplePattern.test(rule.body)
|
|
408
|
+
) {
|
|
409
|
+
const match = vagueInstructionPattern.exec(rule.body);
|
|
410
|
+
findings.push({
|
|
411
|
+
code: "vague-instruction",
|
|
412
|
+
category: "quality",
|
|
413
|
+
severity: "warning",
|
|
414
|
+
message: "Rule contains a vague instruction without a concrete example.",
|
|
415
|
+
path: rule.path,
|
|
416
|
+
line: match ? rule.bodyStartLine + lineNumberAt(rule.body, match.index) - 1 : rule.bodyStartLine,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (rule.alwaysApply && countLines(rule.content) > MAX_ALWAYS_APPLY_LINES) {
|
|
421
|
+
findings.push({
|
|
422
|
+
code: "long-always-apply-rule",
|
|
423
|
+
category: "quality",
|
|
424
|
+
severity: "warning",
|
|
425
|
+
message: `Always-applied rule exceeds ${MAX_ALWAYS_APPLY_LINES} lines.`,
|
|
426
|
+
path: rule.path,
|
|
427
|
+
line: 1,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function inspectSuspiciousInstructions(rule: ParsedRule, findings: Finding[]): void {
|
|
433
|
+
for (const definition of suspiciousPatterns) {
|
|
434
|
+
const match = definition.pattern.exec(rule.content);
|
|
435
|
+
if (match) {
|
|
436
|
+
findings.push({
|
|
437
|
+
code: definition.code,
|
|
438
|
+
category: "suspicious-instruction",
|
|
439
|
+
severity: "high",
|
|
440
|
+
message: definition.message,
|
|
441
|
+
path: rule.path,
|
|
442
|
+
line: lineNumberAt(rule.content, match.index),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function inspectSecrets(rule: ParsedRule, findings: Finding[]): void {
|
|
449
|
+
for (const definition of secretPatterns) {
|
|
450
|
+
definition.pattern.lastIndex = 0;
|
|
451
|
+
const seenLines = new Set<number>();
|
|
452
|
+
|
|
453
|
+
for (const match of rule.content.matchAll(definition.pattern)) {
|
|
454
|
+
const line = lineNumberAt(rule.content, match.index ?? 0);
|
|
455
|
+
if (seenLines.has(line)) {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
seenLines.add(line);
|
|
459
|
+
findings.push({
|
|
460
|
+
code: definition.code,
|
|
461
|
+
category: "secret",
|
|
462
|
+
severity: "high",
|
|
463
|
+
message: definition.message,
|
|
464
|
+
path: rule.path,
|
|
465
|
+
line,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function inspectRuleSet(rules: ParsedRule[], findings: Finding[]): void {
|
|
472
|
+
const alwaysApplyRules = rules.filter((rule) => rule.alwaysApply);
|
|
473
|
+
if (alwaysApplyRules.length > MAX_ALWAYS_APPLY_RULES) {
|
|
474
|
+
findings.push({
|
|
475
|
+
code: "too-many-always-apply-rules",
|
|
476
|
+
category: "quality",
|
|
477
|
+
severity: "warning",
|
|
478
|
+
message: `${alwaysApplyRules.length} rules use alwaysApply; recommended maximum is ${MAX_ALWAYS_APPLY_RULES}.`,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const globOwners = new Map<string, Set<string>>();
|
|
483
|
+
for (const rule of rules) {
|
|
484
|
+
for (const glob of rule.globs) {
|
|
485
|
+
const owners = globOwners.get(glob) ?? new Set<string>();
|
|
486
|
+
owners.add(rule.path);
|
|
487
|
+
globOwners.set(glob, owners);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
for (const owners of globOwners.values()) {
|
|
492
|
+
if (owners.size > 1) {
|
|
493
|
+
const paths = [...owners].sort(compareText);
|
|
494
|
+
findings.push({
|
|
495
|
+
code: "duplicate-glob",
|
|
496
|
+
category: "quality",
|
|
497
|
+
severity: "warning",
|
|
498
|
+
message: `The same glob is used by multiple rules: ${paths.join(", ")}.`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
505
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function stripBom(value: string): string {
|
|
509
|
+
return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function countLines(value: string): number {
|
|
513
|
+
return value ? value.split(/\r?\n/).length : 0;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function lineNumberAt(value: string, index: number): number {
|
|
517
|
+
return value.slice(0, index).split(/\r?\n/).length;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function toReportPath(value: string): string {
|
|
521
|
+
return sep === "/" ? value : value.split(sep).join("/");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function toKebabCase(value: string): string {
|
|
525
|
+
return value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function escapeRegExp(value: string): string {
|
|
529
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function compareFindings(left: Finding, right: Finding): number {
|
|
533
|
+
const severityOrder = { high: 0, warning: 1 };
|
|
534
|
+
return (
|
|
535
|
+
severityOrder[left.severity] - severityOrder[right.severity] ||
|
|
536
|
+
compareText(left.path ?? "", right.path ?? "") ||
|
|
537
|
+
(left.line ?? 0) - (right.line ?? 0) ||
|
|
538
|
+
compareText(left.code, right.code)
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function compareText(left: string, right: string): number {
|
|
543
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
544
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type Severity = "high" | "warning";
|
|
2
|
+
|
|
3
|
+
export type FindingCategory =
|
|
4
|
+
| "frontmatter"
|
|
5
|
+
| "validation"
|
|
6
|
+
| "quality"
|
|
7
|
+
| "suspicious-instruction"
|
|
8
|
+
| "secret";
|
|
9
|
+
|
|
10
|
+
export interface Finding {
|
|
11
|
+
code: string;
|
|
12
|
+
category: FindingCategory;
|
|
13
|
+
severity: Severity;
|
|
14
|
+
message: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
line?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ScanSummary {
|
|
20
|
+
files: number;
|
|
21
|
+
high: number;
|
|
22
|
+
warnings: number;
|
|
23
|
+
total: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ScanReport {
|
|
27
|
+
schemaVersion: 1;
|
|
28
|
+
tool: {
|
|
29
|
+
name: "AgentSafe";
|
|
30
|
+
version: "0.1.0";
|
|
31
|
+
};
|
|
32
|
+
target: ".";
|
|
33
|
+
files: string[];
|
|
34
|
+
findings: Finding[];
|
|
35
|
+
summary: ScanSummary;
|
|
36
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import { runCli } from "../dist/cli.js";
|
|
8
|
+
import { formatJsonReport, formatMarkdownReport } from "../dist/rules/report.js";
|
|
9
|
+
import { scanRules } from "../dist/rules/scan.js";
|
|
10
|
+
|
|
11
|
+
const projectRoot = process.cwd();
|
|
12
|
+
const safeFixture = join(projectRoot, "examples", "safe-rules");
|
|
13
|
+
const unsafeFixture = join(projectRoot, "examples", "unsafe-rules");
|
|
14
|
+
|
|
15
|
+
test("safe fixture has no findings", async () => {
|
|
16
|
+
const report = await scanRules(safeFixture);
|
|
17
|
+
|
|
18
|
+
assert.equal(report.summary.files, 5);
|
|
19
|
+
assert.equal(report.summary.total, 0);
|
|
20
|
+
assert.deepEqual(report.findings, []);
|
|
21
|
+
assert.ok(report.files.every((file) => !file.includes("\\")));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("unsafe fixture covers rule hygiene, suspicious instructions, and secrets", async () => {
|
|
25
|
+
const report = await scanRules(unsafeFixture);
|
|
26
|
+
const codes = new Set(report.findings.map((finding) => finding.code));
|
|
27
|
+
const expectedCodes = [
|
|
28
|
+
"missing-frontmatter",
|
|
29
|
+
"empty-rule-body",
|
|
30
|
+
"duplicate-glob",
|
|
31
|
+
"too-many-always-apply-rules",
|
|
32
|
+
"vague-instruction",
|
|
33
|
+
"invalid-description",
|
|
34
|
+
"invalid-globs",
|
|
35
|
+
"invalid-always-apply",
|
|
36
|
+
"invalid-frontmatter",
|
|
37
|
+
"ignore-previous-instructions",
|
|
38
|
+
"reveal-secrets",
|
|
39
|
+
"read-dot-env",
|
|
40
|
+
"print-environment-variables",
|
|
41
|
+
"send-tokens",
|
|
42
|
+
"exfiltrate",
|
|
43
|
+
"disable-safety",
|
|
44
|
+
"openai-key",
|
|
45
|
+
"anthropic-key",
|
|
46
|
+
"github-token",
|
|
47
|
+
"aws-access-key",
|
|
48
|
+
"generic-api-key",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
for (const code of expectedCodes) {
|
|
52
|
+
assert.ok(codes.has(code), `expected finding ${code}`);
|
|
53
|
+
}
|
|
54
|
+
assert.ok(report.summary.high > 0);
|
|
55
|
+
assert.ok(report.summary.warnings > 0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("alwaysApply rules over 500 lines are reported", async () => {
|
|
59
|
+
const root = await mkdtemp(join(tmpdir(), "agentsafe-"));
|
|
60
|
+
const rulePath = join(root, ".cursor", "rules", "long.mdc");
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await mkdir(dirname(rulePath), { recursive: true });
|
|
64
|
+
const body = Array.from({ length: 501 }, (_, index) => `Specific instruction ${index + 1}.`).join("\n");
|
|
65
|
+
await writeFile(
|
|
66
|
+
rulePath,
|
|
67
|
+
`---\ndescription:\nglobs:\nalwaysApply: true\n---\n${body}\n`,
|
|
68
|
+
"utf8",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const report = await scanRules(root);
|
|
72
|
+
assert.ok(report.findings.some((finding) => finding.code === "long-always-apply-rule"));
|
|
73
|
+
} finally {
|
|
74
|
+
await rm(root, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("JSON and Markdown reports never expose detected values", async () => {
|
|
79
|
+
const report = await scanRules(unsafeFixture);
|
|
80
|
+
const outputs = [formatJsonReport(report), formatMarkdownReport(report)];
|
|
81
|
+
const secretValues = [
|
|
82
|
+
"sk-test-AGENTSAFE000000000000000000",
|
|
83
|
+
"sk-ant-test-AGENTSAFE00000000000000",
|
|
84
|
+
"ghp_AGENTSAFETESTTOKEN0000000000000000",
|
|
85
|
+
"AKIAIOSFODNN7EXAMPLE",
|
|
86
|
+
"agentsafe_test_api_key_1234567890",
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
for (const output of outputs) {
|
|
90
|
+
for (const secret of secretValues) {
|
|
91
|
+
assert.ok(!output.includes(secret));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const parsed = JSON.parse(outputs[0]);
|
|
96
|
+
assert.equal(parsed.schemaVersion, 1);
|
|
97
|
+
assert.equal(parsed.target, ".");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("CLI returns zero for findings and emits pure JSON", async () => {
|
|
101
|
+
let stdout = "";
|
|
102
|
+
let stderr = "";
|
|
103
|
+
const exitCode = await runCli(
|
|
104
|
+
["rules", "scan", unsafeFixture, "--json"],
|
|
105
|
+
projectRoot,
|
|
106
|
+
{
|
|
107
|
+
stdout: (value) => {
|
|
108
|
+
stdout += value;
|
|
109
|
+
},
|
|
110
|
+
stderr: (value) => {
|
|
111
|
+
stderr += value;
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
assert.equal(exitCode, 0);
|
|
117
|
+
assert.equal(stderr, "");
|
|
118
|
+
assert.ok(JSON.parse(stdout).summary.total > 0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("CLI rejects conflicting report flags", async () => {
|
|
122
|
+
let stderr = "";
|
|
123
|
+
const exitCode = await runCli(
|
|
124
|
+
["rules", "scan", "--json", "--markdown"],
|
|
125
|
+
projectRoot,
|
|
126
|
+
{
|
|
127
|
+
stdout: () => {},
|
|
128
|
+
stderr: (value) => {
|
|
129
|
+
stderr += value;
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
assert.equal(exitCode, 1);
|
|
135
|
+
assert.match(stderr, /only one report format/i);
|
|
136
|
+
});
|