ai-agent-security-toolkit 0.4.1__py3-none-any.whl

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.
Files changed (39) hide show
  1. agentsec/__init__.py +3 -0
  2. agentsec/cli.py +219 -0
  3. agentsec/fuzzer/__init__.py +44 -0
  4. agentsec/fuzzer/formats.py +222 -0
  5. agentsec/fuzzer/harness.py +218 -0
  6. agentsec/fuzzer/mock_agents.py +243 -0
  7. agentsec/fuzzer/mutators.py +102 -0
  8. agentsec/fuzzer/payloads.py +250 -0
  9. agentsec/fuzzer/report.py +132 -0
  10. agentsec/fuzzer/targets.py +300 -0
  11. agentsec/integrations/__init__.py +1 -0
  12. agentsec/integrations/langchain.py +94 -0
  13. agentsec/integrations/mcp.py +392 -0
  14. agentsec/middleware/__init__.py +65 -0
  15. agentsec/middleware/audit.py +366 -0
  16. agentsec/middleware/cef.py +183 -0
  17. agentsec/middleware/injection.py +148 -0
  18. agentsec/middleware/pipeline.py +111 -0
  19. agentsec/middleware/redact.py +191 -0
  20. agentsec/middleware/validators.py +505 -0
  21. agentsec/py.typed +0 -0
  22. agentsec/sandbox/__init__.py +31 -0
  23. agentsec/sandbox/adapters.py +203 -0
  24. agentsec/sandbox/guard.py +353 -0
  25. agentsec/sandbox/policy.py +217 -0
  26. agentsec/sandbox/subprocess_runner.py +226 -0
  27. agentsec/sandbox/validators.py +209 -0
  28. agentsec/threatmodel/__init__.py +27 -0
  29. agentsec/threatmodel/crosswalk.py +65 -0
  30. agentsec/threatmodel/data/frameworks.yaml +45 -0
  31. agentsec/threatmodel/data/threats.yaml +440 -0
  32. agentsec/threatmodel/render.py +364 -0
  33. agentsec/types.py +64 -0
  34. ai_agent_security_toolkit-0.4.1.dist-info/METADATA +273 -0
  35. ai_agent_security_toolkit-0.4.1.dist-info/RECORD +39 -0
  36. ai_agent_security_toolkit-0.4.1.dist-info/WHEEL +5 -0
  37. ai_agent_security_toolkit-0.4.1.dist-info/entry_points.txt +2 -0
  38. ai_agent_security_toolkit-0.4.1.dist-info/licenses/LICENSE +21 -0
  39. ai_agent_security_toolkit-0.4.1.dist-info/top_level.txt +1 -0
