ship-safe 7.0.0 → 9.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.
@@ -0,0 +1,536 @@
1
+ /**
2
+ * HermesSecurityAgent — Ship Safe × Hermes Agent
3
+ * =================================================
4
+ *
5
+ * Detects security vulnerabilities specific to Hermes Agent deployments:
6
+ * tool registry poisoning, function-call injection, memory layer attacks,
7
+ * skill permission drift, multi-agent trust boundary violations, and
8
+ * agent manifest attestation failures.
9
+ *
10
+ * Hermes Agent (NousResearch) is an open-source autonomous agent framework
11
+ * featuring a 4-layer memory system, markdown skill playbooks, a self-
12
+ * registering tool registry, and multi-agent orchestration — all of which
13
+ * introduce novel attack surfaces beyond traditional LLM prompt injection.
14
+ *
15
+ * SCANNING TARGETS:
16
+ * - hermes.config.{js,ts,json,yaml}
17
+ * - agents.{json,yaml}, agent-manifest.{json,yaml}
18
+ * - tool-registry.{js,ts,json}, tools/*.{js,ts,json}
19
+ * - skills/*.md, .hermes/**, hermes-skills/**
20
+ * - Any source file importing @nousresearch/hermes-agent or hermes-agent
21
+ * - Memory layer files: .hermes/memory/, episodic/, semantic/, working/
22
+ *
23
+ * OWASP MAPPING:
24
+ * ASI-01 Goal Hijacking, ASI-02 Excessive Agency,
25
+ * ASI-03 Unsafe Tool Use, ASI-04 Inadequate Sandboxing,
26
+ * ASI-05 Untrusted Tools, ASI-06 Memory Poisoning,
27
+ * ASI-07 Lack of Oversight, ASI-10 Supply Chain
28
+ */
29
+
30
+ import fs from 'fs';
31
+ import path from 'path';
32
+ import { BaseAgent, createFinding } from './base-agent.js';
33
+
34
+ // =============================================================================
35
+ // FILES THIS AGENT SCANS
36
+ // =============================================================================
37
+
38
+ const HERMES_FILE_PATTERNS = [
39
+ '**/hermes.config.{js,ts,json,yaml,yml}',
40
+ '**/agents.{json,yaml,yml}',
41
+ '**/agent-manifest.{json,yaml,yml}',
42
+ '**/tool-registry.{js,ts,json}',
43
+ '**/tools/**/*.{js,ts,json}',
44
+ '**/skills/**/*.md',
45
+ '**/.hermes/**/*',
46
+ '**/hermes-skills/**/*.md',
47
+ '**/hermes-tools/**/*.{js,ts}',
48
+ '**/*.{js,ts,py}', // Source files using hermes-agent SDK
49
+ ];
50
+
51
+ // =============================================================================
52
+ // SINGLE-LINE REGEX PATTERNS
53
+ // =============================================================================
54
+
55
+ const PATTERNS = [
56
+
57
+ // ────────────────────────────────────────────────────────────────────────────
58
+ // TRACK A-1: Tool Registry Poisoning
59
+ // Attacker controls the source URL of the tool registry, injecting
60
+ // malicious tool definitions that silently replace legitimate ones.
61
+ // ────────────────────────────────────────────────────────────────────────────
62
+ {
63
+ rule: 'HERMES_REGISTRY_REMOTE_URL',
64
+ title: 'Hermes: Tool Registry Loaded From Unvalidated Remote URL',
65
+ regex: /(?:loadRegistry|registerTools|toolRegistry|addTools)\s*\(\s*(?:await\s+fetch|http|https|axios|got|request)\s*\(\s*[^'"]/gi,
66
+ severity: 'critical',
67
+ cwe: 'CWE-829',
68
+ owasp: 'ASI05',
69
+ description: 'The Hermes tool registry is populated from an unvalidated remote URL. An attacker who controls or MITMs the endpoint can inject malicious tool definitions that override legitimate tools — silently hijacking all agent tool calls.',
70
+ fix: 'Pin the registry URL to a specific commit hash or use a signed manifest. Validate each tool definition against an allowlist schema before registration.',
71
+ confidence: 'high',
72
+ },
73
+
74
+ {
75
+ rule: 'HERMES_REGISTRY_ENV_VAR_URL',
76
+ title: 'Hermes: Tool Registry URL Controlled by Environment Variable',
77
+ regex: /(?:loadRegistry|registerTools|toolRegistry)\s*\(\s*process\.env\.[A-Z_]+/gi,
78
+ severity: 'high',
79
+ cwe: 'CWE-829',
80
+ owasp: 'ASI05',
81
+ description: 'Tool registry URL sourced from an environment variable. If the env var is compromised (leaked .env, CI/CD secret exposure), an attacker can redirect the registry to a malicious endpoint without changing source code.',
82
+ fix: 'Hard-code the registry URL or use a configuration file checked into source control with integrity verification.',
83
+ confidence: 'high',
84
+ },
85
+
86
+ // ────────────────────────────────────────────────────────────────────────────
87
+ // TRACK A-2: Function-Call Injection
88
+ // LLM output used directly as the tool name or arguments without an allowlist.
89
+ // ────────────────────────────────────────────────────────────────────────────
90
+ {
91
+ rule: 'HERMES_FUNCTION_CALL_NO_ALLOWLIST',
92
+ title: 'Hermes: LLM Output Used as Tool Name Without Allowlist',
93
+ regex: /(?:callTool|executeTool|invokeTool|runTool|dispatch)\s*\(\s*(?:response|output|result|llmOutput|toolCall|parsed)[\w.[\]'"]*(?:\.name|\.tool_name|\.function_name|(?:\[['"]name['"]\]))/gi,
94
+ severity: 'critical',
95
+ cwe: 'CWE-20',
96
+ owasp: 'ASI03',
97
+ description: 'LLM response used directly as the tool name to invoke without an allowlist check. A prompt injection attack can force the agent to call any registered tool — including dangerous system tools — by injecting a crafted tool name into the LLM output.',
98
+ fix: 'Validate the tool name against an explicit allowlist before dispatch: if (!ALLOWED_TOOLS.has(toolName)) throw new Error("Forbidden tool: " + toolName);',
99
+ confidence: 'high',
100
+ },
101
+
102
+ {
103
+ rule: 'HERMES_XML_TOOL_CALL_UNSAFE_PARSE',
104
+ title: 'Hermes: Unsafe Parsing of XML-Wrapped Tool Call',
105
+ regex: /(?:parseXML|xml2js|xmlParser|DOMParser|new\s+XMLParser)\s*\([^)]*(?:tool_call|function_call|hermes_call)/gi,
106
+ severity: 'high',
107
+ cwe: 'CWE-611',
108
+ owasp: 'ASI03',
109
+ description: 'XML-wrapped Hermes function calls parsed without XXE protection. A malicious <tool_call> payload could trigger XML External Entity (XXE) injection, reading local files or performing SSRF attacks through the XML parser.',
110
+ fix: 'Disable external entity resolution: set processEntities: false (xml2js), or use a JSON-only parser for Hermes function calls where possible.',
111
+ confidence: 'medium',
112
+ },
113
+
114
+ {
115
+ rule: 'HERMES_TOOL_ARGS_UNVALIDATED',
116
+ title: 'Hermes: Tool Arguments Passed to Dangerous Sink Without Validation',
117
+ regex: /(?:exec|execSync|spawn|eval|Function|query|db\.run|shell)\s*\(\s*(?:args|arguments|toolArgs|params|input|callArgs)[\w.[\]'"]*\b/gi,
118
+ severity: 'critical',
119
+ cwe: 'CWE-78',
120
+ owasp: 'ASI03',
121
+ description: 'Tool call arguments from the LLM passed directly to a dangerous sink (shell, eval, DB query) without sanitization. Prompt injection can craft tool arguments that execute arbitrary commands or SQL.',
122
+ fix: 'Validate all tool arguments against the declared JSON Schema before passing to the implementation. Reject any argument that does not match the expected type and format.',
123
+ confidence: 'high',
124
+ },
125
+
126
+ {
127
+ rule: 'HERMES_ADDITIONAL_PROPERTIES_TRUE',
128
+ title: 'Hermes: Tool Schema Allows Arbitrary Properties (additionalProperties: true)',
129
+ regex: /additionalProperties\s*[:=]\s*true/gi,
130
+ severity: 'high',
131
+ cwe: 'CWE-20',
132
+ owasp: 'ASI03',
133
+ description: 'Tool input schema sets additionalProperties: true, allowing the LLM to pass any arbitrary arguments. This bypasses schema validation and can be used to inject unexpected parameters that change tool behavior.',
134
+ fix: 'Set additionalProperties: false on all tool input schemas. Only accept explicitly declared properties.',
135
+ confidence: 'high',
136
+ },
137
+
138
+ // ────────────────────────────────────────────────────────────────────────────
139
+ // TRACK A-3: Plan/Goal Hijacking
140
+ // Hermes agent planners store goals in mutable state. If user-controlled
141
+ // content reaches the goal/plan state, the agent can be redirected.
142
+ // ────────────────────────────────────────────────────────────────────────────
143
+ {
144
+ rule: 'HERMES_PLAN_USER_INPUT',
145
+ title: 'Hermes: User Input Written Directly Into Agent Plan/Goal State',
146
+ regex: /(?:agent\.goal|agent\.plan|setGoal|setPlan|updatePlan|agent\.task)\s*=\s*(?:req\.|request\.|userInput|body\.|params\.|query\.)/gi,
147
+ severity: 'critical',
148
+ cwe: 'CWE-74',
149
+ owasp: 'ASI01',
150
+ description: 'User-controlled input assigned directly to the agent\'s goal or plan state. An attacker can hijack the agent\'s entire execution trajectory by crafting input that replaces the intended goal with a malicious objective.',
151
+ fix: 'Never write raw user input into agent goal/plan state. Use a constrained task template: goal = TEMPLATE.replace("{task}", sanitize(userInput)).',
152
+ confidence: 'high',
153
+ },
154
+
155
+ {
156
+ rule: 'HERMES_GOAL_PROMPT_INJECTION',
157
+ title: 'Hermes: Goal State Contains Unescaped Template Interpolation',
158
+ regex: /(?:goal|plan|task|objective)\s*[:=`]\s*[`'"]\s*[\s\S]{0,80}\$\{(?:req|request|user|input|body|params|query)/gi,
159
+ severity: 'critical',
160
+ cwe: 'CWE-74',
161
+ owasp: 'ASI01',
162
+ description: 'Goal or plan state built via template literal interpolation of request/user data. Attacker can break out of the template and inject arbitrary goal instructions.',
163
+ fix: 'Sanitize user input before interpolation and validate the final goal string against expected patterns. Prefer structured task objects over free-form strings.',
164
+ confidence: 'high',
165
+ },
166
+
167
+ // ────────────────────────────────────────────────────────────────────────────
168
+ // TRACK A-4: Memory Layer Attacks
169
+ // Hermes has 4 memory layers: in-context, external, episodic, semantic.
170
+ // Unvalidated writes to persistent memory layers enable cross-session poisoning.
171
+ // ────────────────────────────────────────────────────────────────────────────
172
+ {
173
+ rule: 'HERMES_MEMORY_UNVALIDATED_WRITE',
174
+ title: 'Hermes: User-Controlled Content Written to Persistent Memory Layer',
175
+ regex: /(?:memory\.store|memory\.save|episodicMemory\.add|semanticMemory\.upsert|addMemory|storeMemory|persistMemory)\s*\(\s*(?:req\.|userInput|body\.|params\.|input|content|message)\b/gi,
176
+ severity: 'critical',
177
+ cwe: 'CWE-74',
178
+ owasp: 'ASI06',
179
+ description: 'User-controlled content written directly to a Hermes persistent memory layer (episodic or semantic). This enables cross-session memory poisoning: an attacker injects false memories that influence all future agent sessions.',
180
+ fix: 'Sanitize and classify content before writing to persistent memory. Apply a confidence/source filter: only write memories derived from trusted tool outputs, not raw user messages.',
181
+ confidence: 'high',
182
+ },
183
+
184
+ {
185
+ rule: 'HERMES_MEMORY_EXFIL_PATTERN',
186
+ title: 'Hermes: Memory Layer Read Result Sent to External URL',
187
+ regex: /(?:memory\.retrieve|memory\.search|recallMemory|getMemory)\s*\([^)]*\)[\s\S]{0,200}(?:fetch|axios|http|https)\s*\(\s*['"`]https?:\/\/(?!localhost|127\.0\.0\.1)/gi,
188
+ severity: 'critical',
189
+ cwe: 'CWE-201',
190
+ owasp: 'ASI06',
191
+ description: 'Memory layer retrieval result immediately sent to an external HTTP endpoint. A compromised skill or tool chain can exfiltrate all accumulated agent memories — including sensitive context from prior sessions.',
192
+ fix: 'Audit all code paths that read from memory layers. Never forward memory retrieval results to external endpoints without explicit user consent and data classification.',
193
+ confidence: 'medium',
194
+ },
195
+
196
+ // ────────────────────────────────────────────────────────────────────────────
197
+ // TRACK A-5: Skill Permission Drift
198
+ // A skill's declared tool permissions should match what it actually invokes.
199
+ // ────────────────────────────────────────────────────────────────────────────
200
+ // NOTE: HERMES_SKILL_NO_PERMISSIONS_FIELD is detected by checkSkillFrontmatter()
201
+ // (a structural multi-line check), not as a line-by-line PATTERNS entry.
202
+ // A line-by-line regex cannot reliably detect the absence of a field in a
203
+ // multi-line frontmatter block.
204
+
205
+ {
206
+ rule: 'HERMES_SKILL_WILDCARD_PERMISSIONS',
207
+ title: 'Hermes: Skill Requests Wildcard Tool Permissions',
208
+ regex: /permissions\s*[:=]\s*\[\s*['"]?\*['"]?\s*\]/gi,
209
+ severity: 'high',
210
+ cwe: 'CWE-250',
211
+ owasp: 'ASI02',
212
+ description: 'Skill declares wildcard permissions (["*"]), granting itself access to all registered tools. A malicious or compromised skill with wildcard permissions can invoke any tool without restriction.',
213
+ fix: 'Replace wildcard with an explicit list of required tools: permissions: ["web_search", "summarize"]. Reject skills that request wildcard permissions.',
214
+ confidence: 'high',
215
+ },
216
+
217
+ // ────────────────────────────────────────────────────────────────────────────
218
+ // TRACK A-6: Multi-Agent Trust Boundary Violations
219
+ // ────────────────────────────────────────────────────────────────────────────
220
+ {
221
+ rule: 'HERMES_SUB_AGENT_CREDENTIAL_FORWARD',
222
+ title: 'Hermes: Parent Agent Credentials Forwarded to Sub-Agent',
223
+ regex: /(?:spawnAgent|createSubAgent|callAgent|delegateTo|orchestrate)\s*\([^)]*(?:apiKey|token|credentials|secrets|env\.)/gi,
224
+ severity: 'critical',
225
+ cwe: 'CWE-522',
226
+ owasp: 'ASI02',
227
+ description: 'Parent agent\'s credentials or API keys forwarded to a sub-agent. If the sub-agent is compromised via prompt injection, it gains the parent\'s full credential set and can pivot to other services.',
228
+ fix: 'Issue scoped, short-lived credentials to each sub-agent. Never forward the parent\'s primary credentials. Use capability tokens that grant only what the sub-agent needs.',
229
+ confidence: 'high',
230
+ },
231
+
232
+ {
233
+ rule: 'HERMES_UNBOUNDED_AGENT_DEPTH',
234
+ title: 'Hermes: Multi-Agent Recursion Without Depth Limit',
235
+ regex: /(?:spawnAgent|createSubAgent|callAgent|delegateTo)\s*\([^)]*\)(?![\s\S]{0,300}(?:depth|maxDepth|depthLimit|recursionLimit))/gi,
236
+ severity: 'high',
237
+ cwe: 'CWE-674',
238
+ owasp: 'ASI07',
239
+ description: 'Agent spawning a sub-agent without a recursion depth limit. An adversarial prompt can trigger unbounded agent recursion, exhausting resources or causing goal drift through infinite delegation chains.',
240
+ fix: 'Track agent call depth and enforce a maximum: if (depth >= MAX_AGENT_DEPTH) throw new AgentDepthLimitError(). Recommended max: 3-5 levels.',
241
+ confidence: 'medium',
242
+ },
243
+
244
+ {
245
+ rule: 'HERMES_AGENT_OUTPUT_UNVALIDATED_ACTION',
246
+ title: 'Hermes: Sub-Agent Output Triggers Action Without Validation',
247
+ regex: /(?:agentResult|subAgentOutput|delegateResult|orchestrationResult)[\w.[\]'"]*\s*(?:\.|->)\s*(?:execute|run|apply|dispatch|write|delete|send|post|put)/gi,
248
+ severity: 'high',
249
+ cwe: 'CWE-20',
250
+ owasp: 'ASI01',
251
+ description: 'Sub-agent output directly triggers a real-world action (write, delete, send) without human validation. A compromised sub-agent can use this to perform destructive or exfiltrating actions through the parent.',
252
+ fix: 'Require explicit user confirmation before acting on sub-agent output for any irreversible action. Apply output validation schemas to all inter-agent messages.',
253
+ confidence: 'medium',
254
+ },
255
+
256
+ // ────────────────────────────────────────────────────────────────────────────
257
+ // TRACK A-7: Agent Manifest Attestation
258
+ // ────────────────────────────────────────────────────────────────────────────
259
+ {
260
+ rule: 'HERMES_MANIFEST_NO_INTEGRITY',
261
+ title: 'Hermes: Agent Manifest Loaded Without Integrity Check',
262
+ regex: /(?:loadManifest|readManifest|parseManifest|loadAgent)\s*\(\s*(?:filePath|manifestPath|agentPath|configPath)\s*\)(?![\s\S]{0,200}(?:integrity|checksum|hash|verify|signature))/gi,
263
+ severity: 'high',
264
+ cwe: 'CWE-345',
265
+ owasp: 'ASI10',
266
+ description: 'Agent manifest loaded from disk or network without verifying its integrity hash or signature. A supply-chain attack can replace the manifest file, silently changing agent behavior, tool lists, and permissions.',
267
+ fix: 'Compute and verify a SHA-256 hash of the manifest at load time. For production, use a signed manifest: verify the signature against a trusted public key before trusting its contents.',
268
+ confidence: 'medium',
269
+ },
270
+
271
+ {
272
+ rule: 'HERMES_MANIFEST_NO_VERSION_PIN',
273
+ title: 'Hermes: hermes-agent Dependency Uses Mutable Version Range',
274
+ // Match only package.json-style version specs with range operators, not import statements.
275
+ // Fires on: "@nousresearch/hermes-agent": "^1.2.0" or "~1.0.0" or "*"
276
+ // Does NOT fire on import/require statements.
277
+ regex: /["']@nousresearch\/hermes-agent["']\s*:\s*["'][\^~*><=][^"']{1,20}["']/gi,
278
+ severity: 'medium',
279
+ cwe: 'CWE-829',
280
+ owasp: 'ASI10',
281
+ description: 'hermes-agent dependency uses a mutable version range — a compromised minor or patch release would affect all agents using this package without any code change.',
282
+ fix: 'Pin to an exact version: "@nousresearch/hermes-agent": "1.2.3". Commit the lockfile.',
283
+ confidence: 'high',
284
+ },
285
+
286
+ ];
287
+
288
+ // =============================================================================
289
+ // STRUCTURAL / MULTI-LINE CHECKS
290
+ // =============================================================================
291
+
292
+ /**
293
+ * Detect tool namespace collisions — two tools with the same name registered
294
+ * in the same registry (shadowing attack).
295
+ */
296
+ function checkToolNameCollisions(content, filePath, agent) {
297
+ const findings = [];
298
+ const nameRe = /(?:registerTool|addTool|tools\.push|tools\.set)\s*\(\s*\{[^}]*?name\s*[:=]\s*['"]([^'"]+)['"]/gi;
299
+ const names = new Map(); // name → first line number
300
+
301
+ let match;
302
+ while ((match = nameRe.exec(content)) !== null) {
303
+ const toolName = match[1];
304
+ const line = content.slice(0, match.index).split('\n').length;
305
+
306
+ if (names.has(toolName)) {
307
+ findings.push(createFinding({
308
+ file: filePath,
309
+ line,
310
+ severity: 'high',
311
+ category: agent.category,
312
+ rule: 'HERMES_TOOL_NAME_COLLISION',
313
+ title: `Hermes: Tool Name Collision — "${toolName}" Registered Twice`,
314
+ description: `The tool name "${toolName}" is registered more than once in the same registry. The second registration silently shadows the first. This is a tool-shadowing attack vector: a malicious plugin or dynamically loaded tool can replace a trusted tool by registering under the same name.`,
315
+ matched: `duplicate tool name: "${toolName}" (first at line ${names.get(toolName)})`,
316
+ confidence: 'high',
317
+ cwe: 'CWE-15',
318
+ owasp: 'ASI05',
319
+ fix: `Before registering a tool, check: if (registry.has("${toolName}")) throw new Error("Tool name collision: ${toolName}"). Use a registry that rejects duplicate registrations.`,
320
+ }));
321
+ } else {
322
+ names.set(toolName, line);
323
+ }
324
+ }
325
+
326
+ return findings;
327
+ }
328
+
329
+ /**
330
+ * Detect cross-agent trust chain: agent A passes its own tool context to
331
+ * agent B unfiltered. Detected by looking for agent spawn calls where the
332
+ * parent's full `tools` or `toolRegistry` is passed as-is.
333
+ */
334
+ function checkToolContextForwarding(content, filePath, agent) {
335
+ const findings = [];
336
+ const forwardRe = /(?:spawnAgent|createSubAgent|callAgent)\s*\(\s*\{[^}]*tools\s*[:=]\s*(?:this\.tools|toolRegistry|allTools|registeredTools)\b/gi;
337
+
338
+ let match;
339
+ while ((match = forwardRe.exec(content)) !== null) {
340
+ const line = content.slice(0, match.index).split('\n').length;
341
+ findings.push(createFinding({
342
+ file: filePath,
343
+ line,
344
+ severity: 'high',
345
+ category: agent.category,
346
+ rule: 'HERMES_FULL_TOOL_CONTEXT_FORWARD',
347
+ title: 'Hermes: Full Tool Registry Forwarded to Sub-Agent',
348
+ description: 'Parent agent forwards its complete tool registry to a sub-agent. The sub-agent gains access to all of the parent\'s tools — including dangerous system tools it may not need. If the sub-agent is compromised via prompt injection, it can use any of the parent\'s tools.',
349
+ matched: match[0].slice(0, 120),
350
+ confidence: 'high',
351
+ cwe: 'CWE-250',
352
+ owasp: 'ASI02',
353
+ fix: 'Create a scoped tool registry for each sub-agent containing only the tools it needs: spawnAgent({ tools: [allowedTool1, allowedTool2] }).',
354
+ }));
355
+ }
356
+
357
+ return findings;
358
+ }
359
+
360
+ /**
361
+ * Check skill frontmatter for permissions field.
362
+ * Skill files are Markdown; frontmatter is YAML between --- delimiters.
363
+ */
364
+ function checkSkillFrontmatter(content, filePath, agent) {
365
+ const findings = [];
366
+
367
+ // Only check Markdown skill files
368
+ if (!filePath.endsWith('.md')) return findings;
369
+
370
+ // Extract YAML frontmatter
371
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
372
+ if (!fmMatch) return findings;
373
+
374
+ const fm = fmMatch[1];
375
+ const hasName = /^name\s*:/m.test(fm);
376
+ const hasTool = /^(?:tools?|tool_use|tool_calls?)\s*:/m.test(fm);
377
+
378
+ if (!hasName) return findings; // not a skill file
379
+
380
+ // Check for missing permissions
381
+ const hasPermissions = /^permissions?\s*:/m.test(fm);
382
+ if (hasTool && !hasPermissions) {
383
+ findings.push(createFinding({
384
+ file: filePath,
385
+ line: 1,
386
+ severity: 'medium',
387
+ category: agent.category,
388
+ rule: 'HERMES_SKILL_NO_PERMISSIONS_FIELD',
389
+ title: 'Hermes: Skill Declares Tools But Has No permissions Field',
390
+ description: 'This skill uses tools but does not declare a permissions field in its frontmatter. Without explicit permission declarations, the agent runtime may grant the skill access to all tools — violating least-privilege.',
391
+ matched: fm.slice(0, 120),
392
+ confidence: 'medium',
393
+ cwe: 'CWE-1188',
394
+ owasp: 'ASI02',
395
+ fix: 'Add a permissions field to the skill frontmatter listing only the tools this skill needs: permissions: [tool1, tool2].',
396
+ }));
397
+ }
398
+
399
+ // Check for wildcard permissions already handled by PATTERNS regex.
400
+ return findings;
401
+ }
402
+
403
+ /**
404
+ * Detect insecure memory layer file access patterns.
405
+ * Hermes stores memory in JSON files under .hermes/memory/; direct JSON.parse
406
+ * of those files without validation is a deserialization risk.
407
+ */
408
+ function checkMemoryFileDeserialization(content, filePath, agent) {
409
+ const findings = [];
410
+
411
+ // Only flag in source files, not the memory files themselves
412
+ if (filePath.includes('.hermes/memory')) return findings;
413
+
414
+ const re = /JSON\.parse\s*\(\s*(?:fs\.readFileSync|await\s+fs\.promises\.readFile)\s*\([^)]*(?:memory|episodic|semantic|working|\.hermes)/gi;
415
+ let match;
416
+ while ((match = re.exec(content)) !== null) {
417
+ const line = content.slice(0, match.index).split('\n').length;
418
+ findings.push(createFinding({
419
+ file: filePath,
420
+ line,
421
+ severity: 'medium',
422
+ category: agent.category,
423
+ rule: 'HERMES_MEMORY_UNSAFE_DESERIALIZE',
424
+ title: 'Hermes: Memory File Deserialized Without Schema Validation',
425
+ description: 'Hermes memory layer file read and parsed with JSON.parse but not validated against a schema. A tampered memory file (e.g., from a prior memory poisoning attack) could contain crafted data that alters agent behavior when loaded.',
426
+ matched: match[0].slice(0, 100),
427
+ confidence: 'medium',
428
+ cwe: 'CWE-502',
429
+ owasp: 'ASI06',
430
+ fix: 'Validate memory file contents against a strict JSON Schema after parsing. Reject any memory entries that contain unexpected fields or types.',
431
+ }));
432
+ }
433
+
434
+ return findings;
435
+ }
436
+
437
+ // =============================================================================
438
+ // AGENT CLASS
439
+ // =============================================================================
440
+
441
+ export class HermesSecurityAgent extends BaseAgent {
442
+ constructor() {
443
+ super(
444
+ 'HermesSecurityAgent',
445
+ 'Detects security vulnerabilities in Hermes Agent deployments: tool registry poisoning, function-call injection, memory layer attacks, skill permission drift, and multi-agent trust boundary violations',
446
+ 'llm'
447
+ );
448
+ }
449
+
450
+ /**
451
+ * Only run if the project appears to use Hermes Agent.
452
+ */
453
+ shouldRun(recon) {
454
+ // Run if hermes is detected in dependencies or frameworks
455
+ if (recon?.dependencies?.some(d => /hermes/i.test(d))) return true;
456
+ if (recon?.frameworks?.some(f => /hermes/i.test(f))) return true;
457
+ // Run if hermes config files were discovered during recon
458
+ if (recon?.configFiles?.some(f => /hermes/i.test(f))) return true;
459
+ // Don't scan every project — Hermes files are distinctive enough to skip otherwise
460
+ return false;
461
+ }
462
+
463
+ async analyze(context) {
464
+ const { rootPath, files = [] } = context;
465
+ const findings = [];
466
+
467
+ // Discover Hermes-relevant files
468
+ const hermesFiles = this._findHermesFiles(files, rootPath);
469
+
470
+ if (hermesFiles.length === 0) return findings;
471
+
472
+ for (const filePath of hermesFiles) {
473
+ const content = this.readFile(filePath);
474
+ if (!content) continue;
475
+
476
+ // Skip files with blanket suppression
477
+ if (/hermes-security-ignore-file/i.test(content)) continue;
478
+
479
+ // Reset stateful regex lastIndex before each file (patterns use /g flag)
480
+ for (const p of PATTERNS) p.regex.lastIndex = 0;
481
+
482
+ // Single-line pattern scan
483
+ findings.push(...this.scanFileWithPatterns(filePath, PATTERNS));
484
+
485
+ // Structural checks
486
+ findings.push(...checkToolNameCollisions(content, filePath, this));
487
+ findings.push(...checkToolContextForwarding(content, filePath, this));
488
+ findings.push(...checkSkillFrontmatter(content, filePath, this));
489
+ findings.push(...checkMemoryFileDeserialization(content, filePath, this));
490
+ }
491
+
492
+ return findings;
493
+ }
494
+
495
+ /**
496
+ * Identify files relevant to Hermes Agent analysis.
497
+ */
498
+ _findHermesFiles(allFiles, rootPath) {
499
+ const hermesFiles = new Set();
500
+
501
+ for (const file of allFiles) {
502
+ const rel = file.replace(/\\/g, '/');
503
+
504
+ // Hermes config and manifest files
505
+ if (/(?:hermes\.config|agents\.(?:json|yaml|yml)|agent-manifest|tool-registry|hermes-tools)\./i.test(rel)) {
506
+ hermesFiles.add(file);
507
+ continue;
508
+ }
509
+
510
+ // Skill files
511
+ if (/\/skills\/[^/]+\.md$/i.test(rel) || /\/hermes-skills\//i.test(rel)) {
512
+ hermesFiles.add(file);
513
+ continue;
514
+ }
515
+
516
+ // .hermes directory
517
+ if (/\/\.hermes\//i.test(rel)) {
518
+ hermesFiles.add(file);
519
+ continue;
520
+ }
521
+
522
+ // Source files — check for hermes imports (avoid reading every file)
523
+ if (/\.(js|ts|mjs|cjs|py)$/.test(rel)) {
524
+ const content = this.readFile(file);
525
+ if (!content) continue;
526
+ if (/(?:hermes[-_]agent|@nousresearch\/hermes|hermes\.config|toolRegistry|registerTool|callTool|spawnAgent|createSubAgent|memory\.store|episodicMemory|semanticMemory|loadManifest)/i.test(content)) {
527
+ hermesFiles.add(file);
528
+ }
529
+ }
530
+ }
531
+
532
+ return [...hermesFiles];
533
+ }
534
+ }
535
+
536
+ export default HermesSecurityAgent;
@@ -28,6 +28,9 @@ export { ExceptionHandlerAgent } from './exception-handler-agent.js';
28
28
  export { AgentConfigScanner } from './agent-config-scanner.js';
29
29
  export { MemoryPoisoningAgent } from './memory-poisoning-agent.js';
30
30
  export { LegalRiskAgent, LEGALLY_RISKY_PACKAGES } from './legal-risk-agent.js';
31
+ export { ManagedAgentScanner } from './managed-agent-scanner.js';
32
+ export { HermesSecurityAgent } from './hermes-security-agent.js';
33
+ export { AgentAttestationAgent } from './agent-attestation-agent.js';
31
34
  export { ABOMGenerator } from './abom-generator.js';
32
35
  export { VerifierAgent } from './verifier-agent.js';
33
36
  export { DeepAnalyzer } from './deep-analyzer.js';
@@ -37,8 +40,12 @@ export { PolicyEngine } from './policy-engine.js';
37
40
  export { HTMLReporter } from './html-reporter.js';
38
41
 
39
42
  /**
40
- * Create a fully configured orchestrator with all 19 scanning agents.
43
+ * Create a fully configured orchestrator with all 22 scanning agents.
41
44
  * (VerifierAgent and DeepAnalyzer run as post-processors, not in the agent pool.)
45
+ *
46
+ * Plugin system: if rootPath is provided, custom agents from
47
+ * .ship-safe/agents/*.js are loaded and registered automatically.
48
+ * Use buildOrchestratorAsync(rootPath) to get plugin support.
42
49
  */
43
50
  import { Orchestrator as OrchestratorClass } from './orchestrator.js';
44
51
  import { InjectionTester as InjectionTesterClass } from './injection-tester.js';
@@ -60,29 +67,63 @@ import { VibeCodingAgent as VibeCodingAgentClass } from './vibe-coding-agent.js'
60
67
  import { ExceptionHandlerAgent as ExceptionHandlerAgentClass } from './exception-handler-agent.js';
61
68
  import { AgentConfigScanner as AgentConfigScannerClass } from './agent-config-scanner.js';
62
69
  import { MemoryPoisoningAgent as MemoryPoisoningAgentClass } from './memory-poisoning-agent.js';
70
+ import { ManagedAgentScanner as ManagedAgentScannerClass } from './managed-agent-scanner.js';
71
+ import { HermesSecurityAgent as HermesSecurityAgentClass } from './hermes-security-agent.js';
72
+ import { AgentAttestationAgent as AgentAttestationAgentClass } from './agent-attestation-agent.js';
73
+ import { loadPlugins } from '../utils/plugin-loader.js';
74
+
75
+ const BUILT_IN_AGENTS = () => [
76
+ new InjectionTesterClass(),
77
+ new AuthBypassAgentClass(),
78
+ new SSRFProberClass(),
79
+ new SupplyChainAuditClass(),
80
+ new ConfigAuditorClass(),
81
+ new LLMRedTeamClass(),
82
+ new MobileScannerClass(),
83
+ new GitHistoryScannerClass(),
84
+ new CICDScannerClass(),
85
+ new APIFuzzerClass(),
86
+ new SupabaseRLSAgentClass(),
87
+ new MCPSecurityAgentClass(),
88
+ new AgenticSecurityAgentClass(),
89
+ new RAGSecurityAgentClass(),
90
+ new PIIComplianceAgentClass(),
91
+ new VibeCodingAgentClass(),
92
+ new ExceptionHandlerAgentClass(),
93
+ new AgentConfigScannerClass(),
94
+ new MemoryPoisoningAgentClass(),
95
+ new ManagedAgentScannerClass(),
96
+ new HermesSecurityAgentClass(),
97
+ new AgentAttestationAgentClass(),
98
+ ];
63
99
 
100
+ /** Synchronous build — no plugin support. Used by legacy callers. */
64
101
  export function buildOrchestrator() {
65
102
  const orchestrator = new OrchestratorClass();
66
- orchestrator.registerAll([
67
- new InjectionTesterClass(),
68
- new AuthBypassAgentClass(),
69
- new SSRFProberClass(),
70
- new SupplyChainAuditClass(),
71
- new ConfigAuditorClass(),
72
- new LLMRedTeamClass(),
73
- new MobileScannerClass(),
74
- new GitHistoryScannerClass(),
75
- new CICDScannerClass(),
76
- new APIFuzzerClass(),
77
- new SupabaseRLSAgentClass(),
78
- new MCPSecurityAgentClass(),
79
- new AgenticSecurityAgentClass(),
80
- new RAGSecurityAgentClass(),
81
- new PIIComplianceAgentClass(),
82
- new VibeCodingAgentClass(),
83
- new ExceptionHandlerAgentClass(),
84
- new AgentConfigScannerClass(),
85
- new MemoryPoisoningAgentClass(),
86
- ]);
103
+ orchestrator.registerAll(BUILT_IN_AGENTS());
104
+ return orchestrator;
105
+ }
106
+
107
+ /**
108
+ * Async build — loads built-in agents + any plugins from .ship-safe/agents/.
109
+ * Preferred over buildOrchestrator() when rootPath is available.
110
+ *
111
+ * @param {string} rootPath — project root (for plugin discovery)
112
+ * @param {object} options — { verbose, quiet }
113
+ */
114
+ export async function buildOrchestratorAsync(rootPath, options = {}) {
115
+ const orchestrator = new OrchestratorClass();
116
+ orchestrator.registerAll(BUILT_IN_AGENTS());
117
+
118
+ if (rootPath) {
119
+ const plugins = await loadPlugins(rootPath, options);
120
+ if (plugins.length > 0) {
121
+ orchestrator.registerAll(plugins);
122
+ if (!options.quiet) {
123
+ console.log(` Registered ${plugins.length} custom plugin(s)`);
124
+ }
125
+ }
126
+ }
127
+
87
128
  return orchestrator;
88
129
  }