@entet/ai-agent-guard 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ENTET Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # ai-agent-guard
2
+
3
+ Scan a local project for security risks **before** you point an AI coding agent at it.
4
+
5
+ AI coding agents (Claude Code, Cursor, Codex, Windsurf, …) read your whole repo and can execute commands, follow instruction files, and call MCP servers. A leaked key, an over-permissioned MCP config, or a hostile `CLAUDE.md` becomes the agent's problem the moment it starts. `ai-agent-guard` does a fast local pass and tells you what to look at first.
6
+
7
+ ```
8
+ npx ai-agent-guard
9
+ ```
10
+
11
+ No install, no config, no account.
12
+
13
+ ## What it checks
14
+
15
+ | Category | Examples |
16
+ |---|---|
17
+ | **Secrets** | AWS keys (`AKIA…`), GitHub tokens (`ghp_/gho_/ghs_/ghu_/ghr_/github_pat_`), Stripe live keys (`sk_live_`), OpenAI / Anthropic keys, private key blocks, DB URLs with embedded credentials, hardcoded `api_key = "…"` assignments |
18
+ | **MCP configs** | `mcp.json`, `.mcp.json`, `claude_desktop_config.json` — unpinned `npx`, root/home filesystem access, inline secrets in `env` |
19
+ | **AI instruction files** | `CLAUDE.md`, `AGENTS.md`, `.cursor/rules`, `.cursorrules`, `.windsurfrules`, `gemini.md`, `copilot-instructions.md` — flagged so you can review them for prompt-injection or risky directives |
20
+ | **GitHub Actions** | `pull_request_target` + checkout, `write-all` permissions, untrusted `github.event.*` interpolated into shell steps |
21
+ | **package.json scripts** | `postinstall`/`prepare` piping `curl \| bash`, unpinned `npx` in lifecycle scripts |
22
+ | **n8n workflows** | webhook nodes without authentication, code nodes using `exec`/`eval`/`fs`, inline credentials |
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ # scan the current directory
28
+ npx ai-agent-guard
29
+
30
+ # scan a specific path
31
+ npx ai-agent-guard --path ./my-repo
32
+
33
+ # machine-readable output (for CI)
34
+ npx ai-agent-guard --json
35
+
36
+ # disable colors
37
+ npx ai-agent-guard --no-color
38
+ ```
39
+
40
+ Exit code is `0` when clean and `1` when there are findings, so it drops straight into CI:
41
+
42
+ ```yaml
43
+ - run: npx ai-agent-guard
44
+ ```
45
+
46
+ Example output:
47
+
48
+ ```
49
+ AI Agent Guard v0.1.0
50
+ scanned: /home/me/my-repo
51
+
52
+ CRITICAL
53
+ ● .env:3 [secret.aws-access-key]
54
+ AWS access key ID
55
+ evidence: AKIA****************WXYZ
56
+
57
+ HIGH
58
+ ● .mcp.json:5 [mcp.unpinned-npx]
59
+ MCP server "fs" runs npx without a pinned version
60
+ evidence: npx ****************r-fs
61
+
62
+ ────────────────────────────────────────────────
63
+ files scanned: 142 skipped: 6
64
+ findings: 1 critical 1 high 0 medium 0 low
65
+ ```
66
+
67
+ ## Private by design
68
+
69
+ Runs entirely on your machine. **No network calls, no API key, no telemetry.** The source is a single dependency-free file — read it: [`bin/ai-agent-guard.js`](bin/ai-agent-guard.js). Evidence is masked in output (first 4 and last 4 characters only).
70
+
71
+ ## Want this in your IDE?
72
+
73
+ This CLI is the free, open-source companion to the **AI Agent Workspace Guard** plugin for JetBrains IDEs, which runs these checks continuously inside your editor with inline highlighting, quick-fixes, and per-workspace policy:
74
+
75
+ ➡️ https://plugins.jetbrains.com/plugin/32116-ai-agent-workspace-guard
76
+
77
+ ## License
78
+
79
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,625 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const VERSION = require('../package.json').version;
8
+
9
+ const SKIP_DIRS = new Set([
10
+ '.git', 'node_modules', '.venv', 'venv', 'dist', 'build',
11
+ '.next', '.nuxt', 'coverage', 'target', 'out', '.turbo', '.cache',
12
+ ]);
13
+
14
+ const MAX_FILE_SIZE = 512 * 1024;
15
+ const BINARY_SNIFF_BYTES = 4096;
16
+
17
+ const SEVERITY_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
18
+
19
+ const COLORS = {
20
+ CRITICAL: '\x1b[31m',
21
+ HIGH: '\x1b[33m',
22
+ MEDIUM: '\x1b[34m',
23
+ LOW: '\x1b[90m',
24
+ reset: '\x1b[0m',
25
+ bold: '\x1b[1m',
26
+ dim: '\x1b[2m',
27
+ green: '\x1b[32m',
28
+ cyan: '\x1b[36m',
29
+ };
30
+
31
+ function supportsColor() {
32
+ if (process.env.NO_COLOR) return false;
33
+ if (process.env.FORCE_COLOR) return true;
34
+ return process.stdout.isTTY === true;
35
+ }
36
+
37
+ let useColor = supportsColor();
38
+
39
+ function color(name, text) {
40
+ if (!useColor) return text;
41
+ return (COLORS[name] || '') + text + COLORS.reset;
42
+ }
43
+
44
+ function mask(value) {
45
+ const v = String(value).trim();
46
+ if (v.length <= 8) return '*'.repeat(v.length);
47
+ return v.slice(0, 4) + '*'.repeat(v.length - 4);
48
+ }
49
+
50
+ // --- Secret rules (regex matched against each line) ---
51
+ // patterns mirror the AI Agent Workspace Guard JetBrains plugin.
52
+ const SECRET_RULES = [
53
+ {
54
+ id: 'secret.aws-access-key',
55
+ description: 'AWS access key ID',
56
+ severity: 'CRITICAL',
57
+ regex: /\bAKIA[0-9A-Z]{16}\b/,
58
+ },
59
+ {
60
+ id: 'secret.github-token',
61
+ description: 'GitHub token',
62
+ severity: 'CRITICAL',
63
+ regex: /\b(?:ghp|gho|ghs|ghu|ghr|github_pat)_[A-Za-z0-9_]{36,}\b/,
64
+ },
65
+ {
66
+ id: 'secret.stripe-live-key',
67
+ description: 'Stripe live secret key',
68
+ severity: 'CRITICAL',
69
+ regex: /\bsk_live_[A-Za-z0-9]{16,}\b/,
70
+ },
71
+ {
72
+ id: 'secret.anthropic-key',
73
+ description: 'Anthropic API key',
74
+ severity: 'CRITICAL',
75
+ regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/,
76
+ },
77
+ {
78
+ id: 'secret.openai-key',
79
+ description: 'OpenAI API key',
80
+ severity: 'CRITICAL',
81
+ regex: /\bsk-(?!ant-)(?:proj-|live_test_|org-)?[A-Za-z0-9_-]{20,}\b/,
82
+ },
83
+ {
84
+ id: 'secret.private-key',
85
+ description: 'Private key material',
86
+ severity: 'CRITICAL',
87
+ regex: /-----BEGIN(?:\s+[A-Z0-9]+)*\s+PRIVATE KEY-----/,
88
+ },
89
+ {
90
+ id: 'secret.db-url-credentials',
91
+ description: 'Database URL with embedded credentials',
92
+ severity: 'HIGH',
93
+ regex: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s:/@]+:[^\s:/@]+@[^\s/]+/,
94
+ },
95
+ {
96
+ id: 'secret.generic-assignment',
97
+ description: 'Hardcoded secret assignment',
98
+ severity: 'MEDIUM',
99
+ regex: /\b(?:api[_-]?key|secret|token|passwd|password|access[_-]?token|auth[_-]?token|client[_-]?secret)\b\s*[:=]\s*['"][^'"\s]{8,}['"]/i,
100
+ },
101
+ ];
102
+
103
+ // Placeholder-ish values to suppress generic-assignment noise.
104
+ const PLACEHOLDER_RE = /^(?:x{3,}|\.{3,}|<[^>]+>|\$\{[^}]+\}|your[_-]|changeme|example|placeholder|todo|null|true|false|process\.env)/i;
105
+
106
+ function isBinary(buf) {
107
+ const len = Math.min(buf.length, BINARY_SNIFF_BYTES);
108
+ for (let i = 0; i < len; i++) {
109
+ if (buf[i] === 0) return true;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ function scanSecrets(relPath, lines, findings) {
115
+ for (let i = 0; i < lines.length; i++) {
116
+ const line = lines[i];
117
+ if (line.length > 4000) continue;
118
+ for (const rule of SECRET_RULES) {
119
+ const m = rule.regex.exec(line);
120
+ if (!m) continue;
121
+ const evidence = m[0];
122
+ if (rule.id === 'secret.generic-assignment') {
123
+ const quoted = /['"]([^'"\s]{8,})['"]/.exec(m[0]);
124
+ const val = quoted ? quoted[1] : evidence;
125
+ if (PLACEHOLDER_RE.test(val)) continue;
126
+ }
127
+ findings.push({
128
+ ruleId: rule.id,
129
+ description: rule.description,
130
+ severity: rule.severity,
131
+ file: relPath,
132
+ line: i + 1,
133
+ evidence: mask(evidence),
134
+ });
135
+ }
136
+ }
137
+ }
138
+
139
+ function parseJsonLoose(text) {
140
+ try {
141
+ return JSON.parse(text);
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function lineOf(text, needle) {
148
+ const idx = text.indexOf(needle);
149
+ if (idx < 0) return 1;
150
+ return text.slice(0, idx).split('\n').length;
151
+ }
152
+
153
+ function scanMcpConfig(relPath, text, findings, data) {
154
+ if (!data || typeof data !== 'object') return;
155
+ const servers = data.mcpServers || data.servers || {};
156
+ if (!servers || typeof servers !== 'object') return;
157
+
158
+ for (const [name, srv] of Object.entries(servers)) {
159
+ if (!srv || typeof srv !== 'object') continue;
160
+ const cmd = String(srv.command || '');
161
+ const args = Array.isArray(srv.args) ? srv.args.map(String) : [];
162
+ const all = [cmd, ...args];
163
+ const joined = all.join(' ');
164
+ const ln = lineOf(text, name);
165
+
166
+ if (/\b(npx|npm|pnpm|yarn|bunx)\b/.test(joined)) {
167
+ const hasPin = args.some((a) => /@(?:\d|latest|next|[~^]?\d)/.test(a)) ||
168
+ args.some((a) => /@[0-9a-f]{7,40}$/.test(a));
169
+ const hasYesFlag = args.includes('-y') || args.includes('--yes');
170
+ if (!hasPin) {
171
+ findings.push({
172
+ ruleId: 'mcp.unpinned-npx',
173
+ description: `MCP server "${name}" runs npx without a pinned version` + (hasYesFlag ? ' (-y auto-confirm)' : ''),
174
+ severity: 'HIGH',
175
+ file: relPath,
176
+ line: ln,
177
+ evidence: mask(joined),
178
+ });
179
+ }
180
+ }
181
+
182
+ for (const a of all) {
183
+ if (/--allow-all|--dangerously|--yolo|--no-sandbox/.test(a)) {
184
+ findings.push({
185
+ ruleId: 'mcp.broad-permissions',
186
+ description: `MCP server "${name}" grants broad/unsafe permissions (${a})`,
187
+ severity: 'HIGH',
188
+ file: relPath,
189
+ line: ln,
190
+ evidence: a,
191
+ });
192
+ }
193
+ if (/^(?:\/|[A-Za-z]:[\\/])$/.test(a) || a === '~' || a === '/Users' || a === 'C:\\Users') {
194
+ findings.push({
195
+ ruleId: 'mcp.broad-filesystem',
196
+ description: `MCP server "${name}" exposes a root/home filesystem path (${a})`,
197
+ severity: 'HIGH',
198
+ file: relPath,
199
+ line: ln,
200
+ evidence: a,
201
+ });
202
+ }
203
+ }
204
+
205
+ const env = srv.env && typeof srv.env === 'object' ? srv.env : {};
206
+ for (const [k, v] of Object.entries(env)) {
207
+ const val = String(v);
208
+ if (val && !/^\$\{?[A-Za-z0-9_]+\}?$/.test(val) && val.length >= 12 &&
209
+ /(key|token|secret|password|pass)/i.test(k)) {
210
+ findings.push({
211
+ ruleId: 'mcp.inline-secret',
212
+ description: `MCP server "${name}" hardcodes a secret in env "${k}"`,
213
+ severity: 'CRITICAL',
214
+ file: relPath,
215
+ line: lineOf(text, k),
216
+ evidence: mask(val),
217
+ });
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ function scanGithubWorkflow(relPath, text, findings) {
224
+ const lower = text.toLowerCase();
225
+ const hasPRTarget = /on:\s*[\s\S]*?pull_request_target/.test(lower) ||
226
+ /\bpull_request_target\b/.test(lower);
227
+ const hasCheckout = /uses:\s*actions\/checkout/.test(lower);
228
+ const refsHeadSha = /github\.event\.pull_request\.head\.(?:sha|ref)/.test(lower);
229
+
230
+ if (hasPRTarget && hasCheckout) {
231
+ findings.push({
232
+ ruleId: 'gha.pr-target-checkout',
233
+ description: 'pull_request_target combined with code checkout can run untrusted PR code with write access',
234
+ severity: 'HIGH',
235
+ file: relPath,
236
+ line: lineOf(text, 'pull_request_target'),
237
+ evidence: refsHeadSha ? 'checkout of PR head ref under pull_request_target' : 'checkout under pull_request_target',
238
+ });
239
+ }
240
+
241
+ if (/permissions:\s*write-all/.test(lower)) {
242
+ findings.push({
243
+ ruleId: 'gha.broad-permissions',
244
+ description: 'Workflow grants write-all permissions to GITHUB_TOKEN',
245
+ severity: 'MEDIUM',
246
+ file: relPath,
247
+ line: lineOf(text, 'write-all'),
248
+ evidence: 'permissions: write-all',
249
+ });
250
+ }
251
+
252
+ // untrusted input interpolated into a run shell -> injection
253
+ const runInjection = /\$\{\{\s*github\.event\.(?:issue|pull_request|comment|review)[^}]*\}\}/;
254
+ if (runInjection.test(text)) {
255
+ const m = runInjection.exec(text);
256
+ findings.push({
257
+ ruleId: 'gha.script-injection',
258
+ description: 'Untrusted github.event input interpolated into workflow (possible script injection)',
259
+ severity: 'HIGH',
260
+ file: relPath,
261
+ line: lineOf(text, m[0]),
262
+ evidence: mask(m[0]),
263
+ });
264
+ }
265
+ }
266
+
267
+ function scanPackageJson(relPath, text, findings, data) {
268
+ if (!data || !data.scripts || typeof data.scripts !== 'object') return;
269
+ const risky = ['postinstall', 'preinstall', 'install', 'prepare', 'prepublish'];
270
+ for (const [name, raw] of Object.entries(data.scripts)) {
271
+ const script = String(raw);
272
+ const ln = lineOf(text, '"' + name + '"');
273
+ if (/\b(?:curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:ba)?sh\b/.test(script) ||
274
+ /\b(?:curl|wget)\b[^|]*\|\s*node\b/.test(script)) {
275
+ findings.push({
276
+ ruleId: 'pkg.curl-pipe-sh',
277
+ description: `npm script "${name}" pipes a remote download into a shell`,
278
+ severity: 'CRITICAL',
279
+ file: relPath,
280
+ line: ln,
281
+ evidence: mask(script),
282
+ });
283
+ }
284
+ if (risky.includes(name)) {
285
+ if (/\b(?:npx|bunx)\b/.test(script) && !/@(?:\d|latest|next)/.test(script)) {
286
+ findings.push({
287
+ ruleId: 'pkg.lifecycle-unpinned-npx',
288
+ description: `Lifecycle script "${name}" runs npx without a pinned version`,
289
+ severity: 'HIGH',
290
+ file: relPath,
291
+ line: ln,
292
+ evidence: mask(script),
293
+ });
294
+ } else if (/\b(?:curl|wget|node\s+-e|eval)\b/.test(script)) {
295
+ findings.push({
296
+ ruleId: 'pkg.lifecycle-script',
297
+ description: `Lifecycle script "${name}" runs network/eval commands during install`,
298
+ severity: 'HIGH',
299
+ file: relPath,
300
+ line: ln,
301
+ evidence: mask(script),
302
+ });
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ function looksLikeN8n(data) {
309
+ if (!data || typeof data !== 'object') return false;
310
+ if (!Array.isArray(data.nodes)) return false;
311
+ return data.nodes.some((n) => n && typeof n.type === 'string' &&
312
+ /n8n-nodes|nodes-base/.test(n.type));
313
+ }
314
+
315
+ function scanN8nWorkflow(relPath, text, data, findings) {
316
+ for (const node of data.nodes) {
317
+ if (!node || typeof node !== 'object') continue;
318
+ const type = String(node.type || '');
319
+ const params = node.parameters && typeof node.parameters === 'object' ? node.parameters : {};
320
+ const nodeName = String(node.name || type);
321
+ const ln = lineOf(text, '"' + nodeName + '"');
322
+
323
+ if (/webhook/i.test(type)) {
324
+ const auth = params.authentication;
325
+ if (!auth || auth === 'none') {
326
+ findings.push({
327
+ ruleId: 'n8n.webhook-no-auth',
328
+ description: `n8n webhook node "${nodeName}" has no authentication`,
329
+ severity: 'HIGH',
330
+ file: relPath,
331
+ line: ln,
332
+ evidence: 'authentication: none',
333
+ });
334
+ }
335
+ }
336
+
337
+ if (/(?:^|\.)code$|function|functionItem/i.test(type)) {
338
+ const codeStr = JSON.stringify(params);
339
+ if (/child_process|require\(\s*['"]child_process|\beval\b|\bexec(?:Sync)?\(|process\.env|require\(\s*['"]fs['"]/.test(codeStr)) {
340
+ findings.push({
341
+ ruleId: 'n8n.dangerous-code',
342
+ description: `n8n code node "${nodeName}" uses dangerous APIs (exec/eval/fs/env)`,
343
+ severity: 'HIGH',
344
+ file: relPath,
345
+ line: ln,
346
+ evidence: 'code node references exec/eval/fs/process.env',
347
+ });
348
+ }
349
+ }
350
+
351
+ const flat = JSON.stringify(params);
352
+ const credMatch = /"(?:apiKey|token|password|secret|authorization)"\s*:\s*"((?:Bearer\s+)?[^"]{12,})"/i.exec(flat);
353
+ if (credMatch && !/\{\{|^=|^\$/.test(credMatch[1])) {
354
+ findings.push({
355
+ ruleId: 'n8n.inline-credential',
356
+ description: `n8n node "${nodeName}" contains an inline credential`,
357
+ severity: 'CRITICAL',
358
+ file: relPath,
359
+ line: ln,
360
+ evidence: mask(credMatch[1]),
361
+ });
362
+ }
363
+ }
364
+ }
365
+
366
+ function flagInstructionFile(relPath, findings) {
367
+ findings.push({
368
+ ruleId: 'ai.instruction-file',
369
+ description: 'AI agent instruction file present — review for prompt-injection or risky directives before handing to an agent',
370
+ severity: 'LOW',
371
+ file: relPath,
372
+ line: 1,
373
+ evidence: path.basename(relPath),
374
+ });
375
+ }
376
+
377
+ const INSTRUCTION_BASENAMES = new Set([
378
+ 'claude.md', 'agents.md', '.windsurfrules', '.cursorrules', 'gemini.md', 'copilot-instructions.md',
379
+ ]);
380
+
381
+ function isInstructionFile(relPath, base) {
382
+ const lower = base.toLowerCase();
383
+ if (INSTRUCTION_BASENAMES.has(lower)) return true;
384
+ const norm = relPath.replace(/\\/g, '/').toLowerCase();
385
+ if (norm.includes('.cursor/rules')) return true;
386
+ if (norm.endsWith('.github/copilot-instructions.md')) return true;
387
+ return false;
388
+ }
389
+
390
+ function isWorkflowFile(relPath) {
391
+ const norm = relPath.replace(/\\/g, '/').toLowerCase();
392
+ return /\.github\/workflows\/[^/]+\.ya?ml$/.test(norm);
393
+ }
394
+
395
+ function scanFile(absPath, relPath, findings, counters) {
396
+ let buf;
397
+ try {
398
+ buf = fs.readFileSync(absPath);
399
+ } catch {
400
+ counters.skipped++;
401
+ return;
402
+ }
403
+ if (isBinary(buf)) {
404
+ counters.skipped++;
405
+ return;
406
+ }
407
+ const text = buf.toString('utf8');
408
+ const lines = text.split(/\r?\n/);
409
+ const base = path.basename(relPath);
410
+ const lowerBase = base.toLowerCase();
411
+
412
+ counters.scanned++;
413
+
414
+ scanSecrets(relPath, lines, findings);
415
+
416
+ let jsonData;
417
+ if (lowerBase.endsWith('.json')) {
418
+ jsonData = parseJsonLoose(text);
419
+ }
420
+
421
+ if (lowerBase === 'package.json') {
422
+ scanPackageJson(relPath, text, findings, jsonData);
423
+ }
424
+
425
+ if (lowerBase === 'mcp.json' || lowerBase === '.mcp.json' || lowerBase === 'claude_desktop_config.json') {
426
+ scanMcpConfig(relPath, text, findings, jsonData);
427
+ }
428
+
429
+ if (isInstructionFile(relPath, base)) {
430
+ flagInstructionFile(relPath, findings);
431
+ }
432
+
433
+ if (isWorkflowFile(relPath)) {
434
+ scanGithubWorkflow(relPath, text, findings);
435
+ }
436
+
437
+ if (jsonData && looksLikeN8n(jsonData)) {
438
+ scanN8nWorkflow(relPath, text, jsonData, findings);
439
+ }
440
+ }
441
+
442
+ function walk(root, findings, counters) {
443
+ const stack = [root];
444
+ while (stack.length) {
445
+ const dir = stack.pop();
446
+ let entries;
447
+ try {
448
+ entries = fs.readdirSync(dir, { withFileTypes: true });
449
+ } catch {
450
+ continue;
451
+ }
452
+ for (const ent of entries) {
453
+ const abs = path.join(dir, ent.name);
454
+ if (ent.isSymbolicLink()) continue;
455
+ const isDir = ent.isDirectory();
456
+ const isFile = ent.isFile();
457
+ if (isDir) {
458
+ if (SKIP_DIRS.has(ent.name)) continue;
459
+ stack.push(abs);
460
+ continue;
461
+ }
462
+ if (!isFile) continue;
463
+ let stats;
464
+ try {
465
+ stats = fs.statSync(abs);
466
+ } catch {
467
+ counters.skipped++;
468
+ continue;
469
+ }
470
+ if (stats.size > MAX_FILE_SIZE) {
471
+ counters.skipped++;
472
+ continue;
473
+ }
474
+ const rel = path.relative(root, abs) || ent.name;
475
+ scanFile(abs, rel, findings, counters);
476
+ }
477
+ }
478
+ }
479
+
480
+ function dedupe(findings) {
481
+ const seen = new Set();
482
+ const out = [];
483
+ for (const f of findings) {
484
+ const key = [f.ruleId, f.file, f.line, f.evidence].join('|');
485
+ if (seen.has(key)) continue;
486
+ seen.add(key);
487
+ out.push(f);
488
+ }
489
+ return out;
490
+ }
491
+
492
+ function sortFindings(findings) {
493
+ return findings.sort((a, b) => {
494
+ const s = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
495
+ if (s !== 0) return s;
496
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
497
+ return a.line - b.line;
498
+ });
499
+ }
500
+
501
+ function printReport(findings, counters, root) {
502
+ const out = [];
503
+ out.push('');
504
+ out.push(color('bold', ' AI Agent Guard') + color('dim', ` v${VERSION}`));
505
+ out.push(color('dim', ` scanned: ${path.resolve(root)}`));
506
+ out.push('');
507
+
508
+ if (findings.length === 0) {
509
+ out.push(' ' + color('green', '✓ No issues found'));
510
+ } else {
511
+ let lastSeverity = null;
512
+ for (const f of findings) {
513
+ if (f.severity !== lastSeverity) {
514
+ out.push('');
515
+ out.push(' ' + color(f.severity, color('bold', f.severity)));
516
+ lastSeverity = f.severity;
517
+ }
518
+ const loc = color('cyan', `${f.file}:${f.line}`);
519
+ out.push(` ${color(f.severity, '●')} ${loc} ${color('dim', '[' + f.ruleId + ']')}`);
520
+ out.push(` ${f.description}`);
521
+ out.push(` ${color('dim', 'evidence:')} ${f.evidence}`);
522
+ }
523
+ }
524
+
525
+ out.push('');
526
+ out.push(color('dim', ' ' + '─'.repeat(48)));
527
+ const bySev = countBySeverity(findings);
528
+ out.push(` files scanned: ${counters.scanned} skipped: ${counters.skipped}`);
529
+ out.push(
530
+ ` findings: ` +
531
+ color('CRITICAL', `${bySev.CRITICAL} critical`) + ' ' +
532
+ color('HIGH', `${bySev.HIGH} high`) + ' ' +
533
+ color('MEDIUM', `${bySev.MEDIUM} medium`) + ' ' +
534
+ color('LOW', `${bySev.LOW} low`)
535
+ );
536
+ out.push('');
537
+ process.stdout.write(out.join('\n') + '\n');
538
+ }
539
+
540
+ function countBySeverity(findings) {
541
+ const c = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
542
+ for (const f of findings) c[f.severity]++;
543
+ return c;
544
+ }
545
+
546
+ function printHelp() {
547
+ process.stdout.write(`
548
+ ai-agent-guard v${VERSION}
549
+ Scan a project for security risks before handing it to an AI coding agent.
550
+
551
+ Usage:
552
+ npx ai-agent-guard [options]
553
+
554
+ Options:
555
+ --path <dir> Directory to scan (default: current directory)
556
+ --json Output findings as JSON
557
+ --no-color Disable colored output
558
+ --help, -h Show this help
559
+ --version, -v Show version
560
+
561
+ Checks: leaked secrets, risky MCP configs, AI instruction files,
562
+ GitHub Actions misconfig, dangerous package.json scripts, n8n workflows.
563
+
564
+ Private by design: runs locally, no network calls, no telemetry.
565
+ Exit code: 0 = clean, 1 = findings.
566
+ `);
567
+ }
568
+
569
+ function parseArgs(argv) {
570
+ const opts = { path: '.', json: false };
571
+ for (let i = 0; i < argv.length; i++) {
572
+ const a = argv[i];
573
+ if (a === '--help' || a === '-h') opts.help = true;
574
+ else if (a === '--version' || a === '-v') opts.version = true;
575
+ else if (a === '--json') opts.json = true;
576
+ else if (a === '--no-color') opts.noColor = true;
577
+ else if (a === '--path') { opts.path = argv[++i] || '.'; }
578
+ else if (a.startsWith('--path=')) { opts.path = a.slice(7); }
579
+ else if (!a.startsWith('-')) { opts.path = a; }
580
+ }
581
+ return opts;
582
+ }
583
+
584
+ function main() {
585
+ const opts = parseArgs(process.argv.slice(2));
586
+ if (opts.noColor) useColor = false;
587
+ if (opts.help) { printHelp(); process.exit(0); }
588
+ if (opts.version) { process.stdout.write(VERSION + '\n'); process.exit(0); }
589
+
590
+ const root = path.resolve(opts.path);
591
+ let st;
592
+ try {
593
+ st = fs.statSync(root);
594
+ } catch {
595
+ process.stderr.write(`error: path not found: ${root}\n`);
596
+ process.exit(2);
597
+ }
598
+ if (!st.isDirectory()) {
599
+ process.stderr.write(`error: not a directory: ${root}\n`);
600
+ process.exit(2);
601
+ }
602
+
603
+ const findings = [];
604
+ const counters = { scanned: 0, skipped: 0 };
605
+ walk(root, findings, counters);
606
+
607
+ const deduped = sortFindings(dedupe(findings));
608
+
609
+ if (opts.json) {
610
+ process.stdout.write(JSON.stringify({
611
+ version: VERSION,
612
+ scannedPath: root,
613
+ filesScanned: counters.scanned,
614
+ filesSkipped: counters.skipped,
615
+ summary: countBySeverity(deduped),
616
+ findings: deduped,
617
+ }, null, 2) + '\n');
618
+ } else {
619
+ printReport(deduped, counters, root);
620
+ }
621
+
622
+ process.exit(deduped.length > 0 ? 1 : 0);
623
+ }
624
+
625
+ main();
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@entet/ai-agent-guard",
3
+ "version": "0.1.0",
4
+ "description": "Scan a project for leaked secrets, risky MCP configs, and unsafe automation before handing it to an AI coding agent (Claude Code, Cursor, Codex). Zero dependencies, runs locally.",
5
+ "bin": {
6
+ "ai-agent-guard": "bin/ai-agent-guard.js"
7
+ },
8
+ "type": "commonjs",
9
+ "scripts": {
10
+ "test": "node test/run.js",
11
+ "selftest": "node bin/ai-agent-guard.js --path bin"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "keywords": [
19
+ "security",
20
+ "secrets",
21
+ "secret-scanner",
22
+ "ai",
23
+ "ai-agent",
24
+ "claude",
25
+ "claude-code",
26
+ "cursor",
27
+ "codex",
28
+ "mcp",
29
+ "prompt-injection",
30
+ "cli",
31
+ "static-analysis",
32
+ "devsecops"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "author": "ENTET Inc.",
38
+ "license": "MIT",
39
+ "homepage": "https://github.com/yuuritto/ai-agent-guard#readme",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/yuuritto/ai-agent-guard.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/yuuritto/ai-agent-guard/issues"
46
+ }
47
+ }