agentsafe 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/package.json CHANGED
@@ -1,7 +1,27 @@
1
1
  {
2
2
  "name": "agentsafe",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Offline safety and hygiene toolkit for AI coding workspaces",
5
+ "repository": "https://github.com/mewsyy/agentsafe",
6
+ "homepage": "https://github.com/mewsyy/agentsafe#readme",
7
+ "bugs": "https://github.com/mewsyy/agentsafe/issues",
8
+ "keywords": [
9
+ "ai",
10
+ "agents",
11
+ "cursor",
12
+ "cli",
13
+ "security",
14
+ "static-analysis",
15
+ "privacy"
16
+ ],
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE",
21
+ "CHANGELOG.md",
22
+ "SECURITY.md",
23
+ "examples"
24
+ ],
5
25
  "type": "module",
6
26
  "bin": {
7
27
  "agentsafe": "dist/index.js"
package/.gitattributes DELETED
@@ -1,5 +0,0 @@
1
- * text=auto eol=lf
2
-
3
- *.bat text eol=crlf
4
- *.cmd text eol=crlf
5
- *.ps1 text eol=crlf
@@ -1,34 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- pull_request:
6
-
7
- permissions:
8
- contents: read
9
-
10
- jobs:
11
- test:
12
- runs-on: ubuntu-latest
13
-
14
- steps:
15
- - name: Checkout repository
16
- uses: actions/checkout@v4
17
-
18
- - name: Set up Node.js
19
- uses: actions/setup-node@v4
20
- with:
21
- node-version: 20
22
- cache: npm
23
-
24
- - name: Install dependencies
25
- run: npm ci
26
-
27
- - name: Run tests
28
- run: npm test
29
-
30
- - name: Run typecheck
31
- run: npm run typecheck
32
-
33
- - name: Build
34
- run: npm run build
package/CONTRIBUTING.md DELETED
@@ -1,23 +0,0 @@
1
- # Contributing
2
-
3
- Thank you for helping improve AgentSafe.
4
-
5
- ## Development Checks
6
-
7
- Before opening a pull request, run:
8
-
9
- ```shell
10
- npm test
11
- npm run typecheck
12
- npm run build
13
- ```
14
-
15
- ## Project Principles
16
-
17
- Contributions must preserve AgentSafe's offline-first, privacy-first design:
18
-
19
- - do not add telemetry;
20
- - do not add network calls or external API dependencies;
21
- - do not execute scanned files or workspace code;
22
- - do not execute MCP commands or start subprocesses from scan results;
23
- - keep changes focused, small, and covered by tests where appropriate.
package/src/cli.ts DELETED
@@ -1,104 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { pathToFileURL } from "node:url";
4
- import { resolve } from "node:path";
5
-
6
- import {
7
- formatJsonReport,
8
- formatMarkdownReport,
9
- formatTextReport,
10
- } from "./rules/report.js";
11
- import { scanRules } from "./rules/scan.js";
12
-
13
- type OutputFormat = "text" | "json" | "markdown";
14
-
15
- interface CliOptions {
16
- directory: string;
17
- format: OutputFormat;
18
- }
19
-
20
- interface CliOutput {
21
- stdout(value: string): void;
22
- stderr(value: string): void;
23
- }
24
-
25
- const usage = `Usage:
26
- agentsafe rules scan [directory] [--json | --markdown]
27
-
28
- Scans AI rule files using local, static analysis only.
29
- `;
30
-
31
- export async function runCli(
32
- args: string[],
33
- cwd = process.cwd(),
34
- output: CliOutput = {
35
- stdout: (value) => process.stdout.write(value),
36
- stderr: (value) => process.stderr.write(value),
37
- },
38
- ): Promise<number> {
39
- if (args.includes("--help") || args.includes("-h")) {
40
- output.stdout(usage);
41
- return 0;
42
- }
43
-
44
- let options: CliOptions;
45
- try {
46
- options = parseArguments(args);
47
- } catch (error) {
48
- output.stderr(`${getErrorMessage(error)}\n\n${usage}`);
49
- return 1;
50
- }
51
-
52
- try {
53
- const report = await scanRules(resolve(cwd, options.directory));
54
- const formatted =
55
- options.format === "json"
56
- ? formatJsonReport(report)
57
- : options.format === "markdown"
58
- ? formatMarkdownReport(report)
59
- : formatTextReport(report);
60
- output.stdout(formatted);
61
- return 0;
62
- } catch (error) {
63
- output.stderr(`AgentSafe scan failed: ${getErrorMessage(error)}\n`);
64
- return 1;
65
- }
66
- }
67
-
68
- function parseArguments(args: string[]): CliOptions {
69
- if (args[0] !== "rules" || args[1] !== "scan") {
70
- throw new Error("Unknown command.");
71
- }
72
-
73
- let format: OutputFormat = "text";
74
- let directory = ".";
75
- let hasDirectory = false;
76
-
77
- for (const argument of args.slice(2)) {
78
- if (argument === "--json" || argument === "--markdown") {
79
- const requestedFormat = argument.slice(2) as OutputFormat;
80
- if (format !== "text") {
81
- throw new Error("Use only one report format flag.");
82
- }
83
- format = requestedFormat;
84
- } else if (argument.startsWith("-")) {
85
- throw new Error(`Unknown option: ${argument}`);
86
- } else if (hasDirectory) {
87
- throw new Error("Only one scan directory may be provided.");
88
- } else {
89
- directory = argument;
90
- hasDirectory = true;
91
- }
92
- }
93
-
94
- return { directory, format };
95
- }
96
-
97
- function getErrorMessage(error: unknown): string {
98
- return error instanceof Error ? error.message : "Unknown error.";
99
- }
100
-
101
- const invokedPath = process.argv[1];
102
- if (invokedPath && pathToFileURL(invokedPath).href === import.meta.url) {
103
- process.exitCode = await runCli(process.argv.slice(2));
104
- }
package/src/index.ts DELETED
@@ -1,5 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { runCli } from "./cli.js";
4
-
5
- process.exitCode = await runCli(process.argv.slice(2));
@@ -1,83 +0,0 @@
1
- import type { Finding, ScanReport } from "./types.js";
2
-
3
- export function formatJsonReport(report: ScanReport): string {
4
- return `${JSON.stringify(report, null, 2)}\n`;
5
- }
6
-
7
- export function formatMarkdownReport(report: ScanReport): string {
8
- const lines = [
9
- "# AgentSafe Rules Scan",
10
- "",
11
- `- Target: \`${report.target}\``,
12
- `- Files scanned: ${report.summary.files}`,
13
- `- High severity: ${report.summary.high}`,
14
- `- Warnings: ${report.summary.warnings}`,
15
- `- Total findings: ${report.summary.total}`,
16
- "",
17
- "## Findings",
18
- "",
19
- ];
20
-
21
- if (report.findings.length === 0) {
22
- lines.push("_No findings._");
23
- } else {
24
- lines.push("| Severity | Rule | Location | Message |");
25
- lines.push("| --- | --- | --- | --- |");
26
- for (const finding of report.findings) {
27
- lines.push(
28
- `| ${finding.severity.toUpperCase()} | \`${escapeMarkdown(finding.code)}\` | ${formatMarkdownLocation(finding)} | ${escapeMarkdown(finding.message)} |`,
29
- );
30
- }
31
- }
32
-
33
- lines.push("", "## Scanned Files", "");
34
- if (report.files.length === 0) {
35
- lines.push("_No supported rule files found._");
36
- } else {
37
- for (const file of report.files) {
38
- lines.push(`- \`${escapeMarkdown(file)}\``);
39
- }
40
- }
41
-
42
- return `${lines.join("\n")}\n`;
43
- }
44
-
45
- export function formatTextReport(report: ScanReport): string {
46
- const lines = [
47
- "AgentSafe Rules Scan",
48
- `Files scanned: ${report.summary.files}`,
49
- `Findings: ${report.summary.total} (${report.summary.high} high, ${report.summary.warnings} warnings)`,
50
- ];
51
-
52
- if (report.findings.length === 0) {
53
- lines.push("", "No findings.");
54
- } else {
55
- lines.push("");
56
- for (const finding of report.findings) {
57
- lines.push(
58
- `[${finding.severity.toUpperCase()}] ${formatTextLocation(finding)} ${finding.code}: ${finding.message}`,
59
- );
60
- }
61
- }
62
-
63
- return `${lines.join("\n")}\n`;
64
- }
65
-
66
- function formatMarkdownLocation(finding: Finding): string {
67
- if (!finding.path) {
68
- return "project";
69
- }
70
- const location = finding.line ? `${finding.path}:${finding.line}` : finding.path;
71
- return `\`${escapeMarkdown(location)}\``;
72
- }
73
-
74
- function formatTextLocation(finding: Finding): string {
75
- if (!finding.path) {
76
- return "project";
77
- }
78
- return finding.line ? `${finding.path}:${finding.line}` : finding.path;
79
- }
80
-
81
- function escapeMarkdown(value: string): string {
82
- return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/`/g, "\\`");
83
- }
package/src/rules/scan.ts DELETED
@@ -1,544 +0,0 @@
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
- }
@@ -1,36 +0,0 @@
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
- }
@@ -1,136 +0,0 @@
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
- });
package/tsconfig.json DELETED
@@ -1,17 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "rootDir": "src",
7
- "outDir": "dist",
8
- "strict": true,
9
- "noUncheckedIndexedAccess": true,
10
- "exactOptionalPropertyTypes": true,
11
- "declaration": true,
12
- "sourceMap": true,
13
- "forceConsistentCasingInFileNames": true,
14
- "skipLibCheck": true
15
- },
16
- "include": ["src/**/*.ts"]
17
- }