Skip to content

Commit 62137f3

Browse files
committed
feat: add OWASP ASI gap-fill rules AGT-008 to AGT-011 (v1.9 agentic)
- AGT-008 (critical): ASI03 — agent calls assumeRole/grantAccess/setPermissions with LLM output value; privilege escalation via prompt injection - AGT-009 (high): ASI04 — agent loads tool/plugin at runtime from variable path or dynamic import; supply chain substitution vector - AGT-010 (high): ASI07 — raw agent output forwarded to another agent via send/route/dispatch without HMAC, JWT, or schema validation - AGT-011 (high): ASI08 — agent plan step error silently swallowed in catch block; downstream steps proceed on incomplete or corrupted state - 24 new tests (453 total), all passing - Rule count: 91 → 95 - README: expand AGT section with ASI mapping
1 parent 1ac64d4 commit 62137f3

3 files changed

Lines changed: 283 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ It fits into your existing workflow as a CLI command, an `npm` script, or a GitH
3939

4040
| | |
4141
|---|---|
42-
| **91 security rules** | Across 14 categories: injection, exfiltration, jailbreak, unsafe tool use, command injection, RAG poisoning, encoding, output handling, multimodal, skills marketplace, agentic, MCP, supply chain, DoS |
42+
| **95 security rules** | Across 14 categories: injection, exfiltration, jailbreak, unsafe tool use, command injection, RAG poisoning, encoding, output handling, multimodal, skills marketplace, agentic, MCP, supply chain, DoS |
4343
| **Numeric risk score (0-100)** | Normalized repo-level score with low, medium, high and critical thresholds |
4444
| **Mitigation detection** | Explicit safety language in your prompts reduces your score |
4545
| **7 output formats** | Console, JSON, SARIF, GitHub Annotations, Markdown, JSONL streaming, and interactive HTML |
@@ -107,7 +107,7 @@ hound scan --format markdown --out report
107107
# Stream findings as JSONL (one JSON object per line)
108108
hound scan --format jsonl | jq '.severity'
109109

110-
# List all 91 rules
110+
# List all 95 rules
111111
hound scan --list-rules
112112

113113
# Interactive HTML report (self-contained, open in browser)
@@ -449,9 +449,9 @@ Targets OpenClaw `SKILL.md` files and any markdown files inside `skills/` direct
449449

450450
> **Scanning OpenClaw skills:** Run `npx hound scan --dir ./skills` or add `**/skills/**/*.md` and `**/SKILL.md` to your `include` config. ContextHound automatically emits skill files as `code-block` for multi-line rule analysis.
451451

452-
### K. Agentic (AGT) — v1.3
452+
### K. Agentic (AGT) — v1.3 / v1.9
453453

454-
Targets risks specific to multi-step agentic systems: unbounded execution loops, unvalidated memory writes, user input leaking into agent planning, and inter-agent trust boundary violations.
454+
Targets risks specific to multi-step agentic systems: unbounded execution loops, unvalidated memory writes, user input leaking into agent planning, inter-agent trust boundary violations, and OWASP Agentic AI Security Issues (ASI) gaps.
455455

456456
| ID | Severity | Description |
457457
|----|----------|-------------|
@@ -462,6 +462,10 @@ Targets risks specific to multi-step agentic systems: unbounded execution loops,
462462
| AGT-005 | Critical | Agent trusts claimed identity without cryptographic verification — trust decision based on `agentId`, `sender`, `source`, or `from_agent` field without HMAC, JWT, or shared-secret verification |
463463
| AGT-006 | High | Raw agent output chained as input to another agent without validation — `.run()`, `.invoke()`, or `.generate()` called with another agent's `.output`/`.content`/`.result` directly as the argument |
464464
| AGT-007 | Critical | Agent self-modification — agent rewrites its own `system_prompt`, `instructions`, or `tools` list with LLM-generated content at runtime |
465+
| AGT-008 | Critical | ASI03 — Agent calls `assumeRole`, `grantAccess`, or `setPermissions` with a value derived from LLM output; privilege escalation via prompt injection |
466+
| AGT-009 | High | ASI04 — Agent loads a tool or plugin at runtime from a variable path or dynamic import, enabling supply chain substitution |
467+
| AGT-010 | High | ASI07 — Raw agent output forwarded to another agent via `send`/`route`/`dispatch` without HMAC, JWT signing, or schema validation |
468+
| AGT-011 | High | ASI08 — Agent plan step error caught silently (no rethrow, no error-state flag); downstream steps proceed on bad or incomplete state |
465469

