@musashimiyamoto/agent-guard 0.4.2 → 0.4.4
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 +2 -2
- package/src/cli.js +17 -4
- package/src/license.js +17 -6
- package/src/scanner.js +79 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@musashimiyamoto/agent-guard",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Security scanner for AI agent configurations. Detects misconfigurations, exposed secrets, and unsafe skill patterns.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -45,6 +45,6 @@
|
|
|
45
45
|
"README.md"
|
|
46
46
|
],
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"yaml": "^2.
|
|
48
|
+
"yaml": "^2.8.2"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/src/cli.js
CHANGED
|
@@ -9,7 +9,7 @@ import { resolve } from 'path';
|
|
|
9
9
|
import { createProxy } from './proxy/index.js';
|
|
10
10
|
import { getLicense, getUpgradePrompt } from './license.js';
|
|
11
11
|
|
|
12
|
-
const VERSION = '0.4.
|
|
12
|
+
const VERSION = '0.4.3';
|
|
13
13
|
|
|
14
14
|
const FEEDBACK_URL = 'https://github.com/MusashiMiyamoto1-cloud/agent-guard/issues/new';
|
|
15
15
|
const REPO_URL = 'https://github.com/MusashiMiyamoto1-cloud/agent-guard';
|
|
@@ -74,6 +74,7 @@ ${COLORS.bold}Options:${COLORS.reset}
|
|
|
74
74
|
--json Output results as JSON (Pro)
|
|
75
75
|
--quiet Only show findings (no banner)
|
|
76
76
|
--policy <file> Use custom policy file
|
|
77
|
+
--ignore-file <file> Read accepted risks from file (.agentguard.ignore)
|
|
77
78
|
|
|
78
79
|
${COLORS.bold}Free vs Pro:${COLORS.reset}
|
|
79
80
|
Free: 50 files, 10 findings, 5 basic rules
|
|
@@ -103,6 +104,10 @@ function printScore(report) {
|
|
|
103
104
|
|
|
104
105
|
const bar = '█'.repeat(Math.floor(score / 5)) + '░'.repeat(20 - Math.floor(score / 5));
|
|
105
106
|
|
|
107
|
+
const ignoredInfo = report.ignoredFindings > 0
|
|
108
|
+
? `\n ${COLORS.gray}Accepted risks: ${report.ignoredFindings} (skipped)${COLORS.reset}`
|
|
109
|
+
: '';
|
|
110
|
+
|
|
106
111
|
console.log(`
|
|
107
112
|
${COLORS.bold}Security Score:${COLORS.reset} ${gradeColor}${score}/100 (${grade})${COLORS.reset}
|
|
108
113
|
|
|
@@ -114,7 +119,7 @@ ${COLORS.bold}Summary:${COLORS.reset}
|
|
|
114
119
|
${COLORS.yellow}Medium:${COLORS.reset} ${report.summary.medium}
|
|
115
120
|
${COLORS.cyan}Low:${COLORS.reset} ${report.summary.low}
|
|
116
121
|
|
|
117
|
-
Files scanned: ${report.scannedFiles}
|
|
122
|
+
Files scanned: ${report.scannedFiles}${ignoredInfo}
|
|
118
123
|
`);
|
|
119
124
|
}
|
|
120
125
|
|
|
@@ -307,7 +312,15 @@ async function main() {
|
|
|
307
312
|
|
|
308
313
|
const jsonOutput = args.includes('--json');
|
|
309
314
|
const quiet = args.includes('--quiet');
|
|
310
|
-
|
|
315
|
+
|
|
316
|
+
// Parse --ignore-file option
|
|
317
|
+
let ignoreFile = null;
|
|
318
|
+
const ignoreFileIdx = args.findIndex(a => a === '--ignore-file');
|
|
319
|
+
if (ignoreFileIdx >= 0 && args[ignoreFileIdx + 1]) {
|
|
320
|
+
ignoreFile = args[ignoreFileIdx + 1];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const filteredArgs = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--ignore-file');
|
|
311
324
|
|
|
312
325
|
// Load license early
|
|
313
326
|
const license = await getLicense();
|
|
@@ -391,7 +404,7 @@ async function main() {
|
|
|
391
404
|
}
|
|
392
405
|
|
|
393
406
|
try {
|
|
394
|
-
const report = await scan(targetPath, { license });
|
|
407
|
+
const report = await scan(targetPath, { license, ignoreFile });
|
|
395
408
|
|
|
396
409
|
// Apply license limits
|
|
397
410
|
const limits = license.getLimits();
|
package/src/license.js
CHANGED
|
@@ -10,7 +10,7 @@ const CONFIG_DIR = join(homedir(), '.agent-guard');
|
|
|
10
10
|
const LICENSE_FILE = join(CONFIG_DIR, 'license.json');
|
|
11
11
|
|
|
12
12
|
// Keygen account & product IDs
|
|
13
|
-
const KEYGEN_ACCOUNT = process.env.KEYGEN_ACCOUNT || '
|
|
13
|
+
const KEYGEN_ACCOUNT = process.env.KEYGEN_ACCOUNT || 'musashimiyamoto1';
|
|
14
14
|
const KEYGEN_PRODUCT = '96d1b566-bd48-4bc9-9185-6ddbcb54683b';
|
|
15
15
|
const KEYGEN_API = `https://api.keygen.sh/v1/accounts/${KEYGEN_ACCOUNT}`;
|
|
16
16
|
|
|
@@ -150,17 +150,22 @@ export class License {
|
|
|
150
150
|
'FINGERPRINT_SCOPE_MISMATCH': 'License is activated on another machine. Deactivate first or upgrade to Team.',
|
|
151
151
|
'NO_MACHINES': 'License needs activation. Activating now...',
|
|
152
152
|
'NO_MACHINE': 'License needs activation. Activating now...',
|
|
153
|
+
'FINGERPRINT_SCOPE_REQUIRED': 'Activating machine...',
|
|
153
154
|
'EXPIRED': 'License has expired. Please renew.',
|
|
154
155
|
'SUSPENDED': 'License has been suspended. Contact support.',
|
|
155
156
|
'NOT_FOUND': 'Invalid license key.',
|
|
156
157
|
};
|
|
157
158
|
|
|
158
|
-
|
|
159
|
+
// These codes mean the license key itself is valid but needs machine activation
|
|
160
|
+
const needsActivation = ['NO_MACHINES', 'NO_MACHINE', 'FINGERPRINT_SCOPE_MISMATCH', 'FINGERPRINT_SCOPE_REQUIRED'];
|
|
161
|
+
|
|
162
|
+
if (needsActivation.includes(code)) {
|
|
159
163
|
// Try to activate this machine
|
|
160
164
|
const activateResult = await this.activateMachine(key, validateData.data?.id);
|
|
161
|
-
if (!activateResult.success) {
|
|
165
|
+
if (!activateResult.success && !activateResult.skipMachine) {
|
|
162
166
|
return activateResult;
|
|
163
167
|
}
|
|
168
|
+
// If skipMachine is true, the license is valid but policy doesn't require machine activation
|
|
164
169
|
} else {
|
|
165
170
|
return {
|
|
166
171
|
success: false,
|
|
@@ -216,11 +221,12 @@ export class License {
|
|
|
216
221
|
|
|
217
222
|
async activateMachine(licenseKey, licenseId) {
|
|
218
223
|
try {
|
|
224
|
+
// Use the license key as Bearer token for machine activation
|
|
219
225
|
const res = await fetch(`${KEYGEN_API}/machines`, {
|
|
220
226
|
method: 'POST',
|
|
221
227
|
headers: {
|
|
222
|
-
'Content-Type': 'application/json',
|
|
223
|
-
'Accept': 'application/json',
|
|
228
|
+
'Content-Type': 'application/vnd.api+json',
|
|
229
|
+
'Accept': 'application/vnd.api+json',
|
|
224
230
|
'Authorization': `License ${licenseKey}`,
|
|
225
231
|
},
|
|
226
232
|
body: JSON.stringify({
|
|
@@ -250,10 +256,15 @@ export class License {
|
|
|
250
256
|
message: 'Machine limit reached. Deactivate another machine or upgrade.'
|
|
251
257
|
};
|
|
252
258
|
}
|
|
259
|
+
if (err.code === 'FORBIDDEN' || err.detail?.includes('not allowed')) {
|
|
260
|
+
// Policy doesn't allow license key auth - but license is valid
|
|
261
|
+
// Return success to use the license without machine activation
|
|
262
|
+
return { success: true, skipMachine: true };
|
|
263
|
+
}
|
|
253
264
|
return { success: false, message: err.detail || 'Activation failed' };
|
|
254
265
|
}
|
|
255
266
|
|
|
256
|
-
return { success: true };
|
|
267
|
+
return { success: true, machineId: data.data?.id };
|
|
257
268
|
} catch (err) {
|
|
258
269
|
return { success: false, message: err.message };
|
|
259
270
|
}
|
package/src/scanner.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// Phase 1: Configuration & Secret Scanner
|
|
3
3
|
|
|
4
4
|
import { readdir, readFile, stat } from 'fs/promises';
|
|
5
|
-
import { join, basename, extname } from 'path';
|
|
5
|
+
import { join, basename, extname, relative } from 'path';
|
|
6
|
+
import { existsSync } from 'fs';
|
|
6
7
|
import { rules } from './rules/index.js';
|
|
8
|
+
import { parse as parseYAML } from 'yaml';
|
|
7
9
|
|
|
8
10
|
const IGNORE_DIRS = [
|
|
9
11
|
'node_modules', '.git', '.venv', '__pycache__',
|
|
@@ -21,10 +23,13 @@ export class Scanner {
|
|
|
21
23
|
constructor(options = {}) {
|
|
22
24
|
this.findings = [];
|
|
23
25
|
this.scannedFiles = 0;
|
|
26
|
+
this.ignoredFindings = 0;
|
|
27
|
+
this.acceptedRisks = [];
|
|
24
28
|
this.options = {
|
|
25
29
|
maxFileSize: options.maxFileSize || 1024 * 1024, // 1MB
|
|
26
30
|
followSymlinks: options.followSymlinks || false,
|
|
27
31
|
maxFiles: options.maxFiles || Infinity,
|
|
32
|
+
ignoreFile: options.ignoreFile || null,
|
|
28
33
|
...options
|
|
29
34
|
};
|
|
30
35
|
|
|
@@ -34,9 +39,65 @@ export class Scanner {
|
|
|
34
39
|
: rules.filter(r => BASIC_RULE_IDS.includes(r.id));
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
async loadIgnoreFile(basePath) {
|
|
43
|
+
// Look for ignore file
|
|
44
|
+
const ignoreFilePath = this.options.ignoreFile || join(basePath, '.agentguard.ignore');
|
|
45
|
+
|
|
46
|
+
if (!existsSync(ignoreFilePath)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const content = await readFile(ignoreFilePath, 'utf-8');
|
|
52
|
+
const parsed = parseYAML(content);
|
|
53
|
+
|
|
54
|
+
if (parsed?.accepted_risks && Array.isArray(parsed.accepted_risks)) {
|
|
55
|
+
this.acceptedRisks = parsed.accepted_risks;
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Invalid ignore file, skip
|
|
59
|
+
console.warn(`Warning: Could not parse ${ignoreFilePath}: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
isAcceptedRisk(rule, filePath, lineNum) {
|
|
64
|
+
if (this.acceptedRisks.length === 0) return false;
|
|
65
|
+
|
|
66
|
+
for (const risk of this.acceptedRisks) {
|
|
67
|
+
// Match rule
|
|
68
|
+
if (risk.rule && risk.rule !== rule) continue;
|
|
69
|
+
|
|
70
|
+
// Match file (supports glob patterns)
|
|
71
|
+
if (risk.file) {
|
|
72
|
+
const riskFile = risk.file;
|
|
73
|
+
if (riskFile.includes('*')) {
|
|
74
|
+
// Glob pattern
|
|
75
|
+
const regex = new RegExp('^' + riskFile.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$');
|
|
76
|
+
if (!regex.test(filePath)) continue;
|
|
77
|
+
} else {
|
|
78
|
+
// Exact match or suffix match
|
|
79
|
+
if (!filePath.endsWith(riskFile) && filePath !== riskFile) continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Match line (optional)
|
|
84
|
+
if (risk.line !== undefined && risk.line !== null && risk.line !== lineNum) continue;
|
|
85
|
+
|
|
86
|
+
// All criteria matched - this is an accepted risk
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
37
93
|
async scan(targetPath) {
|
|
38
94
|
this.findings = [];
|
|
39
95
|
this.scannedFiles = 0;
|
|
96
|
+
this.ignoredFindings = 0;
|
|
97
|
+
this.basePath = targetPath;
|
|
98
|
+
|
|
99
|
+
// Load accepted risks from ignore file
|
|
100
|
+
await this.loadIgnoreFile(targetPath);
|
|
40
101
|
|
|
41
102
|
const stats = await stat(targetPath);
|
|
42
103
|
|
|
@@ -191,6 +252,21 @@ export class Scanner {
|
|
|
191
252
|
}
|
|
192
253
|
}
|
|
193
254
|
|
|
255
|
+
// Deduplicate key for this finding
|
|
256
|
+
const findingKey = `${rule.id}:${filePath}:${lineNum}`;
|
|
257
|
+
|
|
258
|
+
// Check if this is an accepted risk
|
|
259
|
+
const relPath = this.basePath ? relative(this.basePath, filePath) : filePath;
|
|
260
|
+
if (this.isAcceptedRisk(rule.id, relPath, lineNum) || this.isAcceptedRisk(rule.id, filePath, lineNum)) {
|
|
261
|
+
// Only count once per unique finding
|
|
262
|
+
if (!this.ignoredFindingKeys) this.ignoredFindingKeys = new Set();
|
|
263
|
+
if (!this.ignoredFindingKeys.has(findingKey)) {
|
|
264
|
+
this.ignoredFindingKeys.add(findingKey);
|
|
265
|
+
this.ignoredFindings++;
|
|
266
|
+
}
|
|
267
|
+
return; // Skip accepted risks
|
|
268
|
+
}
|
|
269
|
+
|
|
194
270
|
// Deduplicate: skip if same rule+file+line already recorded
|
|
195
271
|
const isDuplicate = this.findings.some(f =>
|
|
196
272
|
f.rule === rule.id && f.file === filePath && f.line === lineNum
|
|
@@ -254,6 +330,8 @@ export class Scanner {
|
|
|
254
330
|
grade,
|
|
255
331
|
scannedFiles: this.scannedFiles,
|
|
256
332
|
totalFindings: this.findings.length,
|
|
333
|
+
ignoredFindings: this.ignoredFindings,
|
|
334
|
+
acceptedRisks: this.acceptedRisks.length,
|
|
257
335
|
summary: {
|
|
258
336
|
critical: critical.length,
|
|
259
337
|
high: high.length,
|