diff --git a/CHANGELOG.md b/CHANGELOG.md index 183dbcc..4afb6c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.16.0] + +### Added +- **AG034 — Cloud/infrastructure destruction exposed to the agent.** Flags an agent tool that + can tear down cloud infrastructure with no approval: AWS `terminate_instances`, + `delete_bucket`, `delete_db_instance`, `delete_cluster`, `delete_stack`, `delete_volume`, + and Kubernetes `delete_namespaced_*` / `delete_collection_*` / `delete_namespace`. Mapped to + MITRE ATT&CK T1485 + T1531. +- **AG035 — Money movement exposed to the agent without approval.** Flags `Refund.create`, + `Payout.create`, and `Transfer.create` (Stripe-style) inside an agent tool with no approval + gate — the classic prompt-injection payout attack. + +Both reuse AG033's call-based, tool-scoped, approval-suppressed model (the shared +`_iter_tool_sinks` spine), so they stay zero-false-positive. Verified: corpus precision/recall +1.000, and 0 findings across the 41-repo real benchmark. + ## [0.15.0] ### Added diff --git a/README.md b/README.md index 126a50c..b00777f 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,9 @@ passthrough, guardrail self-modification, secrets in model context, insecure des framework flags (`allow_dangerous_*`, `trust_remote_code`), code/shell interpreter tools (`PythonREPLTool`, `ShellTool`), a disabled code-execution sandbox (`use_docker=False`), irreversible datastore/filesystem wipes exposed to an agent tool with no approval -(`drop_all`, `flushall`, `shutil.rmtree`, `DROP DATABASE`), +(`drop_all`, `flushall`, `shutil.rmtree`, `DROP DATABASE`), cloud/infrastructure +destruction (`terminate_instances`, `delete_bucket`, `delete_cluster`, k8s teardown), +money movement without approval (`Refund`/`Payout`/`Transfer.create`), known-vulnerable framework dependencies (version-validated CVEs), and more. Run `autonomyproof rules list` for the full catalogue and `autonomyproof rules explain AG001` for details. Every finding carries **OWASP Agentic, NIST AI RMF, ISO 42001, MITRE @@ -138,7 +140,7 @@ re-run `autonomyproof baseline .` and commit the updated file in the same PR. Use the action directly: ```yaml -- uses: autonomyproof/autonomyproof-cli@v0.15.0 +- uses: autonomyproof/autonomyproof-cli@v0.16.0 with: target: . fail-on: high @@ -165,7 +167,7 @@ Gate locally before a commit ever leaves your machine: # .pre-commit-config.yaml repos: - repo: https://github.com/autonomyproof/autonomyproof-cli - rev: v0.15.0 + rev: v0.16.0 hooks: - id: autonomyproof ``` diff --git a/benchmark/CORPUS_RESULTS.md b/benchmark/CORPUS_RESULTS.md index 72dc595..d8f77bb 100644 --- a/benchmark/CORPUS_RESULTS.md +++ b/benchmark/CORPUS_RESULTS.md @@ -1,6 +1,6 @@ # Labeled-corpus results (ground-truth precision & recall) -**Cases:** 127 · **Rules covered:** 27 · **Overall precision:** 1.000 · **Overall recall:** 1.000 +**Cases:** 136 · **Rules covered:** 29 · **Overall precision:** 1.000 · **Overall recall:** 1.000 | Rule | pos | neg | TP | FP | FN | Precision | Recall | F1 | |---|--:|--:|--:|--:|--:|--:|--:|--:| @@ -31,8 +31,10 @@ | AG031 | 2 | 2 | 2 | 0 | 0 | 1.00 | 1.00 | 1.00 | | AG032 | 1 | 1 | 1 | 0 | 0 | 1.00 | 1.00 | 1.00 | | AG033 | 4 | 3 | 4 | 0 | 0 | 1.00 | 1.00 | 1.00 | +| AG034 | 3 | 2 | 3 | 0 | 0 | 1.00 | 1.00 | 1.00 | +| AG035 | 2 | 2 | 2 | 0 | 0 | 1.00 | 1.00 | 1.00 | -**Totals:** TP 67 · FP 0 · FN 0 · TN 60 +**Totals:** TP 72 · FP 0 · FN 0 · TN 64 Precision = of the cases where a rule fired, how many were true positives. Recall = of the cases where a rule should fire, how many did. Reproduce with `python benchmark/corpus_eval.py`. diff --git a/benchmark/corpus.yaml b/benchmark/corpus.yaml index 0171611..ad7a8c5 100644 --- a/benchmark/corpus.yaml +++ b/benchmark/corpus.yaml @@ -187,6 +187,19 @@ cases: - {id: ag033-neg-approval, rule: AG033, label: negative, code: "@tool\ndef reset_db():\n if not confirm:\n return\n Base.metadata.drop_all(engine)\n"} - {id: ag033-neg-pandas, rule: AG033, label: negative, code: "@tool\ndef trim(df):\n return df.drop(columns=['x'])\n"} + # --- AG034 cloud/infra destruction via agent tool --- + - {id: ag034-pos-terminate, rule: AG034, label: positive, code: "@tool\ndef scale_down():\n ec2.terminate_instances(InstanceIds=ids)\n"} + - {id: ag034-pos-bucket, rule: AG034, label: positive, code: "@tool\ndef purge():\n s3.delete_bucket(Bucket=b)\n"} + - {id: ag034-pos-k8s, rule: AG034, label: positive, code: "@tool\ndef teardown():\n api.delete_namespaced_deployment(n, ns)\n"} + - {id: ag034-neg-nontool, rule: AG034, label: negative, code: "def scale_down():\n ec2.terminate_instances(InstanceIds=ids)\n"} + - {id: ag034-neg-benign, rule: AG034, label: negative, code: "@tool\ndef cleanup():\n queue.delete_message(handle)\n"} + + # --- AG035 money movement via agent tool --- + - {id: ag035-pos-refund, rule: AG035, label: positive, code: "@tool\ndef handle(o):\n stripe.Refund.create(charge=o.charge)\n"} + - {id: ag035-pos-payout, rule: AG035, label: positive, code: "from stripe import Payout\n@tool\ndef pay(v):\n Payout.create(amount=v.owed)\n"} + - {id: ag035-neg-nontool, rule: AG035, label: negative, code: "def handle(o):\n stripe.Refund.create(charge=o.charge)\n"} + - {id: ag035-neg-customer, rule: AG035, label: negative, code: "@tool\ndef signup(e):\n stripe.Customer.create(email=e)\n"} + # --- AG021 broadened deserialization sinks --- - {id: ag021-pos-joblib, rule: AG021, label: positive, code: "import joblib\njoblib.load(f)\n"} - {id: ag021-pos-pandas, rule: AG021, label: positive, code: "import pandas\npandas.read_pickle(f)\n"} diff --git a/benchmark/results.json b/benchmark/results.json index 69341e6..d34f696 100644 --- a/benchmark/results.json +++ b/benchmark/results.json @@ -1,36 +1,36 @@ { - "repo_count": 41, - "total_files": 34722, - "total_findings": 9148, + "repo_count": 40, + "total_files": 34831, + "total_findings": 9217, "by_rule": { "AG001": 39, - "AG002": 156, - "AG003": 3392, - "AG004": 54, - "AG005": 1004, - "AG006": 71, - "AG007": 51, - "AG009": 537, + "AG002": 159, + "AG003": 3354, + "AG004": 58, + "AG005": 994, + "AG006": 76, + "AG007": 55, + "AG009": 564, "AG011": 73, - "AG012": 820, - "AG013": 151, + "AG012": 809, + "AG013": 175, "AG014": 11, - "AG015": 254, - "AG016": 35, - "AG017": 215, - "AG018": 1496, - "AG019": 401, - "AG020": 41, - "AG021": 58, - "AG022": 28, - "AG023": 111, + "AG015": 276, + "AG016": 42, + "AG017": 219, + "AG018": 1511, + "AG019": 394, + "AG020": 40, + "AG021": 60, + "AG022": 30, + "AG023": 112, "AG024": 12, - "AG025": 120, + "AG025": 135, "AG026": 7, "AG028": 4, "AG029": 1, "AG031": 5, - "AG032": 1 + "AG032": 2 }, "per_repo": [ { @@ -53,7 +53,7 @@ { "repo": "crewai", "status": "ok", - "files_scanned": 1281, + "files_scanned": 1297, "findings": 360, "by_rule": { "AG002": 5, @@ -77,7 +77,7 @@ { "repo": "langgraph", "status": "ok", - "files_scanned": 447, + "files_scanned": 450, "findings": 309, "by_rule": { "AG003": 52, @@ -97,44 +97,47 @@ { "repo": "openai-agents", "status": "ok", - "files_scanned": 841, - "findings": 294, + "files_scanned": 887, + "findings": 345, "by_rule": { - "AG002": 6, - "AG003": 28, + "AG002": 11, + "AG003": 31, + "AG004": 4, "AG005": 9, "AG007": 2, - "AG009": 38, + "AG009": 44, "AG012": 9, - "AG013": 8, + "AG013": 9, "AG015": 6, - "AG016": 8, + "AG016": 11, "AG017": 2, - "AG018": 30, + "AG018": 44, "AG019": 36, "AG020": 1, "AG022": 6, - "AG025": 105 + "AG025": 120 } }, { "repo": "pydantic-ai", "status": "ok", - "files_scanned": 605, - "findings": 83, + "files_scanned": 671, + "findings": 109, "by_rule": { "AG002": 6, - "AG003": 17, + "AG003": 19, "AG005": 7, "AG006": 1, - "AG009": 27, + "AG007": 2, + "AG009": 36, "AG012": 4, - "AG013": 1, - "AG015": 2, + "AG013": 5, + "AG015": 5, + "AG016": 1, "AG017": 2, - "AG018": 11, + "AG018": 15, "AG020": 1, - "AG021": 4 + "AG021": 5 } }, { @@ -205,23 +208,23 @@ { "repo": "agno", "status": "ok", - "files_scanned": 4295, - "findings": 802, + "files_scanned": 4320, + "findings": 855, "by_rule": { "AG001": 2, - "AG002": 8, + "AG002": 17, "AG003": 177, "AG004": 6, "AG005": 75, "AG006": 1, - "AG007": 18, - "AG009": 15, + "AG007": 20, + "AG009": 17, "AG011": 2, "AG012": 221, - "AG013": 35, + "AG013": 58, "AG014": 1, - "AG015": 69, - "AG016": 8, + "AG015": 83, + "AG016": 11, "AG017": 2, "AG018": 113, "AG019": 47, @@ -232,16 +235,16 @@ { "repo": "haystack", "status": "ok", - "files_scanned": 541, - "findings": 130, + "files_scanned": 554, + "findings": 120, "by_rule": { "AG002": 1, - "AG003": 58, - "AG005": 3, + "AG003": 52, + "AG005": 2, "AG009": 1, "AG015": 5, "AG016": 1, - "AG018": 9, + "AG018": 6, "AG020": 1, "AG021": 1, "AG023": 50 @@ -250,21 +253,21 @@ { "repo": "litellm", "status": "ok", - "files_scanned": 5084, - "findings": 879, + "files_scanned": 5192, + "findings": 939, "by_rule": { "AG001": 2, "AG002": 3, - "AG003": 401, + "AG003": 407, "AG004": 5, - "AG005": 79, - "AG006": 21, - "AG009": 92, + "AG005": 84, + "AG006": 26, + "AG009": 101, "AG012": 5, "AG014": 1, "AG015": 13, - "AG017": 71, - "AG018": 162, + "AG017": 83, + "AG018": 185, "AG019": 18, "AG020": 1, "AG022": 2, @@ -314,17 +317,17 @@ { "repo": "livekit-agents", "status": "ok", - "files_scanned": 901, - "findings": 131, + "files_scanned": 935, + "findings": 139, "by_rule": { "AG002": 2, - "AG003": 16, + "AG003": 19, "AG005": 2, "AG007": 5, - "AG009": 68, + "AG009": 72, "AG012": 4, "AG013": 8, - "AG015": 12, + "AG015": 13, "AG016": 1, "AG018": 8, "AG020": 1, @@ -336,7 +339,7 @@ { "repo": "browser-use", "status": "ok", - "files_scanned": 370, + "files_scanned": 385, "findings": 101, "by_rule": { "AG002": 2, @@ -410,25 +413,25 @@ { "repo": "langflow", "status": "ok", - "files_scanned": 3566, - "findings": 817, + "files_scanned": 3582, + "findings": 827, "by_rule": { "AG002": 24, "AG003": 92, "AG004": 10, - "AG005": 259, + "AG005": 258, "AG006": 8, "AG007": 2, - "AG009": 34, + "AG009": 36, "AG011": 11, "AG012": 65, "AG013": 3, - "AG015": 10, + "AG015": 14, "AG017": 3, "AG018": 218, - "AG019": 59, + "AG019": 62, "AG020": 1, - "AG021": 6, + "AG021": 8, "AG022": 5, "AG024": 2, "AG028": 4, @@ -447,10 +450,11 @@ { "repo": "dspy", "status": "ok", - "files_scanned": 261, - "findings": 110, + "files_scanned": 279, + "findings": 114, "by_rule": { - "AG003": 66, + "AG002": 1, + "AG003": 67, "AG005": 10, "AG009": 1, "AG013": 5, @@ -458,6 +462,7 @@ "AG018": 19, "AG020": 1, "AG021": 2, + "AG022": 2, "AG026": 2 } }, @@ -485,10 +490,10 @@ { "repo": "mem0", "status": "ok", - "files_scanned": 362, - "findings": 188, + "files_scanned": 366, + "findings": 187, "by_rule": { - "AG003": 85, + "AG003": 84, "AG004": 1, "AG005": 20, "AG011": 8, @@ -501,7 +506,7 @@ { "repo": "marvin", "status": "ok", - "files_scanned": 179, + "files_scanned": 180, "findings": 62, "by_rule": { "AG001": 1, @@ -535,16 +540,17 @@ { "repo": "adk-python", "status": "ok", - "files_scanned": 1708, - "findings": 340, + "files_scanned": 1756, + "findings": 351, "by_rule": { "AG002": 4, - "AG003": 187, + "AG003": 195, "AG004": 9, "AG005": 29, "AG006": 2, "AG007": 5, "AG009": 15, + "AG012": 1, "AG013": 12, "AG015": 10, "AG017": 3, @@ -552,8 +558,9 @@ "AG019": 7, "AG020": 1, "AG021": 9, - "AG023": 3, - "AG025": 1 + "AG023": 4, + "AG025": 1, + "AG032": 1 } }, { @@ -581,23 +588,23 @@ { "repo": "swarms", "status": "ok", - "files_scanned": 944, - "findings": 310, + "files_scanned": 965, + "findings": 300, "by_rule": { "AG001": 1, "AG002": 2, - "AG003": 157, + "AG003": 153, "AG004": 2, - "AG005": 29, + "AG005": 28, "AG006": 2, - "AG009": 3, + "AG009": 2, "AG012": 5, "AG013": 12, "AG014": 1, "AG015": 45, "AG016": 3, - "AG017": 10, - "AG018": 36, + "AG017": 9, + "AG018": 33, "AG019": 1, "AG020": 1 } @@ -618,22 +625,7 @@ }, { "repo": "langroid", - "status": "ok", - "files_scanned": 438, - "findings": 150, - "by_rule": { - "AG002": 12, - "AG003": 57, - "AG005": 12, - "AG009": 11, - "AG012": 12, - "AG013": 4, - "AG017": 7, - "AG018": 23, - "AG019": 10, - "AG020": 1, - "AG021": 1 - } + "status": "clone_failed" }, { "repo": "multi-agent-orchestrator", @@ -672,13 +664,13 @@ { "repo": "pipecat", "status": "ok", - "files_scanned": 1157, - "findings": 144, + "files_scanned": 1193, + "findings": 147, "by_rule": { - "AG003": 45, + "AG003": 46, "AG004": 3, "AG005": 2, - "AG009": 54, + "AG009": 56, "AG013": 8, "AG015": 10, "AG017": 4, @@ -712,15 +704,15 @@ { "repo": "open-interpreter", "status": "ok", - "files_scanned": 134, - "findings": 95, + "files_scanned": 137, + "findings": 98, "by_rule": { "AG002": 2, "AG003": 31, "AG005": 8, "AG009": 7, "AG015": 1, - "AG018": 45, + "AG018": 48, "AG020": 1 } }, @@ -745,7 +737,7 @@ { "repo": "beeai-framework", "status": "ok", - "files_scanned": 577, + "files_scanned": 583, "findings": 35, "by_rule": { "AG003": 7, @@ -763,13 +755,13 @@ { "repo": "agentscope", "status": "ok", - "files_scanned": 508, - "findings": 239, + "files_scanned": 576, + "findings": 250, "by_rule": { - "AG003": 137, + "AG003": 143, "AG005": 7, "AG006": 1, - "AG009": 15, + "AG009": 20, "AG015": 9, "AG017": 1, "AG018": 1, diff --git a/pyproject.toml b/pyproject.toml index 94aec44..a71e373 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "autonomyproof" -version = "0.15.0" +version = "0.16.0" description = "Open-source local scanner that finds unsafe capabilities and missing guardrails in Python AI-agent code." readme = "README.md" requires-python = ">=3.11" diff --git a/src/autonomyproof/__init__.py b/src/autonomyproof/__init__.py index b42715a..ecaadcd 100644 --- a/src/autonomyproof/__init__.py +++ b/src/autonomyproof/__init__.py @@ -4,4 +4,4 @@ __all__ = ["__version__"] -__version__ = "0.15.0" +__version__ = "0.16.0" diff --git a/src/autonomyproof/rules/agent_controls.py b/src/autonomyproof/rules/agent_controls.py index bf7f502..9994328 100644 --- a/src/autonomyproof/rules/agent_controls.py +++ b/src/autonomyproof/rules/agent_controls.py @@ -3,7 +3,7 @@ from __future__ import annotations import ast -from collections.abc import Iterable +from collections.abc import Callable, Iterable from autonomyproof.astutils import string_literals from autonomyproof.models import Finding, Mappings, Severity @@ -96,6 +96,30 @@ # Destructive DDL embedded as a string and executed from inside a tool body. _DESTRUCTIVE_SQL = ("drop database", "drop table", "truncate table") +# AG034 — cloud/infra resource destruction. These SDK method names each tear down a whole +# resource (a bucket, instance, cluster, stack, volume) and have no benign single-record +# meaning, so matching by method name inside a tool stays zero-FP. +_CLOUD_DESTROY_METHODS = { + "delete_bucket", # AWS S3 — the entire bucket + "terminate_instances", # AWS EC2 + "delete_db_instance", # AWS RDS + "delete_db_cluster", # AWS RDS/Aurora + "delete_cluster", # EKS / ECS / Redshift + "delete_stack", # CloudFormation — the whole stack + "delete_volume", # EBS + "delete_file_system", # EFS + "delete_nodegroup", # EKS +} +# Kubernetes client teardown verbs are matched by prefix (delete_namespaced_deployment, +# delete_collection_namespaced_pod, ...) plus the whole-namespace delete. +_K8S_DESTROY_PREFIXES = ("delete_namespaced_", "delete_collection_") +_K8S_DESTROY_METHODS = {"delete_namespace"} + +# AG035 — money movement. `.create(...)` on one of these Stripe-style resource +# classes moves funds; none has a benign meaning inside an unattended agent tool. +_MONEY_RESOURCES = {"Refund", "Payout", "Transfer"} +_MONEY_VERBS = {"create", "create_async"} + _CTRL_MAPPINGS = Mappings( owaspAgentic=["Excessive agency", "Insufficient oversight"], nistAiRmf=["Govern", "Manage"], @@ -115,6 +139,33 @@ def _identifiers_in(node: ast.AST) -> set[str]: return tokens +def _iter_tool_sinks( + ctx: RuleContext, + match: Callable[[RuleContext, ast.AST, ast.FunctionDef | ast.AsyncFunctionDef], str | None], +) -> Iterable[tuple[ast.FunctionDef | ast.AsyncFunctionDef, ast.AST, str]]: + """Yield ``(tool_func, sink_node, evidence)`` for each registered agent tool with no + approval marker whose body contains a sink accepted by ``match``. + + This is the shared spine of the high-impact tool-scoped rules (AG033/AG034/AG035): a + dangerous operation only counts as *authority the agent holds* when it sits inside a + model-callable tool and nothing gates it behind a human. At most one finding per tool. + """ + for node in ast.walk(ctx.analysis.tree): + if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + continue + if node.name not in ctx.tool_functions: + continue + # Substring match (like AG007) so approved/is_approved/needs_approval all suppress. + body_text = " ".join(_identifiers_in(node)) + if any(marker in body_text for marker in _APPROVAL_MARKERS): + continue + for child in ast.walk(node): + evidence = match(ctx, child, node) + if evidence is not None: + yield node, child, evidence + break + + class DangerousOperationRule(Rule): """AG007 — Dangerous operation without approval.""" @@ -182,45 +233,142 @@ class IrreversibleDataDestructionRule(Rule): ) def check(self, ctx: RuleContext) -> Iterable[Finding]: - for node in ast.walk(ctx.analysis.tree): - if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): - continue - # Only operations actually exposed to the model qualify — a destructive call in - # an ordinary migration script or admin helper is not agent-reachable authority. - if node.name not in ctx.tool_functions: - continue - if any(marker in _identifiers_in(node) for marker in _APPROVAL_MARKERS): - continue - hit = self._destructive_sink(ctx, node) - if hit is None: - continue - sink_node, evidence = hit + for func, sink, evidence in _iter_tool_sinks(ctx, self._match): yield self.make_finding( ctx, - sink_node, + sink, evidence=evidence, - tool_name=ctx.tool_functions.get(node.name, node.name), - pattern=f"{self.id}:{node.name}", + tool_name=ctx.tool_functions.get(func.name, func.name), + pattern=f"{self.id}:{func.name}", ) - def _destructive_sink( - self, ctx: RuleContext, func: ast.FunctionDef | ast.AsyncFunctionDef - ) -> tuple[ast.AST, str] | None: - for child in ast.walk(func): - if isinstance(child, ast.Call): - # Attribute calls whose name is an unambiguous full-store wipe (drop_all, - # flushall, drop_database, ...). Deliberately excludes the overloaded bare - # `.drop(` — pandas `df.drop(col)` is a benign column drop, not a wipe. - if isinstance(child.func, ast.Attribute) and child.func.attr in _WIPE_METHODS: - return child, f"Tool '{func.name}' calls {child.func.attr}() — full-store wipe" - if ctx.analysis.resolve_call(child) in _WIPE_FUNCTIONS: - name = ctx.analysis.resolve_call(child) - return child, f"Tool '{func.name}' calls {name}() — recursive delete" - if isinstance(child, ast.Constant) and isinstance(child.value, str): - lowered = child.value.lower() - marker = next((m for m in _DESTRUCTIVE_SQL if m in lowered), None) - if marker is not None: - return child, f"Tool '{func.name}' embeds destructive SQL: {marker!r}" + @staticmethod + def _match( + ctx: RuleContext, child: ast.AST, func: ast.FunctionDef | ast.AsyncFunctionDef + ) -> str | None: + if isinstance(child, ast.Call): + # Attribute calls whose name is an unambiguous full-store wipe (drop_all, + # flushall, drop_database, ...). Deliberately excludes the overloaded bare + # `.drop(` — pandas `df.drop(col)` is a benign column drop, not a wipe. + if isinstance(child.func, ast.Attribute) and child.func.attr in _WIPE_METHODS: + return f"Tool '{func.name}' calls {child.func.attr}() — full-store wipe" + name = ctx.analysis.resolve_call(child) + if name in _WIPE_FUNCTIONS: + return f"Tool '{func.name}' calls {name}() — recursive delete" + if isinstance(child, ast.Constant) and isinstance(child.value, str): + lowered = child.value.lower() + marker = next((m for m in _DESTRUCTIVE_SQL if m in lowered), None) + if marker is not None: + return f"Tool '{func.name}' embeds destructive SQL: {marker!r}" + return None + + +class CloudResourceDestructionRule(Rule): + """AG034 — Cloud/infrastructure destruction exposed to the agent.""" + + id = "AG034" + name = "Cloud/infrastructure destruction exposed to the agent" + default_severity = Severity.CRITICAL + description = ( + "An agent tool can tear down cloud infrastructure (delete a bucket, terminate " + "instances, delete a cluster/stack/volume) with no approval step." + ) + risk = ( + "A manipulated agent could destroy production infrastructure — an irreversible, " + "high-blast-radius action with no human in the loop." + ) + remediation = [ + "Require human approval before any terminate/delete of a cloud resource", + "Grant the agent least-privilege IAM that cannot destroy infrastructure", + "Scope the tool to a single named, non-production resource", + "Enable deletion protection / termination protection on critical resources", + ] + mappings = Mappings( + owaspAgentic=["Excessive agency", "Tool misuse"], + nistAiRmf=["Govern", "Manage"], + iso42001Alignment=["Operational control", "Accountability"], + mitre=["T1485", "T1531"], # Data Destruction, Account Access Removal + ) + + def check(self, ctx: RuleContext) -> Iterable[Finding]: + for func, sink, evidence in _iter_tool_sinks(ctx, self._match): + yield self.make_finding( + ctx, + sink, + evidence=evidence, + tool_name=ctx.tool_functions.get(func.name, func.name), + pattern=f"{self.id}:{func.name}", + ) + + @staticmethod + def _match( + ctx: RuleContext, child: ast.AST, func: ast.FunctionDef | ast.AsyncFunctionDef + ) -> str | None: + if not (isinstance(child, ast.Call) and isinstance(child.func, ast.Attribute)): + return None + attr = child.func.attr + if ( + attr in _CLOUD_DESTROY_METHODS + or attr in _K8S_DESTROY_METHODS + or attr.startswith(_K8S_DESTROY_PREFIXES) + ): + return f"Tool '{func.name}' calls {attr}() — cloud/infrastructure destruction" + return None + + +class FinancialTransactionRule(Rule): + """AG035 — Money movement exposed to the agent without approval.""" + + id = "AG035" + name = "Money movement exposed to the agent without approval" + default_severity = Severity.CRITICAL + description = ( + "An agent tool can move money (issue a refund, payout, or transfer) with no approval step." + ) + risk = ( + "A manipulated agent could issue refunds, payouts, or transfers — draining funds " + "with no human in the loop. This is the classic prompt-injection payout attack." + ) + remediation = [ + "Require human approval before any refund, payout, or transfer", + "Cap amounts and rate-limit financial actions", + "Use restricted API keys that cannot move funds", + "Log and alert on every money-movement call", + ] + mappings = Mappings( + owaspAgentic=["Excessive agency", "Tool misuse"], + nistAiRmf=["Govern", "Manage"], + iso42001Alignment=["Operational control", "Accountability"], + ) + + def check(self, ctx: RuleContext) -> Iterable[Finding]: + for func, sink, evidence in _iter_tool_sinks(ctx, self._match): + yield self.make_finding( + ctx, + sink, + evidence=evidence, + tool_name=ctx.tool_functions.get(func.name, func.name), + pattern=f"{self.id}:{func.name}", + ) + + @staticmethod + def _match( + ctx: RuleContext, child: ast.AST, func: ast.FunctionDef | ast.AsyncFunctionDef + ) -> str | None: + # Match `.create(...)` where Resource is Refund/Payout/Transfer, whether + # written as `stripe.Refund.create(...)` or an imported `Refund.create(...)`. + if not (isinstance(child, ast.Call) and isinstance(child.func, ast.Attribute)): + return None + if child.func.attr not in _MONEY_VERBS: + return None + receiver = child.func.value + resource: str | None = None + if isinstance(receiver, ast.Attribute): + resource = receiver.attr + elif isinstance(receiver, ast.Name): + resource = receiver.id + if resource in _MONEY_RESOURCES: + return f"Tool '{func.name}' calls {resource}.{child.func.attr}() — moves money" return None diff --git a/src/autonomyproof/rules/registry.py b/src/autonomyproof/rules/registry.py index 4a936ff..ffaaa2c 100644 --- a/src/autonomyproof/rules/registry.py +++ b/src/autonomyproof/rules/registry.py @@ -3,8 +3,10 @@ from __future__ import annotations from autonomyproof.rules.agent_controls import ( + CloudResourceDestructionRule, DangerousOperationRule, ExcessiveLimitRule, + FinancialTransactionRule, GuardrailSelfModificationRule, IrreversibleDataDestructionRule, McpArgumentValidationRule, @@ -84,6 +86,8 @@ CorsWildcardCredentialsRule, # AG031 DisabledSafetyFilterRule, # AG032 IrreversibleDataDestructionRule, # AG033 + CloudResourceDestructionRule, # AG034 + FinancialTransactionRule, # AG035 ] diff --git a/tests/test_cli.py b/tests/test_cli.py index 50d5b47..6d6f505 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,7 +33,7 @@ def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: def test_version(runner: CliRunner) -> None: result = runner.invoke(cli.main, ["--version"]) assert result.exit_code == 0 - assert "0.15.0" in result.output + assert "0.16.0" in result.output def test_init_creates_and_is_idempotent(runner: CliRunner) -> None: diff --git a/tests/test_rules_agent_controls.py b/tests/test_rules_agent_controls.py index bd0b980..e483d06 100644 --- a/tests/test_rules_agent_controls.py +++ b/tests/test_rules_agent_controls.py @@ -3,8 +3,10 @@ from __future__ import annotations from autonomyproof.rules.agent_controls import ( + CloudResourceDestructionRule, DangerousOperationRule, ExcessiveLimitRule, + FinancialTransactionRule, GuardrailSelfModificationRule, IrreversibleDataDestructionRule, McpArgumentValidationRule, @@ -100,6 +102,82 @@ def test_ag033_harmless_tool_clean() -> None: ) +# --- AG034 -------------------------------------------------------------------- +def test_ag034_terminate_instances() -> None: + code = "@tool\ndef scale_down():\n ec2.terminate_instances(InstanceIds=ids)\n" + findings = run_rule(CloudResourceDestructionRule(), code) + assert findings and findings[0].ruleId == "AG034" + assert findings[0].toolName == "scale_down" + + +def test_ag034_delete_bucket() -> None: + assert run_rule( + CloudResourceDestructionRule(), "@tool\ndef purge():\n s3.delete_bucket(Bucket=b)\n" + ) + + +def test_ag034_k8s_delete_namespaced() -> None: + code = "@tool\ndef teardown():\n api.delete_namespaced_deployment(name, ns)\n" + assert run_rule(CloudResourceDestructionRule(), code) + + +def test_ag034_non_tool_clean() -> None: + code = "def scale_down():\n ec2.terminate_instances(InstanceIds=ids)\n" + assert run_rule(CloudResourceDestructionRule(), code) == [] + + +def test_ag034_approval_gated_clean() -> None: + code = ( + "@tool\ndef scale_down():\n if not approved:\n" + " return\n ec2.terminate_instances(InstanceIds=ids)\n" + ) + assert run_rule(CloudResourceDestructionRule(), code) == [] + + +def test_ag034_benign_delete_clean() -> None: + # A single-message delete is not infra destruction — the method name is not in the set. + code = "@tool\ndef cleanup():\n queue.delete_message(handle)\n" + assert run_rule(CloudResourceDestructionRule(), code) == [] + + +# --- AG035 -------------------------------------------------------------------- +def test_ag035_stripe_refund() -> None: + code = "@tool\ndef handle(order):\n stripe.Refund.create(charge=order.charge)\n" + findings = run_rule(FinancialTransactionRule(), code) + assert findings and findings[0].ruleId == "AG035" + assert findings[0].toolName == "handle" + + +def test_ag035_imported_payout() -> None: + code = "from stripe import Payout\n@tool\ndef pay(vendor):\n Payout.create(amount=vendor.owed)\n" + assert run_rule(FinancialTransactionRule(), code) + + +def test_ag035_transfer() -> None: + assert run_rule( + FinancialTransactionRule(), "@tool\ndef move(x):\n stripe.Transfer.create(amount=x)\n" + ) + + +def test_ag035_non_tool_clean() -> None: + code = "def handle(order):\n stripe.Refund.create(charge=order.charge)\n" + assert run_rule(FinancialTransactionRule(), code) == [] + + +def test_ag035_approval_gated_clean() -> None: + code = ( + "@tool\ndef handle(order):\n if not confirm:\n" + " return\n stripe.Refund.create(charge=order.charge)\n" + ) + assert run_rule(FinancialTransactionRule(), code) == [] + + +def test_ag035_other_create_clean() -> None: + # Creating a non-money resource (e.g. a Customer) must not fire. + code = "@tool\ndef signup(email):\n stripe.Customer.create(email=email)\n" + assert run_rule(FinancialTransactionRule(), code) == [] + + # --- AG009 -------------------------------------------------------------------- def test_ag009_high_retries() -> None: assert run_rule(ExcessiveLimitRule(), "Agent(max_retries=50)\n") diff --git a/tests/test_scanner.py b/tests/test_scanner.py index d3e9591..73d28c2 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -30,7 +30,7 @@ def test_scan_produces_findings_and_metadata(tmp_path: Path) -> None: assert result.score < 100 assert result.risk_level assert result.files_scanned == 1 - assert len(result.rules_executed) == 33 + assert len(result.rules_executed) == 35 assert any(c.name == "Shell execution" for c in result.capabilities)