Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
```
Expand Down
5 changes: 3 additions & 2 deletions benchmark/CORPUS_RESULTS.md
Original file line number Diff line number Diff line change
@@ -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 |
|---|--:|--:|--:|--:|--:|--:|--:|--:|
Expand Down Expand Up @@ -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`.

9 changes: 9 additions & 0 deletions benchmark/corpus.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/autonomyproof/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@

__all__ = ["__version__"]

__version__ = "0.14.0"
__version__ = "0.15.0"
87 changes: 87 additions & 0 deletions src/autonomyproof/rules/agent_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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."""

Expand Down
2 changes: 2 additions & 0 deletions src/autonomyproof/rules/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
DangerousOperationRule,
ExcessiveLimitRule,
GuardrailSelfModificationRule,
IrreversibleDataDestructionRule,
McpArgumentValidationRule,
SubAgentCreationRule,
)
Expand Down Expand Up @@ -82,6 +83,7 @@
PublicShareRule, # AG030
CorsWildcardCredentialsRule, # AG031
DisabledSafetyFilterRule, # AG032
IrreversibleDataDestructionRule, # AG033
]


Expand Down
2 changes: 1 addition & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions tests/test_rules_agent_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
DangerousOperationRule,
ExcessiveLimitRule,
GuardrailSelfModificationRule,
IrreversibleDataDestructionRule,
McpArgumentValidationRule,
SubAgentCreationRule,
)
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
Loading