agentsec/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ai-agent-security-toolkit: fuzz, contain, validate and audit LLM agents."""
2
+
3
+ __version__ = "0.4.1"
agentsec/cli.py ADDED
@@ -0,0 +1,219 @@
1
+ """``agentsec`` command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from agentsec import __version__
12
+ from agentsec.fuzzer import CORPUS, MUTATORS, FuzzConfig, load_target, run
13
+ from agentsec.fuzzer.harness import SCENARIOS, build_cases
14
+ from agentsec.middleware.audit import verify_file
15
+ from agentsec.sandbox import Policy, PolicyError, ToolGuard
16
+ from agentsec.threatmodel import (
17
+ SYSTEM_TEMPLATE,
18
+ SystemSpecError,
19
+ crosswalk_markdown,
20
+ load_catalog,
21
+ load_system,
22
+ render_markdown,
23
+ )
24
+ from agentsec.types import ToolCall
25
+
26
+
27
+ def _csv(value: str | None) -> list[str] | None:
28
+ return [v.strip() for v in value.split(",") if v.strip()] if value else None
29
+
30
+
31
+ def cmd_fuzz(args: argparse.Namespace) -> int:
32
+ config = FuzzConfig(
33
+ seed=args.seed,
34
+ scenarios=_csv(args.scenarios) or list(SCENARIOS),
35
+ mutators=_csv(args.mutators),
36
+ categories=_csv(args.categories),
37
+ max_cases=args.max_cases,
38
+ )
39
+ if args.list:
40
+ cases = build_cases(config)
41
+ print(f"{len(CORPUS)} payloads x {len(MUTATORS)} mutators x {len(SCENARIOS)} scenarios")
42
+ print(f"selected: {len(cases)} cases")
43
+ print("mutators:", ", ".join(MUTATORS))
44
+ return 0
45
+ try:
46
+ target = load_target(args.target)
47
+ report = run(target, config)
48
+ except (ValueError, ImportError, AttributeError) as exc:
49
+ print(f"error: {exc}", file=sys.stderr)
50
+ return 2
51
+ if args.json:
52
+ Path(args.json).write_text(report.to_json(), encoding="utf-8", newline="\n")
53
+ if args.md:
54
+ Path(args.md).write_text(report.to_markdown(), encoding="utf-8", newline="\n")
55
+ if args.sarif:
56
+ Path(args.sarif).write_text(
57
+ report.to_sarif(artifact_uri=args.sarif_artifact, target=args.target),
58
+ encoding="utf-8",
59
+ newline="\n",
60
+ )
61
+ if args.junit:
62
+ Path(args.junit).write_text(report.to_junit(), encoding="utf-8", newline="\n")
63
+ print(
64
+ f"{report.successes}/{report.total - report.errors} attacks succeeded "
65
+ f"(ASR {report.asr:.1%}); {report.blocked} blocked, {report.errors} errors"
66
+ )
67
+ if not args.quiet:
68
+ print()
69
+ print(report.to_markdown(max_bypasses=8))
70
+ if args.max_asr is not None and report.exceeds(args.max_asr):
71
+ print(f"FAIL: ASR {report.asr:.1%} exceeds --max-asr {args.max_asr:.1%}", file=sys.stderr)
72
+ return 1
73
+ return 0
74
+
75
+
76
+ def cmd_policy(args: argparse.Namespace) -> int:
77
+ try:
78
+ policy = Policy.from_yaml(args.file)
79
+ except (PolicyError, OSError, ValueError) as exc:
80
+ print(f"invalid policy: {exc}", file=sys.stderr)
81
+ return 2
82
+ if args.action == "validate":
83
+ notes = policy.lint()
84
+ print(f"OK: {len(policy.tools)} tool(s) defined")
85
+ for n in notes:
86
+ print(f" warning: {n}")
87
+ return 1 if (notes and args.strict) else 0
88
+ try:
89
+ call_args: Any = json.loads(args.args or "{}")
90
+ except json.JSONDecodeError as exc:
91
+ print(f"--args is not valid JSON: {exc}", file=sys.stderr)
92
+ return 2
93
+ guard = ToolGuard(policy)
94
+ if args.tainted:
95
+ guard.default_session.mark_untrusted("cli")
96
+ decision = guard.evaluate(ToolCall(name=args.tool, arguments=call_args))
97
+ print(f"{decision.verdict.value.upper()}: {decision.message()}")
98
+ return 0 if decision.allowed else 1
99
+
100
+
101
+ def cmd_audit(args: argparse.Namespace) -> int:
102
+ try:
103
+ result = verify_file(args.file)
104
+ except OSError as exc:
105
+ print(f"error: {exc}", file=sys.stderr)
106
+ return 2
107
+ if result.ok:
108
+ print(f"OK: {result.records_checked} record(s), chain intact")
109
+ return 0
110
+ print(
111
+ f"TAMPERED: {result.reason} (first bad seq: {result.first_bad_seq}, "
112
+ f"{result.records_checked} valid before it)",
113
+ file=sys.stderr,
114
+ )
115
+ return 1
116
+
117
+
118
+ def cmd_threatmodel(args: argparse.Namespace) -> int:
119
+ if args.action == "init":
120
+ out = Path(args.output) if args.output else None
121
+ if out:
122
+ out.write_text(SYSTEM_TEMPLATE, encoding="utf-8", newline="\n")
123
+ print(f"wrote {out}")
124
+ else:
125
+ print(SYSTEM_TEMPLATE, end="")
126
+ return 0
127
+ if args.action == "crosswalk":
128
+ print(crosswalk_markdown(), end="")
129
+ return 0
130
+ if args.action == "catalog":
131
+ for t in load_catalog():
132
+ print(f"{t.id} {t.stride:<24} {t.title}")
133
+ return 0
134
+ try:
135
+ doc = render_markdown(load_system(args.system), load_catalog())
136
+ except (SystemSpecError, OSError, KeyError) as exc:
137
+ print(f"error: {exc}", file=sys.stderr)
138
+ return 2
139
+ if args.output:
140
+ Path(args.output).write_text(doc, encoding="utf-8", newline="\n")
141
+ print(f"wrote {args.output}")
142
+ else:
143
+ print(doc)
144
+ return 0
145
+
146
+
147
+ def build_parser() -> argparse.ArgumentParser:
148
+ p = argparse.ArgumentParser(prog="agentsec", description="Security toolkit for LLM agents.")
149
+ p.add_argument("--version", action="version", version=f"agentsec {__version__}")
150
+ sub = p.add_subparsers(dest="command", required=True)
151
+
152
+ f = sub.add_parser("fuzz", help="Run the prompt-injection fuzzer against a target.")
153
+ f.add_argument(
154
+ "--target",
155
+ default="builtin:guarded",
156
+ help="builtin:naive | builtin:guarded | builtin:guarded-noscanner | http(s)://URL | "
157
+ "anthropic:MODEL | package.module:callable",
158
+ )
159
+ f.add_argument("--scenarios", help=f"comma list from {list(SCENARIOS)} (default: all)")
160
+ f.add_argument("--mutators", help=f"comma list from {list(MUTATORS)} (default: all)")
161
+ f.add_argument("--categories", help="comma list of payload categories (default: all)")
162
+ f.add_argument("--seed", type=int, default=0)
163
+ f.add_argument("--max-cases", type=int, help="randomly sample at most N cases")
164
+ f.add_argument("--max-asr", type=float, help="exit 1 if attack-success rate exceeds this (0-1)")
165
+ f.add_argument("--json", help="write full JSON report to this path")
166
+ f.add_argument("--md", help="write Markdown report to this path")
167
+ f.add_argument("--sarif", help="write a SARIF 2.1.0 report (GitHub code scanning)")
168
+ f.add_argument(
169
+ "--sarif-artifact",
170
+ default="agent",
171
+ help="repo-relative path of the file that defines the agent; SARIF alerts attach to it",
172
+ )
173
+ f.add_argument("--junit", help="write a JUnit XML report to this path")
174
+ f.add_argument("--quiet", action="store_true", help="only print the one-line summary")
175
+ f.add_argument("--list", action="store_true", help="show the test matrix and exit")
176
+ f.set_defaults(func=cmd_fuzz)
177
+
178
+ pol = sub.add_parser("policy", help="Validate a tool policy or test a call against it.")
179
+ pol_sub = pol.add_subparsers(dest="action", required=True)
180
+ pv = pol_sub.add_parser("validate", help="Load and lint a policy file.")
181
+ pv.add_argument("file")
182
+ pv.add_argument("--strict", action="store_true", help="exit 1 on lint warnings")
183
+ pc = pol_sub.add_parser("check", help="Evaluate one tool call against a policy.")
184
+ pc.add_argument("file")
185
+ pc.add_argument("--tool", required=True)
186
+ pc.add_argument("--args", help="JSON object of arguments")
187
+ pc.add_argument(
188
+ "--tainted", action="store_true", help="evaluate as if untrusted content was read"
189
+ )
190
+ pol.set_defaults(func=cmd_policy)
191
+
192
+ a = sub.add_parser("audit", help="Audit-log utilities.")
193
+ a_sub = a.add_subparsers(dest="action", required=True)
194
+ av = a_sub.add_parser("verify", help="Verify a hash-chained JSONL audit log.")
195
+ av.add_argument("file")
196
+ a.set_defaults(func=cmd_audit)
197
+
198
+ tm = sub.add_parser("threatmodel", help="STRIDE-for-agents threat modeling.")
199
+ tm_sub = tm.add_subparsers(dest="action", required=True)
200
+ ti = tm_sub.add_parser("init", help="Print or write a starter system description.")
201
+ ti.add_argument("-o", "--output")
202
+ tr = tm_sub.add_parser("render", help="Generate a threat model from a system description.")
203
+ tr.add_argument("system", help="system description YAML")
204
+ tr.add_argument("-o", "--output")
205
+ tm_sub.add_parser("catalog", help="List the threat catalog.")
206
+ tm_sub.add_parser(
207
+ "crosswalk", help="Map the catalog to OWASP Agentic Top 10 and MITRE ATLAS (Markdown)."
208
+ )
209
+ tm.set_defaults(func=cmd_threatmodel)
210
+ return p
211
+
212
+
213
+ def main(argv: list[str] | None = None) -> int:
214
+ args = build_parser().parse_args(argv)
215
+ return int(args.func(args))
216
+
217
+
218
+ if __name__ == "__main__":
219
+ sys.exit(main())
@@ -0,0 +1,44 @@
1
+ """Prompt-injection test harness / fuzzer."""
2
+
3
+ from agentsec.fuzzer.harness import (
4
+ Case,
5
+ CaseResult,
6
+ FuzzConfig,
7
+ build_cases,
8
+ detect,
9
+ run,
10
+ )
11
+ from agentsec.fuzzer.mock_agents import GuardedAgent, NaiveAgent, demo_policy
12
+ from agentsec.fuzzer.mutators import MUTATORS
13
+ from agentsec.fuzzer.payloads import CORPUS, Payload
14
+ from agentsec.fuzzer.report import FuzzReport
15
+ from agentsec.fuzzer.targets import (
16
+ AnthropicTarget,
17
+ HttpTarget,
18
+ OllamaTarget,
19
+ load_target,
20
+ sync_target,
21
+ text_target,
22
+ )
23
+
24
+ __all__ = [
25
+ "CORPUS",
26
+ "MUTATORS",
27
+ "AnthropicTarget",
28
+ "Case",
29
+ "CaseResult",
30
+ "FuzzConfig",
31
+ "FuzzReport",
32
+ "GuardedAgent",
33
+ "HttpTarget",
34
+ "NaiveAgent",
35
+ "OllamaTarget",
36
+ "Payload",
37
+ "build_cases",
38
+ "demo_policy",
39
+ "detect",
40
+ "load_target",
41
+ "run",
42
+ "sync_target",
43
+ "text_target",
44
+ ]
@@ -0,0 +1,222 @@
1
+ """CI-friendly renderings of a fuzz report: SARIF 2.1.0 and JUnit XML.
2
+
3
+ SARIF lets results appear in GitHub code scanning (and other SARIF viewers); JUnit
4
+ XML is understood by nearly every CI system's test-report UI.
5
+
6
+ Both embed text that came back from the system under test. An attacker may have
7
+ steered that text, so it is bounded and stripped of control characters before use.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import unicodedata
13
+ from typing import TYPE_CHECKING, Any
14
+ from xml.etree import ElementTree as ET # noqa: S405 - only used to *generate* XML
15
+
16
+ from agentsec import __version__
17
+
18
+ if TYPE_CHECKING:
19
+ from agentsec.fuzzer.harness import CaseResult
20
+ from agentsec.fuzzer.report import FuzzReport
21
+
22
+ SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
23
+ INFORMATION_URI = "https://github.com/DustyStudy/ai-agent-security-toolkit"
24
+ MAX_TEXT = 300
25
+
26
+ # Unicode categories removed from quoted text: control characters (incl. NUL and ESC), format
27
+ # characters (zero-width and bidirectional overrides that can visually spoof a report),
28
+ # line/paragraph separators and lone surrogates. XML 1.0 cannot carry most of these anyway.
29
+ _STRIP_CATEGORIES = frozenset({"Cc", "Cf", "Cs", "Zl", "Zp"})
30
+
31
+ # goal -> (rule id, name, severity level, GitHub security-severity, OWASP LLM Top 10 (2025), help)
32
+ _RULES: dict[str, tuple[str, str, str, str, str, str]] = {
33
+ "tool_misuse": (
34
+ "AGSEC-TOOL-MISUSE",
35
+ "InjectedInstructionCalledForbiddenTool",
36
+ "error",
37
+ "8.5",
38
+ "LLM06",
39
+ "A prompt injection made the agent request a tool it must never call. Enforce a "
40
+ "deny-by-default tool policy (agentsec.sandbox.ToolGuard) so a hijacked model "
41
+ "cannot act on the request.",
42
+ ),
43
+ "exfil": (
44
+ "AGSEC-EXFILTRATION",
45
+ "InjectedInstructionCausedDataExfiltration",
46
+ "error",
47
+ "8.5",
48
+ "LLM02",
49
+ "A prompt injection made the agent send data toward an attacker-controlled host "
50
+ "(tool arguments or a rendered markdown/HTML image or link). Validate output and "
51
+ "restrict outbound destinations (agentsec.middleware.OutputGuard, ToolGuard URL rules).",
52
+ ),
53
+ "prompt_leak": (
54
+ "AGSEC-PROMPT-LEAK",
55
+ "InjectedInstructionLeakedSystemPrompt",
56
+ "error",
57
+ "7.0",
58
+ "LLM07",
59
+ "A prompt injection made the agent disclose a secret planted in its system prompt. "
60
+ "Keep secrets out of prompts and add a protected-string output check.",
61
+ ),
62
+ "canary": (
63
+ "AGSEC-INSTRUCTION-FOLLOWING",
64
+ "AgentFollowedInjectedInstruction",
65
+ "warning",
66
+ "5.0",
67
+ "LLM01",
68
+ "The agent followed an instruction planted in untrusted content. On its own this is "
69
+ "low impact, but it is the first step of every real injection. Contain the blast "
70
+ "radius with tool policy and output validation.",
71
+ ),
72
+ }
73
+ _FALLBACK = (
74
+ "AGSEC-OTHER",
75
+ "InjectionSucceeded",
76
+ "warning",
77
+ "5.0",
78
+ "LLM01",
79
+ "A prompt-injection test case succeeded against the target.",
80
+ )
81
+
82
+
83
+ def clean(text: str, limit: int = MAX_TEXT) -> str:
84
+ """Bound and de-fang text that originated in the system under test."""
85
+ text = "".join(
86
+ " " if unicodedata.category(ch) in _STRIP_CATEGORIES or ch in "\ufffe\uffff" else ch
87
+ for ch in text
88
+ )
89
+ return text if len(text) <= limit else text[: limit - 1] + "…"
90
+
91
+
92
+ def _rule_for(goal: str) -> tuple[str, str, str, str, str, str]:
93
+ return _RULES.get(goal, _FALLBACK)
94
+
95
+
96
+ # ------------------------------------------------------------------------------ SARIF
97
+
98
+
99
+ def to_sarif(
100
+ report: FuzzReport, *, artifact_uri: str = "agent", target: str | None = None
101
+ ) -> dict[str, Any]:
102
+ """Render successful attacks as a SARIF 2.1.0 log.
103
+
104
+ Fuzz findings have no source location, but code-scanning UIs need one to attach an
105
+ alert to, so every result points at ``artifact_uri`` (line 1). Pass the path of the
106
+ file that defines your agent, relative to the repository root.
107
+ """
108
+ used = sorted({_rule_for(r.goal) for r in report.results if r.success})
109
+ rules = [
110
+ {
111
+ "id": rid,
112
+ "name": name,
113
+ "shortDescription": {"text": name},
114
+ "fullDescription": {"text": help_text},
115
+ "help": {"text": help_text},
116
+ "defaultConfiguration": {"level": level},
117
+ "properties": {
118
+ "tags": ["security", "prompt-injection", "OWASP-LLM-2025-" + owasp],
119
+ "security-severity": sev,
120
+ "precision": "high",
121
+ },
122
+ }
123
+ for rid, name, level, sev, owasp, help_text in used
124
+ ]
125
+ results = [_sarif_result(r, artifact_uri) for r in report.results if r.success]
126
+ run: dict[str, Any] = {
127
+ "tool": {
128
+ "driver": {
129
+ "name": "agentsec",
130
+ "version": __version__,
131
+ "informationUri": INFORMATION_URI,
132
+ "rules": rules,
133
+ }
134
+ },
135
+ "results": results,
136
+ "properties": {
137
+ "seed": report.seed,
138
+ "attackSuccessRate": report.asr,
139
+ "cases": report.total,
140
+ "targetErrors": report.errors,
141
+ },
142
+ }
143
+ if target:
144
+ run["properties"]["target"] = clean(target, 200)
145
+ return {"$schema": SARIF_SCHEMA, "version": "2.1.0", "runs": [run]}
146
+
147
+
148
+ def _sarif_result(r: CaseResult, artifact_uri: str) -> dict[str, Any]:
149
+ rid, _, level, _, _, _ = _rule_for(r.goal)
150
+ detail = f" Evidence: {clean(r.evidence)}" if r.evidence else ""
151
+ return {
152
+ "ruleId": rid,
153
+ "level": level,
154
+ "message": {
155
+ "text": (
156
+ f"Attack {r.payload_id} ({r.category}, obfuscation {r.mutator}, "
157
+ f"delivered {r.scenario}) succeeded: {', '.join(r.hits)}.{detail}"
158
+ )
159
+ },
160
+ "locations": [
161
+ {
162
+ "physicalLocation": {
163
+ "artifactLocation": {"uri": artifact_uri},
164
+ "region": {"startLine": 1},
165
+ }
166
+ }
167
+ ],
168
+ "partialFingerprints": {"agentsec/caseId/v1": r.case_id},
169
+ "properties": {"category": r.category, "mutator": r.mutator, "scenario": r.scenario},
170
+ }
171
+
172
+
173
+ # ------------------------------------------------------------------------------ JUnit
174
+
175
+
176
+ def to_junit(report: FuzzReport) -> str:
177
+ """Render a JUnit XML report: one test suite per attack category, one test per case.
178
+
179
+ A succeeded attack is a *failure*; a case where the target crashed is an *error*.
180
+ """
181
+ by_category: dict[str, list[CaseResult]] = {}
182
+ for r in report.results:
183
+ by_category.setdefault(r.category, []).append(r)
184
+
185
+ root = ET.Element(
186
+ "testsuites",
187
+ name="agentsec-fuzz",
188
+ tests=str(report.total),
189
+ failures=str(report.successes),
190
+ errors=str(report.errors),
191
+ )
192
+ for category, cases in sorted(by_category.items()):
193
+ suite = ET.SubElement(
194
+ root,
195
+ "testsuite",
196
+ name=clean(category),
197
+ tests=str(len(cases)),
198
+ failures=str(sum(1 for c in cases if c.success)),
199
+ errors=str(sum(1 for c in cases if c.error is not None)),
200
+ time=f"{sum(c.latency_ms for c in cases) / 1000:.3f}",
201
+ )
202
+ for c in cases:
203
+ case = ET.SubElement(
204
+ suite,
205
+ "testcase",
206
+ classname=f"agentsec.{clean(category)}",
207
+ name=clean(c.case_id),
208
+ time=f"{c.latency_ms / 1000:.3f}",
209
+ )
210
+ if c.error is not None:
211
+ err = ET.SubElement(case, "error", message=clean(c.error), type="TargetError")
212
+ err.text = clean(c.error)
213
+ elif c.success:
214
+ fail = ET.SubElement(
215
+ case,
216
+ "failure",
217
+ message=clean(f"attack succeeded: {', '.join(c.hits)}"),
218
+ type=_rule_for(c.goal)[0],
219
+ )
220
+ fail.text = clean(c.evidence)
221
+ ET.indent(root)
222
+ return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode") + "\n"