466470
### L. MCP Security (MCP) — v1.7 / v1.8
467471

‎src/rules/agentic.ts‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,101 @@ export const agenticRules: Rule[] = [
171171
return results;
172172
},
173173
},
174+
{
175+
id: 'AGT-008',
176+
title: 'Agent assumes IAM role or grants permissions based on LLM output (ASI03)',
177+
severity: 'critical',
178+
confidence: 'medium',
179+
category: 'agentic',
180+
remediation:
181+
'Never call IAM, RBAC, or credential-assignment functions with values derived from LLM output. A prompt injection or jailbreak can escalate the agent\'s own privileges by inducing it to call assumeRole, grantAccess, or setPermissions with an attacker-chosen role ARN or permission set. Resolve permitted roles from a static allowlist in your application code, never from model responses.',
182+
check(prompt: ExtractedPrompt): RuleMatch[] {
183+
if (prompt.kind !== 'code-block') return [];
184+
185+
const results: RuleMatch[] = [];
186+
const lines = prompt.text.split('\n');
187+
188+
const iamPattern =
189+
/(?:assumeRole|putRolePolicy|attachRolePolicy|createRole|grantAccess|setPermissions?|addPermissions?|elevatePrivilege|setCredentials?|assignRole)\s*[.(]/i;
190+
const llmOutputPattern =
191+
/\b(?:response|output|result|completion|llm_?output|model_?output|answer|generated|content|choices)\b/i;
192+
193+
lines.forEach((line, i) => {
194+
if (iamPattern.test(line) && llmOutputPattern.test(line)) {
195+
results.push({
196+
evidence: line.trim(),
197+
lineStart: prompt.lineStart + i,
198+
lineEnd: prompt.lineStart + i,
199+
});
200+
}
201+
});
202+
203+
return results;
204+
},
205+
},
206+
{
207+
id: 'AGT-009',
208+
title: 'Agent loads tool or plugin from variable path or external URL at runtime (ASI04)',
209+
severity: 'high',
210+
confidence: 'medium',
211+
category: 'agentic',
212+
remediation:
213+
'Never load tools, plugins, or modules at runtime from user-controlled values, LLM-generated paths, or dynamic imports without an explicit allowlist. An attacker who can influence the loaded path or URL can substitute a malicious binary or script for a legitimate tool. Resolve tool implementations from a trusted, static registry at startup.',
214+
check(prompt: ExtractedPrompt): RuleMatch[] {
215+
if (prompt.kind !== 'code-block') return [];
216+
217+
// Detect: agent.loadTool/addTool/loadPlugin(variable) or dynamic import(variableNamedWithTool/Plugin/Url)
218+
const pattern =
219+
/(?:agent|executor|planner|runner)\s*\.(?:loadTool|addTool|registerTool|loadPlugin|importTool)\s*\(\s*(?!['"`])[a-z_$]|(?:await\s+)?import\s*\(\s*[a-z_$][a-z0-9_$.]*(?:Url|Path|Plugin|Tool|Module)\b/i;
220+
return matchPattern(prompt, pattern);
221+
},
222+
},
223+
{
224+
id: 'AGT-010',
225+
title: 'Raw agent output forwarded to another agent without trust boundary validation (ASI07)',
226+
severity: 'high',
227+
confidence: 'medium',
228+
category: 'agentic',
229+
remediation:
230+
'Never pass one agent\'s raw output directly as the instruction payload to another agent or message bus without signing or schema validation. In multi-agent pipelines, a compromised upstream agent can inject instructions into every downstream agent that receives its output. Validate, sanitise, and schema-check inter-agent messages at each hop.',
231+
check(prompt: ExtractedPrompt): RuleMatch[] {
232+
if (prompt.kind !== 'code-block') return [];
233+
const text = prompt.text;
234+
235+
// Suppress if message signing / cryptographic verification is present
236+
const signingGuard = /hmac|sign(?:ature)?|jwt|verify|bearer\s+token|shared.?secret/i;
237+
if (signingGuard.test(text)) return [];
238+
239+
// Detect: agent/broker send/forward/route/dispatch called with another agent's output
240+
const pattern =
241+
/(?:agent|broker|orchestrator|router|hub|bus)\w*\s*\.(?:send|forward|route|dispatch|relay|publish|invoke|call|run)\s*\(\s*(?:await\s+)?\w+\.(?:output|result|message|response|content|lastMessage|text)\b/i;
242+
return matchPattern(prompt, pattern);
243+
},
244+
},
245+
{
246+
id: 'AGT-011',
247+
title: 'Agent step error silently swallowed — downstream steps proceed on bad state (ASI08)',
248+
severity: 'high',
249+
confidence: 'medium',
250+
category: 'agentic',
251+
remediation:
252+
'Never swallow exceptions from agent plan steps without halting or flagging the run. Silently catching errors allows subsequent steps to execute against incomplete or corrupted state, which can compound failures or be exploited to skip safety checks. Re-throw, reject the promise, or set an explicit error/aborted state that downstream steps check before proceeding.',
253+
check(prompt: ExtractedPrompt): RuleMatch[] {
254+
if (prompt.kind !== 'code-block') return [];
255+
const text = prompt.text;
256+
257+
// Only fire in agent step/plan execution context
258+
if (!/agent\.(?:run|execute|invoke|step)\s*\(|executeStep\b|runPlan\b|AgentExecutor\b|planStep\b|chain\.(?:run|invoke|call)\s*\(/i.test(text)) return [];
259+
260+
// Must have a try/catch to be relevant
261+
if (!/try\s*\{/.test(text)) return [];
262+
263+
// Suppress if errors are properly propagated or the run is aborted
264+
if (/\bthrow\b|\breject\s*\(|\berrorState\b|\berror\s*=\s*(?!null|false)|failed\s*=\s*true|status\s*=\s*['"`](?:error|fail|abort)/i.test(text)) return [];
265+
266+
// Flag the catch line as evidence
267+
const pattern = /\bcatch\s*\([^)]*\)\s*\{/i;
268+
return matchPattern(prompt, pattern);
269+
},
270+
},
174271
];

‎tests/rules.test.ts‎

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2532,3 +2532,181 @@ describe('AGT-007: Agent modifies its own system prompt, instructions, or tool l
25322532
expect(rule.check(makePrompt(code, 1, 'raw'), 'agent.py')).toHaveLength(0);
25332533
});
25342534
});
2535+
2536+
// ── AGT-008–011 ───────────────────────────────────────────────────────────────
2537+
2538+
describe('AGT-008: Agent assumes IAM role from LLM output (ASI03)', () => {
2539+
const rule = agenticRules.find(r => r.id === 'AGT-008')!;
2540+
2541+
it('flags assumeRole called with LLM result', () => {
2542+
const code = [
2543+
'const result = await llm.complete(prompt);',
2544+
'await iam.assumeRole({ RoleArn: result.content });',
2545+
].join('\n');
2546+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'agent.ts')).toHaveLength(1);
2547+
});
2548+
2549+
it('flags grantAccess called with response value', () => {
2550+
const code = 'grantAccess(response.role);';
2551+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'agent.ts')).toHaveLength(1);
2552+
});
2553+
2554+
it('flags setPermissions called with LLM output', () => {
2555+
const code = 'agent.setPermissions(output.permissions);';
2556+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'agent.ts')).toHaveLength(1);
2557+
});
2558+
2559+
it('does not flag assumeRole with a static string ARN', () => {
2560+
const code = 'await iam.assumeRole({ RoleArn: "arn:aws:iam::123456789012:role/MyRole" });';
2561+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'agent.ts')).toHaveLength(0);
2562+
});
2563+
2564+
it('does not flag grantAccess without LLM output variable', () => {
2565+
const code = 'grantAccess(config.defaultRole);';
2566+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'agent.ts')).toHaveLength(0);
2567+
});
2568+
2569+
it('does not fire on raw kind', () => {
2570+
const code = 'iam.assumeRole(result.role)';
2571+
expect(rule.check(makePrompt(code, 1, 'raw'), 'agent.ts')).toHaveLength(0);
2572+
});
2573+
});
2574+
2575+
describe('AGT-009: Agent loads tool from variable path at runtime (ASI04)', () => {
2576+
const rule = agenticRules.find(r => r.id === 'AGT-009')!;
2577+
2578+
it('flags agent.loadTool with a variable argument', () => {
2579+
const code = 'agent.loadTool(userConfig.toolPath);';
2580+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'agent.ts')).toHaveLength(1);
2581+
});
2582+
2583+
it('flags agent.addTool with a variable argument', () => {
2584+
const code = 'executor.addTool(dynamicTool);';
2585+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'agent.ts')).toHaveLength(1);
2586+
});
2587+
2588+
it('flags dynamic import with variable named pluginUrl', () => {
2589+
const code = 'const plugin = await import(pluginUrl);';
2590+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'agent.ts')).toHaveLength(1);
2591+
});
2592+
2593+
it('flags dynamic import with variable named toolModule', () => {
2594+
const code = 'const t = await import(toolModule);';
2595+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'agent.ts')).toHaveLength(1);
2596+
});
2597+
2598+
it('does not flag agent.addTool with a string literal', () => {
2599+
const code = 'agent.addTool("search");';
2600+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'agent.ts')).toHaveLength(0);
2601+
});
2602+
2603+
it('does not fire on raw kind', () => {
2604+
const code = 'agent.loadTool(pluginUrl)';
2605+
expect(rule.check(makePrompt(code, 1, 'raw'), 'agent.ts')).toHaveLength(0);
2606+
});
2607+
});
2608+
2609+
describe('AGT-010: Raw agent output forwarded to another agent without trust validation (ASI07)', () => {
2610+
const rule = agenticRules.find(r => r.id === 'AGT-010')!;
2611+
2612+
it('flags broker.send with agent output', () => {
2613+
const code = 'broker.send(agentA.output);';
2614+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'pipeline.ts')).toHaveLength(1);
2615+
});
2616+
2617+
it('flags router.forward with agent result', () => {
2618+
const code = 'router.forward(upstreamAgent.result);';
2619+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'pipeline.ts')).toHaveLength(1);
2620+
});
2621+
2622+
it('flags orchestrator.dispatch with agent message', () => {
2623+
const code = 'orchestrator.dispatch(agentOne.message);';
2624+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'pipeline.ts')).toHaveLength(1);
2625+
});
2626+
2627+
it('does not flag when HMAC signing is present', () => {
2628+
const code = [
2629+
'const sig = hmac(agentA.output, secret);',
2630+
'broker.send(agentA.output);',
2631+
].join('\n');
2632+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'pipeline.ts')).toHaveLength(0);
2633+
});
2634+
2635+
it('does not flag when JWT verification is present', () => {
2636+
const code = [
2637+
'const verified = jwt.verify(token, secret);',
2638+
'router.forward(agentA.result);',
2639+
].join('\n');
2640+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'pipeline.ts')).toHaveLength(0);
2641+
});
2642+
2643+
it('does not fire on raw kind', () => {
2644+
const code = 'broker.send(agentA.output)';
2645+
expect(rule.check(makePrompt(code, 1, 'raw'), 'pipeline.ts')).toHaveLength(0);
2646+
});
2647+
});
2648+
2649+
describe('AGT-011: Agent step error silently swallowed (ASI08)', () => {
2650+
const rule = agenticRules.find(r => r.id === 'AGT-011')!;
2651+
2652+
it('flags empty catch in agent.run context', () => {
2653+
const code = [
2654+
'try {',
2655+
' await agent.run(plan);',
2656+
'} catch (e) {',
2657+
' console.log(e);',
2658+
'}',
2659+
'continueWithNextStep();',
2660+
].join('\n');
2661+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'runner.ts')).toHaveLength(1);
2662+
});
2663+
2664+
it('flags swallowed catch in AgentExecutor context', () => {
2665+
const code = [
2666+
'try {',
2667+
' result = await AgentExecutor.invoke(input);',
2668+
'} catch (err) {',
2669+
' console.error(err);',
2670+
'}',
2671+
].join('\n');
2672+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'runner.ts')).toHaveLength(1);
2673+
});
2674+
2675+
it('does not flag when error is re-thrown', () => {
2676+
const code = [
2677+
'try {',
2678+
' await agent.run(plan);',
2679+
'} catch (e) {',
2680+
' throw e;',
2681+
'}',
2682+
].join('\n');
2683+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'runner.ts')).toHaveLength(0);
2684+
});
2685+
2686+
it('does not flag when error state is set', () => {
2687+
const code = [
2688+
'try {',
2689+
' await executeStep(step);',
2690+
'} catch (e) {',
2691+
' failed = true;',
2692+
'}',
2693+
].join('\n');
2694+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'runner.ts')).toHaveLength(0);
2695+
});
2696+
2697+
it('does not fire without agent execution context', () => {
2698+
const code = [
2699+
'try {',
2700+
' await fetchData();',
2701+
'} catch (e) {',
2702+
' console.log(e);',
2703+
'}',
2704+
].join('\n');
2705+
expect(rule.check(makePrompt(code, 1, 'code-block'), 'runner.ts')).toHaveLength(0);
2706+
});
2707+
2708+
it('does not fire on raw kind', () => {
2709+
const code = 'try { agent.run(plan); } catch(e) {}';
2710+
expect(rule.check(makePrompt(code, 1, 'raw'), 'runner.ts')).toHaveLength(0);
2711+
});
2712+
});

0 commit comments

Comments
 (0)