ship-safe 6.4.0 → 8.0.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/README.md +80 -23
- package/cli/agents/agent-attestation-agent.js +318 -0
- package/cli/agents/agent-config-scanner.js +15 -0
- package/cli/agents/agentic-security-agent.js +35 -0
- package/cli/agents/cicd-scanner.js +22 -0
- package/cli/agents/config-auditor.js +235 -0
- package/cli/agents/deep-analyzer.js +39 -19
- package/cli/agents/hermes-security-agent.js +536 -0
- package/cli/agents/index.js +65 -21
- package/cli/agents/managed-agent-scanner.js +333 -0
- package/cli/agents/memory-poisoning-agent.js +304 -0
- package/cli/agents/scoring-engine.js +16 -1
- package/cli/agents/supply-chain-agent.js +129 -3
- package/cli/bin/ship-safe.js +178 -5
- package/cli/commands/audit.js +116 -2
- package/cli/commands/autofix.js +383 -0
- package/cli/commands/env-audit.js +349 -0
- package/cli/commands/live-advisories.js +241 -0
- package/cli/commands/red-team.js +2 -2
- package/cli/commands/scan-mcp.js +78 -0
- package/cli/commands/scan-skill.js +248 -5
- package/cli/commands/watch.js +205 -0
- package/cli/index.js +5 -0
- package/cli/providers/llm-provider.js +89 -1
- package/cli/utils/compliance-map.js +66 -0
- package/cli/utils/hermes-tool-registry.js +252 -0
- package/cli/utils/patterns.js +1 -0
- package/cli/utils/plugin-loader.js +276 -0
- package/cli/utils/scan-playbook.js +312 -0
- package/cli/utils/security-memory.js +296 -0
- package/package.json +2 -2
|
@@ -19,6 +19,59 @@ import { createHash } from 'crypto';
|
|
|
19
19
|
import * as output from '../utils/output.js';
|
|
20
20
|
import { ThreatIntel } from '../utils/threat-intel.js';
|
|
21
21
|
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// HERMES SKILL FRONTMATTER PATTERNS (Track D — cross-skill/tool binding)
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
// Built-in tool registries that skills may reference.
|
|
27
|
+
// Ship Safe tools are added lazily in checkHermesFrontmatter() to avoid
|
|
28
|
+
// loading hermes-tool-registry.js (and its crypto import) on every invocation.
|
|
29
|
+
const KNOWN_TOOL_REGISTRIES = {
|
|
30
|
+
// Common Hermes community tools (names only — no handler)
|
|
31
|
+
'web_search': 'hermes-community',
|
|
32
|
+
'web_browser': 'hermes-community',
|
|
33
|
+
'file_read': 'hermes-community',
|
|
34
|
+
'file_write': 'hermes-community',
|
|
35
|
+
'code_execute': 'hermes-community',
|
|
36
|
+
'github_api': 'hermes-community',
|
|
37
|
+
'memory_store': 'hermes-community',
|
|
38
|
+
'memory_retrieve': 'hermes-community',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Hermes-specific patterns to check in skill markdown/frontmatter
|
|
42
|
+
const HERMES_SKILL_PATTERNS = [
|
|
43
|
+
{
|
|
44
|
+
name: 'Hermes: XML tool_call injection',
|
|
45
|
+
regex: /<tool_call>[\s\S]{0,300}<\/tool_call>/gi,
|
|
46
|
+
severity: 'critical',
|
|
47
|
+
note: 'Skill body contains a <tool_call> block — will be executed by Hermes agents that load this skill.',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'Hermes: function_calls injection',
|
|
51
|
+
regex: /<function_calls>[\s\S]{0,300}<\/function_calls>/gi,
|
|
52
|
+
severity: 'critical',
|
|
53
|
+
note: 'Skill body contains a <function_calls> block — classic Hermes function-call injection.',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'Hermes: Forced tool invocation instruction',
|
|
57
|
+
regex: /(?:you\s+must\s+(?:call|invoke|use)\s+(?:the\s+)?tool|always\s+(?:call|invoke|run)\s+(?:the\s+)?(?:tool|function)|tool\s+MUST\s+be\s+(?:called|invoked|used))/gi,
|
|
58
|
+
severity: 'high',
|
|
59
|
+
note: 'Skill instructs agent to call a specific tool unconditionally — bypasses agent autonomy.',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'Hermes: Plan/goal hijacking',
|
|
63
|
+
regex: /(?:update\s+(?:your\s+)?(?:goal|plan|objective)\s+to|change\s+(?:your\s+)?(?:goal|plan|objective)|your\s+(?:new\s+)?(?:goal|plan|primary\s+objective)\s+(?:is|should\s+be))/gi,
|
|
64
|
+
severity: 'critical',
|
|
65
|
+
note: 'Skill attempts to overwrite the agent\'s goal or plan state — ASI-01 Goal Hijacking.',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'Hermes: Memory layer write instruction',
|
|
69
|
+
regex: /(?:write\s+(?:this|the\s+following)\s+to\s+(?:memory|episodic|semantic|working)\s+memory|store\s+(?:this|the\s+following)\s+in\s+(?:memory|episodic|semantic))/gi,
|
|
70
|
+
severity: 'high',
|
|
71
|
+
note: 'Skill instructs agent to write attacker-controlled data to memory — ASI-06 Memory Poisoning.',
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
22
75
|
// =============================================================================
|
|
23
76
|
// POPULAR SKILL NAMES (for typosquatting detection)
|
|
24
77
|
// =============================================================================
|
|
@@ -113,7 +166,7 @@ export async function scanSkillCommand(target, options = {}) {
|
|
|
113
166
|
console.log(chalk.gray(` Size: ${content.length} bytes`));
|
|
114
167
|
console.log();
|
|
115
168
|
|
|
116
|
-
const findings = analyzeSkill(content, skillName, source);
|
|
169
|
+
const findings = await analyzeSkill(content, skillName, source);
|
|
117
170
|
|
|
118
171
|
if (options.json) {
|
|
119
172
|
console.log(JSON.stringify({ skill: skillName, source, findings, summary: getSummary(findings) }, null, 2));
|
|
@@ -127,7 +180,7 @@ export async function scanSkillCommand(target, options = {}) {
|
|
|
127
180
|
// SKILL ANALYSIS
|
|
128
181
|
// =============================================================================
|
|
129
182
|
|
|
130
|
-
function analyzeSkill(content, skillName, source) {
|
|
183
|
+
async function analyzeSkill(content, skillName, source) {
|
|
131
184
|
const findings = [];
|
|
132
185
|
|
|
133
186
|
// 1. Static pattern analysis
|
|
@@ -152,10 +205,12 @@ function analyzeSkill(content, skillName, source) {
|
|
|
152
205
|
try {
|
|
153
206
|
const manifest = JSON.parse(content);
|
|
154
207
|
if (manifest.permissions) {
|
|
155
|
-
const
|
|
208
|
+
const dangerousPerm = [/\bshell\b/i, /\bexec\b/i, /\bsystem\b/i, /\badmin\b/i, /\broot\b/i,
|
|
209
|
+
/filesystem\s*:\s*(write|read-write)/i, /network\s*:\s*(unrestricted|all)/i,
|
|
210
|
+
/^filesystem$/i, /^network$/i];
|
|
156
211
|
for (const perm of (Array.isArray(manifest.permissions) ? manifest.permissions : [])) {
|
|
157
212
|
const permStr = typeof perm === 'string' ? perm : perm.name || '';
|
|
158
|
-
if (
|
|
213
|
+
if (dangerousPerm.some(p => p.test(permStr))) {
|
|
159
214
|
findings.push({
|
|
160
215
|
check: 'permission-audit',
|
|
161
216
|
name: `Dangerous permission: ${permStr}`,
|
|
@@ -216,6 +271,194 @@ function analyzeSkill(content, skillName, source) {
|
|
|
216
271
|
});
|
|
217
272
|
}
|
|
218
273
|
|
|
274
|
+
// 6. Hermes-specific: frontmatter tool binding + permission drift validation
|
|
275
|
+
findings.push(...(await checkHermesFrontmatter(content)));
|
|
276
|
+
|
|
277
|
+
// 7. Hermes-specific: function-call injection and goal hijacking in body
|
|
278
|
+
findings.push(...checkHermesBodyPatterns(content, lines));
|
|
279
|
+
|
|
280
|
+
return findings;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// =============================================================================
|
|
284
|
+
// HERMES FRONTMATTER VALIDATION (Track D)
|
|
285
|
+
// =============================================================================
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Parse YAML frontmatter block (between --- delimiters) from markdown skill.
|
|
289
|
+
* Returns a plain object with string/array values; null if no frontmatter.
|
|
290
|
+
*/
|
|
291
|
+
function parseFrontmatter(content) {
|
|
292
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
293
|
+
if (!match) return null;
|
|
294
|
+
|
|
295
|
+
const fm = {};
|
|
296
|
+
const yamlBlock = match[1];
|
|
297
|
+
|
|
298
|
+
for (const line of yamlBlock.split('\n')) {
|
|
299
|
+
const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
300
|
+
if (!kv) continue;
|
|
301
|
+
const [, key, rawVal] = kv;
|
|
302
|
+
const val = rawVal.trim();
|
|
303
|
+
|
|
304
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
305
|
+
// Inline array: [a, b, c]
|
|
306
|
+
fm[key] = val.slice(1, -1).split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean);
|
|
307
|
+
} else {
|
|
308
|
+
fm[key] = val.replace(/^['"]|['"]$/g, '');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Collect multi-line list values (indented - items)
|
|
313
|
+
const listRe = /^(\w[\w-]*):\s*\n((?:\s+-\s+.+\n?)+)/gm;
|
|
314
|
+
let m;
|
|
315
|
+
while ((m = listRe.exec(yamlBlock)) !== null) {
|
|
316
|
+
const [, key, block] = m;
|
|
317
|
+
fm[key] = block.match(/-\s+(.+)/g)?.map(s => s.replace(/^-\s+/, '').replace(/['"]/g, '').trim()) ?? [];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return fm;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let _hermesToolsLoaded = false;
|
|
324
|
+
async function ensureHermesToolsLoaded() {
|
|
325
|
+
if (_hermesToolsLoaded) return;
|
|
326
|
+
try {
|
|
327
|
+
const { HERMES_TOOLS } = await import('../utils/hermes-tool-registry.js');
|
|
328
|
+
for (const t of HERMES_TOOLS) KNOWN_TOOL_REGISTRIES[t.name] = 'ship-safe';
|
|
329
|
+
} catch { /* non-fatal — registry unavailable */ }
|
|
330
|
+
_hermesToolsLoaded = true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function checkHermesFrontmatter(content) {
|
|
334
|
+
await ensureHermesToolsLoaded();
|
|
335
|
+
const findings = [];
|
|
336
|
+
const fm = parseFrontmatter(content);
|
|
337
|
+
|
|
338
|
+
// Not a markdown skill with frontmatter — skip
|
|
339
|
+
if (!fm) return findings;
|
|
340
|
+
|
|
341
|
+
// ── Check: missing permissions field ──────────────────────────────────────
|
|
342
|
+
if (!fm.permissions) {
|
|
343
|
+
findings.push({
|
|
344
|
+
check: 'hermes-frontmatter',
|
|
345
|
+
name: 'Hermes: Skill missing permissions field (ASI-02 Excessive Agency)',
|
|
346
|
+
severity: 'medium',
|
|
347
|
+
line: 0,
|
|
348
|
+
matched: 'No permissions: field in frontmatter — skill may be granted more access than intended',
|
|
349
|
+
});
|
|
350
|
+
} else {
|
|
351
|
+
// ── Check: wildcard permissions ──────────────────────────────────────────
|
|
352
|
+
const perms = Array.isArray(fm.permissions) ? fm.permissions : [fm.permissions];
|
|
353
|
+
const wildcards = perms.filter(p => /^\*$|^all$|^any$/i.test(String(p)));
|
|
354
|
+
if (wildcards.length > 0) {
|
|
355
|
+
findings.push({
|
|
356
|
+
check: 'hermes-frontmatter',
|
|
357
|
+
name: 'Hermes: Wildcard permissions (* / all) — excessive agency (ASI-02)',
|
|
358
|
+
severity: 'high',
|
|
359
|
+
line: 0,
|
|
360
|
+
matched: `permissions: [${wildcards.join(', ')}]`,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Check: dangerous explicit permissions ────────────────────────────────
|
|
365
|
+
// Match whole-word or exact qualified values — don't fire on "filesystem: read-only"
|
|
366
|
+
const dangerousPatterns = [
|
|
367
|
+
/\bshell\b/i, /\bexec\b/i, /\bsystem\b/i, /\badmin\b/i, /\broot\b/i, /\bsudo\b/i,
|
|
368
|
+
/filesystem\s*:\s*write/i, /filesystem\s*:\s*read-write/i,
|
|
369
|
+
/network\s*:\s*unrestricted/i, /network\s*:\s*all/i,
|
|
370
|
+
/^filesystem$/i, /^network$/i, // bare "filesystem" or "network" without qualifier is ambiguous → flag
|
|
371
|
+
];
|
|
372
|
+
for (const perm of perms) {
|
|
373
|
+
if (dangerousPatterns.some(p => p.test(String(perm)))) {
|
|
374
|
+
findings.push({
|
|
375
|
+
check: 'hermes-frontmatter',
|
|
376
|
+
name: `Hermes: Dangerous permission declared: ${perm}`,
|
|
377
|
+
severity: 'high',
|
|
378
|
+
line: 0,
|
|
379
|
+
matched: `permissions: [${perm}]`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Check: missing version pin ────────────────────────────────────────────
|
|
386
|
+
if (!fm.version) {
|
|
387
|
+
findings.push({
|
|
388
|
+
check: 'hermes-frontmatter',
|
|
389
|
+
name: 'Hermes: Skill missing version field — unpinned skill (ASI-10 Supply Chain)',
|
|
390
|
+
severity: 'medium',
|
|
391
|
+
line: 0,
|
|
392
|
+
matched: 'No version: field in frontmatter — skill version drift cannot be detected',
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── Check: cross-skill tool binding validation ────────────────────────────
|
|
397
|
+
const tools = Array.isArray(fm.tools) ? fm.tools : fm.tools ? [fm.tools] : [];
|
|
398
|
+
for (const toolName of tools) {
|
|
399
|
+
if (!KNOWN_TOOL_REGISTRIES[toolName]) {
|
|
400
|
+
findings.push({
|
|
401
|
+
check: 'hermes-tool-binding',
|
|
402
|
+
name: `Hermes: Unresolvable tool reference: "${toolName}"`,
|
|
403
|
+
severity: 'high',
|
|
404
|
+
line: 0,
|
|
405
|
+
matched: `tools: [${toolName}] — not found in any known tool registry. May cause silent failures or late-binding substitution.`,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Check: tools declared but no permissions field ────────────────────────
|
|
411
|
+
if (tools.length > 0 && !fm.permissions) {
|
|
412
|
+
findings.push({
|
|
413
|
+
check: 'hermes-tool-binding',
|
|
414
|
+
name: 'Hermes: Skill declares tools without permissions (permission drift)',
|
|
415
|
+
severity: 'high',
|
|
416
|
+
line: 0,
|
|
417
|
+
matched: `tools: [${tools.join(', ')}] declared but no permissions: field — skill runs with ambient agent permissions`,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return findings;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function checkHermesBodyPatterns(content, lines) {
|
|
425
|
+
const findings = [];
|
|
426
|
+
|
|
427
|
+
for (let i = 0; i < lines.length; i++) {
|
|
428
|
+
const line = lines[i];
|
|
429
|
+
for (const pattern of HERMES_SKILL_PATTERNS) {
|
|
430
|
+
pattern.regex.lastIndex = 0;
|
|
431
|
+
if (pattern.regex.test(line)) {
|
|
432
|
+
findings.push({
|
|
433
|
+
check: 'hermes-injection',
|
|
434
|
+
name: pattern.name,
|
|
435
|
+
severity: pattern.severity,
|
|
436
|
+
line: i + 1,
|
|
437
|
+
matched: line.trim().slice(0, 100),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Multi-line checks for <tool_call> blocks that span lines
|
|
444
|
+
for (const pattern of HERMES_SKILL_PATTERNS) {
|
|
445
|
+
pattern.regex.lastIndex = 0;
|
|
446
|
+
const match = pattern.regex.exec(content);
|
|
447
|
+
if (match) {
|
|
448
|
+
// Avoid duplicate if already caught line-by-line
|
|
449
|
+
const alreadyFound = findings.some(f => f.name === pattern.name);
|
|
450
|
+
if (!alreadyFound) {
|
|
451
|
+
findings.push({
|
|
452
|
+
check: 'hermes-injection',
|
|
453
|
+
name: pattern.name,
|
|
454
|
+
severity: pattern.severity,
|
|
455
|
+
line: 0,
|
|
456
|
+
matched: match[0].slice(0, 100),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
219
462
|
return findings;
|
|
220
463
|
}
|
|
221
464
|
|
|
@@ -281,7 +524,7 @@ async function scanAllSkills(rootPath) {
|
|
|
281
524
|
const response = await fetch(url);
|
|
282
525
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
283
526
|
const content = await response.text();
|
|
284
|
-
const findings = analyzeSkill(content, name, url);
|
|
527
|
+
const findings = await analyzeSkill(content, name, url);
|
|
285
528
|
if (findings.length > 0) {
|
|
286
529
|
printSkillFindings(findings, name);
|
|
287
530
|
} else {
|
package/cli/commands/watch.js
CHANGED
|
@@ -16,6 +16,7 @@ import chalk from 'chalk';
|
|
|
16
16
|
import { SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, SECRET_PATTERNS, SECURITY_PATTERNS } from '../utils/patterns.js';
|
|
17
17
|
import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
|
|
18
18
|
import * as output from '../utils/output.js';
|
|
19
|
+
import { ScoringEngine } from '../agents/scoring-engine.js';
|
|
19
20
|
|
|
20
21
|
// Agent config files to watch
|
|
21
22
|
const AGENT_CONFIG_PATTERNS = [
|
|
@@ -26,6 +27,10 @@ const AGENT_CONFIG_PATTERNS = [
|
|
|
26
27
|
'.cursor/mcp.json', '.vscode/mcp.json',
|
|
27
28
|
];
|
|
28
29
|
|
|
30
|
+
// Watch state persistence
|
|
31
|
+
const WATCH_DB_DIR = '.ship-safe';
|
|
32
|
+
const WATCH_DB_FILE = 'watch.json';
|
|
33
|
+
|
|
29
34
|
export async function watchCommand(targetPath = '.', options = {}) {
|
|
30
35
|
const absolutePath = path.resolve(targetPath);
|
|
31
36
|
|
|
@@ -34,15 +39,26 @@ export async function watchCommand(targetPath = '.', options = {}) {
|
|
|
34
39
|
process.exit(1);
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
// Status mode: print current watch state and exit
|
|
43
|
+
if (options.status) {
|
|
44
|
+
return showWatchStatus(absolutePath);
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
// Config-only watch mode
|
|
38
48
|
if (options.configs) {
|
|
39
49
|
return watchConfigs(absolutePath);
|
|
40
50
|
}
|
|
41
51
|
|
|
52
|
+
// Deep mode: run full orchestrator on changes
|
|
53
|
+
if (options.deep) {
|
|
54
|
+
return watchDeep(absolutePath, options);
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
console.log();
|
|
43
58
|
output.header('Ship Safe — Watch Mode');
|
|
44
59
|
console.log();
|
|
45
60
|
console.log(chalk.cyan(' Watching for file changes...'));
|
|
61
|
+
console.log(chalk.gray(' Use --deep for full agent scanning, --status for current findings'));
|
|
46
62
|
console.log(chalk.gray(' Press Ctrl+C to stop'));
|
|
47
63
|
console.log();
|
|
48
64
|
|
|
@@ -230,6 +246,195 @@ async function watchConfigs(absolutePath) {
|
|
|
230
246
|
}
|
|
231
247
|
}
|
|
232
248
|
|
|
249
|
+
// =============================================================================
|
|
250
|
+
// STATUS MODE
|
|
251
|
+
// =============================================================================
|
|
252
|
+
|
|
253
|
+
function showWatchStatus(rootPath) {
|
|
254
|
+
const dbFile = path.join(rootPath, WATCH_DB_DIR, WATCH_DB_FILE);
|
|
255
|
+
if (!fs.existsSync(dbFile)) {
|
|
256
|
+
console.log('\n No watch data found. Run: ship-safe watch . --deep\n');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const data = JSON.parse(fs.readFileSync(dbFile, 'utf-8'));
|
|
262
|
+
console.log(`\n ${chalk.cyan.bold('Ship Safe Watch — Status')}`);
|
|
263
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
264
|
+
console.log(` Last scan: ${data.lastScan || 'never'}`);
|
|
265
|
+
console.log(` Scans run: ${data.scanCount || 0}`);
|
|
266
|
+
console.log(` Score: ${data.score?.score ?? '?'}/100 ${data.score?.grade ?? ''}`);
|
|
267
|
+
console.log(` Findings: ${data.score?.totalFindings ?? 0}`);
|
|
268
|
+
|
|
269
|
+
if (data.agentic) {
|
|
270
|
+
console.log(` Agentic: ${data.agentic.flagged}/${data.agentic.total} OWASP Agentic risks flagged`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Severity breakdown
|
|
274
|
+
const sevCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
275
|
+
for (const f of (data.findings || [])) {
|
|
276
|
+
sevCounts[f.severity] = (sevCounts[f.severity] || 0) + 1;
|
|
277
|
+
}
|
|
278
|
+
console.log(` Critical: ${sevCounts.critical}`);
|
|
279
|
+
console.log(` High: ${sevCounts.high}`);
|
|
280
|
+
console.log(` Medium: ${sevCounts.medium}`);
|
|
281
|
+
console.log(` Low: ${sevCounts.low}\n`);
|
|
282
|
+
} catch {
|
|
283
|
+
console.log('\n Failed to read watch data. File may be corrupted.\n');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// =============================================================================
|
|
288
|
+
// DEEP WATCH MODE (full orchestrator)
|
|
289
|
+
// =============================================================================
|
|
290
|
+
|
|
291
|
+
async function watchDeep(absolutePath, options = {}) {
|
|
292
|
+
const { buildOrchestrator } = await import('../agents/index.js');
|
|
293
|
+
const { ReconAgent } = await import('../agents/recon-agent.js');
|
|
294
|
+
|
|
295
|
+
const debounceMs = options.debounce || 1500;
|
|
296
|
+
const threshold = options.threshold || null;
|
|
297
|
+
const scoringEngine = new ScoringEngine();
|
|
298
|
+
|
|
299
|
+
console.log();
|
|
300
|
+
output.header('Ship Safe — Deep Watch Mode');
|
|
301
|
+
console.log();
|
|
302
|
+
console.log(chalk.cyan(' Running full agent scans on file changes'));
|
|
303
|
+
console.log(chalk.gray(` Debounce: ${debounceMs}ms`));
|
|
304
|
+
if (threshold) console.log(chalk.gray(` Threshold: ${threshold}/100`));
|
|
305
|
+
console.log(chalk.gray(' Press Ctrl+C to stop'));
|
|
306
|
+
console.log();
|
|
307
|
+
|
|
308
|
+
// Initial recon
|
|
309
|
+
const reconAgent = new ReconAgent();
|
|
310
|
+
console.log(chalk.gray(' Running initial recon...'));
|
|
311
|
+
let recon;
|
|
312
|
+
try {
|
|
313
|
+
const reconResults = await reconAgent.analyze({ rootPath: absolutePath });
|
|
314
|
+
recon = Array.isArray(reconResults) ? {} : reconResults;
|
|
315
|
+
} catch { recon = {}; }
|
|
316
|
+
console.log(chalk.gray(' Recon complete. Watching...\n'));
|
|
317
|
+
|
|
318
|
+
let pendingFiles = new Set();
|
|
319
|
+
let debounceTimer = null;
|
|
320
|
+
let scanCount = 0;
|
|
321
|
+
|
|
322
|
+
const dbDir = path.join(absolutePath, WATCH_DB_DIR);
|
|
323
|
+
const dbFile = path.join(dbDir, WATCH_DB_FILE);
|
|
324
|
+
|
|
325
|
+
const processChanges = async () => {
|
|
326
|
+
const files = [...pendingFiles];
|
|
327
|
+
pendingFiles.clear();
|
|
328
|
+
if (files.length === 0) return;
|
|
329
|
+
|
|
330
|
+
scanCount++;
|
|
331
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
332
|
+
console.log(chalk.gray(` [${timestamp}] ${files.length} file(s) changed — deep scanning...`));
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const orchestrator = buildOrchestrator();
|
|
336
|
+
const context = {
|
|
337
|
+
rootPath: absolutePath,
|
|
338
|
+
files,
|
|
339
|
+
changedFiles: files,
|
|
340
|
+
recon,
|
|
341
|
+
options: { incremental: true },
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const findings = await orchestrator.run(context);
|
|
345
|
+
const scoreResult = scoringEngine.compute(findings);
|
|
346
|
+
|
|
347
|
+
// Persist results
|
|
348
|
+
try {
|
|
349
|
+
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
|
|
350
|
+
fs.writeFileSync(dbFile, JSON.stringify({
|
|
351
|
+
lastScan: new Date().toISOString(),
|
|
352
|
+
scanCount,
|
|
353
|
+
score: {
|
|
354
|
+
score: scoreResult.score,
|
|
355
|
+
grade: scoreResult.grade?.letter,
|
|
356
|
+
totalFindings: scoreResult.totalFindings,
|
|
357
|
+
},
|
|
358
|
+
agentic: scoreResult.agenticSummary
|
|
359
|
+
? { flagged: scoreResult.agenticSummary.flagged, total: scoreResult.agenticSummary.total }
|
|
360
|
+
: null,
|
|
361
|
+
findings: findings.map(f => ({
|
|
362
|
+
file: path.relative(absolutePath, f.file || ''),
|
|
363
|
+
line: f.line,
|
|
364
|
+
severity: f.severity,
|
|
365
|
+
rule: f.rule,
|
|
366
|
+
title: f.title,
|
|
367
|
+
agenticRisk: f.agenticRisk || null,
|
|
368
|
+
})),
|
|
369
|
+
}, null, 2));
|
|
370
|
+
} catch { /* non-fatal */ }
|
|
371
|
+
|
|
372
|
+
// Output
|
|
373
|
+
const criticals = findings.filter(f => f.severity === 'critical').length;
|
|
374
|
+
const highs = findings.filter(f => f.severity === 'high').length;
|
|
375
|
+
|
|
376
|
+
if (findings.length === 0) {
|
|
377
|
+
console.log(chalk.green(` [${timestamp}] ✔ Clean — Score: ${scoreResult.score}/100 ${scoreResult.grade?.letter}\n`));
|
|
378
|
+
} else {
|
|
379
|
+
const scoreColor = scoreResult.score >= 75 ? chalk.cyan : scoreResult.score >= 50 ? chalk.yellow : chalk.red;
|
|
380
|
+
console.log(` [${timestamp}] ${chalk.white(`${findings.length} finding(s)`)}: ${criticals ? chalk.red.bold(`${criticals} critical`) : ''}${criticals && highs ? ', ' : ''}${highs ? chalk.yellow(`${highs} high`) : ''}. Score: ${scoreColor(`${scoreResult.score}/100 ${scoreResult.grade?.letter}`)}`);
|
|
381
|
+
|
|
382
|
+
for (const f of findings.filter(f => f.severity === 'critical' || f.severity === 'high')) {
|
|
383
|
+
const relFile = path.relative(absolutePath, f.file || '');
|
|
384
|
+
const sev = f.severity === 'critical' ? chalk.red.bold('!!') : chalk.yellow(' !');
|
|
385
|
+
const agentic = f.agenticRisk ? chalk.gray(` [${f.agenticRisk.id}]`) : '';
|
|
386
|
+
console.log(` ${sev} ${f.title} — ${relFile}:${f.line}${agentic}`);
|
|
387
|
+
}
|
|
388
|
+
console.log('');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (threshold && scoreResult.score < threshold) {
|
|
392
|
+
console.log(chalk.red.bold(` ⚠ Score ${scoreResult.score} below threshold ${threshold}\n`));
|
|
393
|
+
}
|
|
394
|
+
} catch (err) {
|
|
395
|
+
console.log(chalk.red(` [${timestamp}] Scan error: ${err.message}\n`));
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const watcher = fs.watch(absolutePath, { recursive: true }, (eventType, filename) => {
|
|
401
|
+
if (!filename) return;
|
|
402
|
+
|
|
403
|
+
// Skip non-scannable
|
|
404
|
+
const relPath = filename.replace(/\\/g, '/');
|
|
405
|
+
for (const skipDir of SKIP_DIRS) {
|
|
406
|
+
if (relPath.includes(`${skipDir}/`)) return;
|
|
407
|
+
}
|
|
408
|
+
const ext = path.extname(filename).toLowerCase();
|
|
409
|
+
if (SKIP_EXTENSIONS.has(ext)) return;
|
|
410
|
+
if (SKIP_FILENAMES.has(path.basename(filename))) return;
|
|
411
|
+
if (filename.endsWith('.min.js') || filename.endsWith('.min.css')) return;
|
|
412
|
+
|
|
413
|
+
const fullPath = path.join(absolutePath, filename);
|
|
414
|
+
if (!fs.existsSync(fullPath)) return;
|
|
415
|
+
|
|
416
|
+
pendingFiles.add(fullPath);
|
|
417
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
418
|
+
debounceTimer = setTimeout(processChanges, debounceMs);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
process.on('SIGINT', () => {
|
|
422
|
+
watcher.close();
|
|
423
|
+
console.log(`\n Watch stopped. ${scanCount} scan(s) completed.\n`);
|
|
424
|
+
process.exit(0);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
setInterval(() => {}, 1000 * 60 * 60);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
output.error(`Watch failed: ${err.message}`);
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// =============================================================================
|
|
435
|
+
// CONFIG WATCH — scanConfigFiles
|
|
436
|
+
// =============================================================================
|
|
437
|
+
|
|
233
438
|
async function scanConfigFiles(files, rootPath) {
|
|
234
439
|
// Dynamic import to avoid circular dependency
|
|
235
440
|
const { AgentConfigScanner } = await import('../agents/agent-config-scanner.js');
|
package/cli/index.js
CHANGED
|
@@ -71,3 +71,8 @@ export { CacheManager } from './utils/cache-manager.js';
|
|
|
71
71
|
|
|
72
72
|
// ── LLM Providers ─────────────────────────────────────────────────────────────
|
|
73
73
|
export { createProvider, autoDetectProvider } from './providers/llm-provider.js';
|
|
74
|
+
|
|
75
|
+
// ── v8.0.0 — Ship Safe × Hermes Agent ────────────────────────────────────────
|
|
76
|
+
export { HermesSecurityAgent } from './agents/hermes-security-agent.js';
|
|
77
|
+
export { AgentAttestationAgent } from './agents/agent-attestation-agent.js';
|
|
78
|
+
export { HERMES_TOOLS, registerWithHermes, verifyIntegrity } from './utils/hermes-tool-registry.js';
|
|
@@ -196,7 +196,7 @@ class GoogleProvider extends BaseLLMProvider {
|
|
|
196
196
|
class OllamaProvider extends BaseLLMProvider {
|
|
197
197
|
constructor(apiKey, options = {}) {
|
|
198
198
|
super('Ollama', null, options);
|
|
199
|
-
this.model = options.model || '
|
|
199
|
+
this.model = options.model || 'gemma4:e4b';
|
|
200
200
|
this.baseUrl = options.baseUrl || 'http://localhost:11434/api/chat';
|
|
201
201
|
}
|
|
202
202
|
|
|
@@ -223,6 +223,83 @@ class OllamaProvider extends BaseLLMProvider {
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// =============================================================================
|
|
227
|
+
// GEMMA 4 PROVIDER
|
|
228
|
+
// Uses Ollama's structured output (format: schema) for guaranteed JSON —
|
|
229
|
+
// no regex parsing, no silent dropped findings.
|
|
230
|
+
// =============================================================================
|
|
231
|
+
|
|
232
|
+
const CLASSIFY_SCHEMA = {
|
|
233
|
+
type: 'object',
|
|
234
|
+
properties: {
|
|
235
|
+
results: {
|
|
236
|
+
type: 'array',
|
|
237
|
+
items: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: {
|
|
240
|
+
id: { type: 'string' },
|
|
241
|
+
classification: { type: 'string', enum: ['REAL', 'FALSE_POSITIVE'] },
|
|
242
|
+
reason: { type: 'string' },
|
|
243
|
+
fix: { type: ['string', 'null'] },
|
|
244
|
+
},
|
|
245
|
+
required: ['id', 'classification', 'reason', 'fix'],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
required: ['results'],
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
class GemmaProvider extends OllamaProvider {
|
|
253
|
+
constructor(options = {}) {
|
|
254
|
+
super(null, {
|
|
255
|
+
model: options.model || 'gemma4:e4b',
|
|
256
|
+
baseUrl: options.baseUrl || 'http://localhost:11434/api/chat',
|
|
257
|
+
});
|
|
258
|
+
this.name = 'Gemma4';
|
|
259
|
+
// 256K tokens for 27b/31b, 128K for e4b — set conservatively high
|
|
260
|
+
this.contextWindow = options.model?.includes('27b') ? 131072 : 65536;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Classify using Ollama structured output (format: schema).
|
|
265
|
+
* Gemma 4 has trained-in function calling — the schema is enforced at the
|
|
266
|
+
* token level, so the response is always valid JSON matching CLASSIFY_SCHEMA.
|
|
267
|
+
*/
|
|
268
|
+
async classify(findings, context) {
|
|
269
|
+
const prompt = this.buildClassificationPrompt(findings, context);
|
|
270
|
+
|
|
271
|
+
const response = await fetch(this.baseUrl, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/json' },
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
model: this.model,
|
|
276
|
+
format: CLASSIFY_SCHEMA,
|
|
277
|
+
stream: false,
|
|
278
|
+
options: { num_ctx: this.contextWindow },
|
|
279
|
+
messages: [
|
|
280
|
+
{ role: 'system', content: 'You are a security expert. Classify each finding as REAL or FALSE_POSITIVE and suggest a fix.' },
|
|
281
|
+
{ role: 'user', content: prompt },
|
|
282
|
+
],
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (!response.ok) {
|
|
287
|
+
throw new Error(`Gemma4/Ollama error: HTTP ${response.status}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const data = await response.json();
|
|
291
|
+
const text = data.message?.content || '';
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const parsed = JSON.parse(text);
|
|
295
|
+
return parsed.results ?? [];
|
|
296
|
+
} catch {
|
|
297
|
+
// Fallback: schema enforcement failed (old Ollama version) — try regex parse
|
|
298
|
+
return this.parseJSON(text);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
226
303
|
// =============================================================================
|
|
227
304
|
// OPENAI-COMPATIBLE PROVIDER
|
|
228
305
|
// Handles Groq, Together AI, Mistral API, LM Studio, Azure OpenAI, Bedrock
|
|
@@ -239,6 +316,10 @@ const OPENAI_COMPATIBLE_PRESETS = {
|
|
|
239
316
|
perplexity: { baseUrl: 'https://api.perplexity.ai/chat/completions', model: 'llama-3.1-sonar-large-128k-online', envKey: 'PERPLEXITY_API_KEY' },
|
|
240
317
|
lmstudio: { baseUrl: 'http://localhost:1234/v1/chat/completions', model: null, envKey: null },
|
|
241
318
|
xai: { baseUrl: 'https://api.x.ai/v1/chat/completions', model: 'grok-3-mini', envKey: 'XAI_API_KEY' },
|
|
319
|
+
// Gemma 4 via Ollama — runs fully local, no API key required
|
|
320
|
+
// e4b: MoE 4B active params, ~8GB RAM; 27b: dense, ~20GB RAM
|
|
321
|
+
gemma4: { baseUrl: 'http://localhost:11434/v1/chat/completions', model: 'gemma4:e4b', envKey: null },
|
|
322
|
+
'gemma4:27b': { baseUrl: 'http://localhost:11434/v1/chat/completions', model: 'gemma4:27b', envKey: null },
|
|
242
323
|
};
|
|
243
324
|
|
|
244
325
|
class OpenAICompatibleProvider extends OpenAIProvider {
|
|
@@ -279,6 +360,13 @@ export function createProvider(provider, apiKey, options = {}) {
|
|
|
279
360
|
case 'ollama':
|
|
280
361
|
case 'local':
|
|
281
362
|
return new OllamaProvider(apiKey, options);
|
|
363
|
+
case 'gemma4':
|
|
364
|
+
case 'gemma':
|
|
365
|
+
// Gemma 4 via Ollama — structured output, no API key needed
|
|
366
|
+
return new GemmaProvider({
|
|
367
|
+
model: options.model,
|
|
368
|
+
baseUrl: options.baseUrl,
|
|
369
|
+
});
|
|
282
370
|
}
|
|
283
371
|
|
|
284
372
|
// OpenAI-compatible presets
|