From a7301af8c92ff667f931bd0f75ef69b9bbc9b28e Mon Sep 17 00:00:00 2001 From: AutonomyProof Date: Mon, 3 Aug 2026 20:16:17 +0530 Subject: [PATCH] Add AG033: irreversible data destruction exposed to the agent (0.15.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects an agent tool that can wipe an entire datastore or directory tree with no approval step — the 'agent deleted the whole database' failure class. Why it's distinct from AG019 (destructive string) and generic SAST: - Call-based: catches db.drop_all(), redis.flushall(), shutil.rmtree() — which carry no SQL/string literal and so are invisible to string- or grep-based detection. - Agent-context scoped: fires only inside a registered tool function with no approval marker, which is what keeps it zero-false-positive. - Excludes the overloaded bare .drop( (pandas df.drop(columns=...)) by design. Sinks: drop_all, drop_database, drop_collection, flushall/flushdb, shutil.rmtree, os.removedirs, and embedded DROP DATABASE / DROP TABLE / TRUNCATE TABLE. Mapped to MITRE ATT&CK T1485 (Data Destruction) + T1561 (Disk Wipe). Verification: - Ground-truth corpus: 127 cases, precision 1.000 / recall 1.000 (adds 7 AG033 cases incl. pandas-drop, non-tool, and approval-gated negatives). - Real-repo benchmark: 0 findings across all 41 repos (frameworks register no such tool) — zero false positives, as expected; recall proven by the corpus. - 530 tests pass, 100% branch coverage, ruff + mypy clean. Bumps version 0.14.0 -> 0.15.0; adds CHANGELOG.md. Signed-off-by: AutonomyProof --- CHANGELOG.md | 27 +++++++ README.md | 6 +- benchmark/CORPUS_RESULTS.md | 5 +- benchmark/corpus.yaml | 9 +++ pyproject.toml | 2 +- src/autonomyproof/__init__.py | 2 +- src/autonomyproof/rules/agent_controls.py | 87 +++++++++++++++++++++++ src/autonomyproof/rules/registry.py | 2 + tests/test_cli.py | 2 +- tests/test_rules_agent_controls.py | 51 +++++++++++++ tests/test_scanner.py | 2 +- 11 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..183dbcc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +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.15.0] + +### Added +- **AG033 — Irreversible data destruction exposed to the agent.** Flags an agent tool that + can wipe an entire datastore or directory tree with no approval step: `drop_all`, + `drop_database`, `drop_collection`, `flushall`/`flushdb`, `shutil.rmtree`, and embedded + `DROP DATABASE` / `DROP TABLE` / `TRUNCATE TABLE`. Unlike the string-only AG019, this is + **call-based** (it catches `db.drop_all()` and `redis.flushall()`, which carry no SQL + literal) and is **scoped to registered agent tools without an approval gate**, so it stays + zero-false-positive. The overloaded bare `.drop(` (e.g. pandas `df.drop(columns=...)`) is + deliberately excluded. Mapped to MITRE ATT&CK T1485 (Data Destruction) and T1561 (Disk Wipe). + +## [0.14.0] + +### Changed +- AG021 broadened to cover `joblib.load`, `pandas.read_pickle`, and `numpy.load(allow_pickle=True)`. + +## [0.13.0] + +### Added +- AG026 version-validated CVE detection for known-vulnerable framework dependencies. diff --git a/README.md b/README.md index aa5b9b0..ab44c46 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ passthrough, guardrail self-modification, secrets in model context, insecure des (pickle/`yaml.load`), disabled TLS verification, server-side template injection, dangerous 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`), 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 @@ -136,7 +138,7 @@ re-run `autonomyproof baseline .` and commit the updated file in the same PR. Use the action directly: ```yaml -- uses: autonomyproof/autonomyproof-cli@v0.14.0 +- uses: autonomyproof/autonomyproof-cli@v0.15.0 with: target: . fail-on: high @@ -163,7 +165,7 @@ Gate locally before a commit ever leaves your machine: # .pre-commit-config.yaml repos: - repo: https://github.com/autonomyproof/autonomyproof-cli - rev: v0.14.0 + rev: v0.15.0 hooks: - id: autonomyproof ``` diff --git a/benchmark/CORPUS_RESULTS.md b/benchmark/CORPUS_RESULTS.md index 22886c9..72dc595 100644 --- a/benchmark/CORPUS_RESULTS.md +++ b/benchmark/CORPUS_RESULTS.md @@ -1,6 +1,6 @@ # Labeled-corpus results (ground-truth precision & recall) -**Cases:** 120 · **Rules covered:** 26 · **Overall precision:** 1.000 · **Overall recall:** 1.000 +**Cases:** 127 · **Rules covered:** 27 · **Overall precision:** 1.000 · **Overall recall:** 1.000 | Rule | pos | neg | TP | FP | FN | Precision | Recall | F1 | |---|--:|--:|--:|--:|--:|--:|--:|--:| @@ -30,8 +30,9 @@ | AG030 | 1 | 2 | 1 | 0 | 0 | 1.00 | 1.00 | 1.00 | | 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 | -**Totals:** TP 63 · FP 0 · FN 0 · TN 57 +**Totals:** TP 67 · FP 0 · FN 0 · TN 60 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 86bcd0d..0171611 100644 --- a/benchmark/corpus.yaml +++ b/benchmark/corpus.yaml @@ -178,6 +178,15 @@ cases: - {id: ag026-pos-gradio, rule: AG026, label: positive, code: "x = 1\n", files: {"requirements.txt": "gradio==5.30.0\n"}} - {id: ag026-neg-gradio-patched, rule: AG026, label: negative, code: "x = 1\n", files: {"requirements.txt": "gradio==5.31.0\n"}} + # --- AG033 irreversible data destruction via agent tool --- + - {id: ag033-pos-dropall, rule: AG033, label: positive, code: "@tool\ndef reset_db():\n Base.metadata.drop_all(engine)\n"} + - {id: ag033-pos-flushall, rule: AG033, label: positive, code: "@tool\ndef clear():\n r.flushall()\n"} + - {id: ag033-pos-rmtree, rule: AG033, label: positive, code: "import shutil\n@tool\ndef cleanup(p):\n shutil.rmtree(p)\n"} + - {id: ag033-pos-sql, rule: AG033, label: positive, code: "@tool\ndef wipe():\n cur.execute('DROP DATABASE prod')\n"} + - {id: ag033-neg-nontool, rule: AG033, label: negative, code: "def reset_db():\n Base.metadata.drop_all(engine)\n"} + - {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"} + # --- 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/pyproject.toml b/pyproject.toml index b535617..94aec44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "autonomyproof" -version = "0.14.0" +version = "0.15.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 a6b1d04..b42715a 100644 --- a/src/autonomyproof/__init__.py +++ b/src/autonomyproof/__init__.py @@ -4,4 +4,4 @@ __all__ = ["__version__"] -__version__ = "0.14.0" +__version__ = "0.15.0" diff --git a/src/autonomyproof/rules/agent_controls.py b/src/autonomyproof/rules/agent_controls.py index 8952226..bf7f502 100644 --- a/src/autonomyproof/rules/agent_controls.py +++ b/src/autonomyproof/rules/agent_controls.py @@ -78,6 +78,24 @@ } _LIMIT_KWARGS = ("max", "limit", "budget") +# AG033 — unambiguous "wipe the whole store" operations. Each name means "destroy everything" +# and has no benign single-record meaning, so matching by attribute name stays zero-FP. +_WIPE_METHODS = { + "drop_all", # SQLAlchemy metadata.drop_all() + "drop_database", # pymongo client.drop_database() + "drop_collection", # pymongo db.drop_collection() + "delete_collection", # chroma / vector stores + "delete_index", # elasticsearch / pinecone + "flushall", # redis FLUSHALL — every key in every db + "flushdb", # redis FLUSHDB — every key in the db + "flush_all", # memcached + "deleteall", # solr / assorted clients +} +# Recursive filesystem delete resolved through import tracking (module.attr form). +_WIPE_FUNCTIONS = {"shutil.rmtree", "os.removedirs"} +# Destructive DDL embedded as a string and executed from inside a tool body. +_DESTRUCTIVE_SQL = ("drop database", "drop table", "truncate table") + _CTRL_MAPPINGS = Mappings( owaspAgentic=["Excessive agency", "Insufficient oversight"], nistAiRmf=["Govern", "Manage"], @@ -137,6 +155,75 @@ def check(self, ctx: RuleContext) -> Iterable[Finding]: ) +class IrreversibleDataDestructionRule(Rule): + """AG033 — Irreversible datastore/filesystem wipe exposed to the agent.""" + + id = "AG033" + name = "Irreversible data destruction exposed to the agent" + default_severity = Severity.CRITICAL + description = ( + "An agent tool can wipe an entire datastore or directory tree with no approval step." + ) + risk = ( + "A manipulated agent could drop a database, flush a cache, or recursively delete " + "files — an irreversible action with no human in the loop." + ) + remediation = [ + "Remove the destructive call from the tool, or scope it to a single named target", + "Require human approval before any drop/flush/recursive-delete", + "Grant the agent least-privilege credentials that cannot destroy the store", + "Take a verified backup the operation cannot reach", + ] + mappings = Mappings( + owaspAgentic=["Excessive agency", "Tool misuse"], + nistAiRmf=["Govern", "Manage"], + iso42001Alignment=["Operational control", "Accountability"], + mitre=["T1485", "T1561"], # Data Destruction, Disk Wipe + ) + + 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 + yield self.make_finding( + ctx, + sink_node, + evidence=evidence, + tool_name=ctx.tool_functions.get(node.name, node.name), + pattern=f"{self.id}:{node.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}" + return None + + class ExcessiveLimitRule(Rule): """AG009 — Excessive execution limit.""" diff --git a/src/autonomyproof/rules/registry.py b/src/autonomyproof/rules/registry.py index ec30116..4a936ff 100644 --- a/src/autonomyproof/rules/registry.py +++ b/src/autonomyproof/rules/registry.py @@ -6,6 +6,7 @@ DangerousOperationRule, ExcessiveLimitRule, GuardrailSelfModificationRule, + IrreversibleDataDestructionRule, McpArgumentValidationRule, SubAgentCreationRule, ) @@ -82,6 +83,7 @@ PublicShareRule, # AG030 CorsWildcardCredentialsRule, # AG031 DisabledSafetyFilterRule, # AG032 + IrreversibleDataDestructionRule, # AG033 ] diff --git a/tests/test_cli.py b/tests/test_cli.py index bc4d67f..50d5b47 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.14.0" in result.output + assert "0.15.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 57a6565..c839a0d 100644 --- a/tests/test_rules_agent_controls.py +++ b/tests/test_rules_agent_controls.py @@ -6,6 +6,7 @@ DangerousOperationRule, ExcessiveLimitRule, GuardrailSelfModificationRule, + IrreversibleDataDestructionRule, McpArgumentValidationRule, SubAgentCreationRule, ) @@ -46,6 +47,56 @@ def test_ag007_non_dangerous_tool_clean() -> None: ) +# --- AG033 -------------------------------------------------------------------- +def test_ag033_tool_drops_all_tables() -> None: + # A benignly-named tool whose body wipes the schema — AG007 (name-based) would miss it. + code = "@tool\ndef reset_db():\n Base.metadata.drop_all(engine)\n" + findings = run_rule(IrreversibleDataDestructionRule(), code) + assert findings and findings[0].ruleId == "AG033" + assert findings[0].toolName == "reset_db" + + +def test_ag033_tool_flushes_redis() -> None: + findings = run_rule( + IrreversibleDataDestructionRule(), "@tool\ndef clear():\n redis_client.flushall()\n" + ) + assert findings and findings[0].ruleId == "AG033" + + +def test_ag033_tool_rmtree() -> None: + code = "import shutil\n@tool\ndef cleanup(path):\n shutil.rmtree(path)\n" + assert run_rule(IrreversibleDataDestructionRule(), code) + + +def test_ag033_tool_destructive_sql_literal() -> None: + code = '@tool\ndef wipe():\n cursor.execute("DROP DATABASE prod")\n' + assert run_rule(IrreversibleDataDestructionRule(), code) + + +def test_ag033_non_tool_function_clean() -> None: + # Same wipe in an ordinary migration helper is not agent-reachable authority. + code = "def reset_db():\n Base.metadata.drop_all(engine)\n" + assert run_rule(IrreversibleDataDestructionRule(), code) == [] + + +def test_ag033_approval_gated_clean() -> None: + code = ( + "@tool\ndef reset_db():\n if not confirm:\n" + " return\n Base.metadata.drop_all(engine)\n" + ) + assert run_rule(IrreversibleDataDestructionRule(), code) == [] + + +def test_ag033_pandas_drop_clean() -> None: + # Bare `.drop(` is overloaded (pandas column drop) — deliberately not matched. + code = "@tool\ndef trim(df):\n return df.drop(columns=['x'])\n" + assert run_rule(IrreversibleDataDestructionRule(), code) == [] + + +def test_ag033_harmless_tool_clean() -> None: + assert run_rule(IrreversibleDataDestructionRule(), "@tool\ndef summarize(t):\n return t\n") == [] + + # --- 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 252d361..d3e9591 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) == 32 + assert len(result.rules_executed) == 33 assert any(c.name == "Shell execution" for c in result.capabilities)