From 83144f512b4e9ec5f8645f364bb7921931f6572b Mon Sep 17 00:00:00 2001 From: AutonomyProof Date: Wed, 12 Aug 2026 07:59:02 +0530 Subject: [PATCH 1/2] Add AG036 (persistence-file write) + AG037 (runtime package install) (0.17.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more on-thesis checks for a tricked agent turning a one-shot injection into durable access or code execution. Both use a new _iter_marker_action_tools spine: fire only when a sensitive string marker AND a real action co-occur inside an unguarded (approval-free) tool. AG036 — Persistence-sensitive file write exposed to the agent: writes to SSH authorized_keys, crontab, /etc/sudoers, shell rc files, systemd units, etc. (open('w'/'a'), Path.write_text/bytes, os.symlink/link/shutil.copy/move, or a shell exec). MITRE T1098 + T1547. AG037 — Runtime package installation exposed to the agent: a shell executor running pip/npm/uv/poetry install — arbitrary package == code execution. MITRE T1059 + T1195. Refactored the tool-scope/approval logic into _iter_unguarded_tools (AG033/034/035 reuse it, behavior unchanged). Verification: - Ground-truth corpus: 143 cases, precision 1.000 / recall 1.000 (adds 7 AG036/AG037 cases, incl. read-only, ordinary-write, help-text, and non-tool negatives). - Real-repo benchmark: 0 findings for each across 39 repos — zero false positives. - 572 tests, 100% branch coverage, ruff + ruff format + mypy all clean. Bumps 0.16.0 -> 0.17.0; updates CHANGELOG + README catalogue. Signed-off-by: AutonomyProof --- CHANGELOG.md | 16 ++ README.md | 7 +- benchmark/CORPUS_RESULTS.md | 6 +- benchmark/corpus.yaml | 11 ++ benchmark/results.json | 104 +++++------- pyproject.toml | 2 +- src/autonomyproof/__init__.py | 2 +- src/autonomyproof/rules/agent_controls.py | 192 ++++++++++++++++++++-- src/autonomyproof/rules/registry.py | 4 + tests/test_cli.py | 2 +- tests/test_rules_agent_controls.py | 82 +++++++++ tests/test_scanner.py | 2 +- 12 files changed, 351 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4afb6c0..45f6b1d 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.17.0] + +### Added +- **AG036 — Persistence-sensitive file write exposed to the agent.** Flags an agent tool that + writes to a file granting persistence or backdoor access (SSH `authorized_keys`, `crontab`, + `/etc/sudoers`, shell rc files, systemd units) with no approval — turning a one-shot prompt + injection into durable, privileged access. MITRE ATT&CK T1098 + T1547. +- **AG037 — Runtime package installation exposed to the agent.** Flags an agent tool that runs + `pip`/`npm`/`uv`/`poetry` install via a shell executor — installing an arbitrary package + executes arbitrary code (RCE / supply-chain). MITRE ATT&CK T1059 + T1195. + +Both reuse the tool-scoped, approval-suppressed model (new `_iter_marker_action_tools` spine): +they fire only when a sensitive marker AND a real action co-occur inside an unguarded tool, so +reads, ordinary file writes, non-tool functions, and help text stay silent. Verified: corpus +precision/recall 1.000, 0 findings across the 41-repo real benchmark. + ## [0.16.0] ### Added diff --git a/README.md b/README.md index b00777f..ae9b413 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,8 @@ framework flags (`allow_dangerous_*`, `trust_remote_code`), code/shell interpret irreversible datastore/filesystem wipes exposed to an agent tool with no approval (`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`), +money movement without approval (`Refund`/`Payout`/`Transfer.create`), persistence/backdoor +writes (SSH `authorized_keys`, `crontab`, `sudoers`), runtime package installs (`pip install`), 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 @@ -140,7 +141,7 @@ re-run `autonomyproof baseline .` and commit the updated file in the same PR. Use the action directly: ```yaml -- uses: autonomyproof/autonomyproof-cli@v0.16.0 +- uses: autonomyproof/autonomyproof-cli@v0.17.0 with: target: . fail-on: high @@ -167,7 +168,7 @@ Gate locally before a commit ever leaves your machine: # .pre-commit-config.yaml repos: - repo: https://github.com/autonomyproof/autonomyproof-cli - rev: v0.16.0 + rev: v0.17.0 hooks: - id: autonomyproof ``` diff --git a/benchmark/CORPUS_RESULTS.md b/benchmark/CORPUS_RESULTS.md index d8f77bb..20be977 100644 --- a/benchmark/CORPUS_RESULTS.md +++ b/benchmark/CORPUS_RESULTS.md @@ -1,6 +1,6 @@ # Labeled-corpus results (ground-truth precision & recall) -**Cases:** 136 · **Rules covered:** 29 · **Overall precision:** 1.000 · **Overall recall:** 1.000 +**Cases:** 143 · **Rules covered:** 31 · **Overall precision:** 1.000 · **Overall recall:** 1.000 | Rule | pos | neg | TP | FP | FN | Precision | Recall | F1 | |---|--:|--:|--:|--:|--:|--:|--:|--:| @@ -33,8 +33,10 @@ | 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 | +| AG036 | 2 | 2 | 2 | 0 | 0 | 1.00 | 1.00 | 1.00 | +| AG037 | 1 | 2 | 1 | 0 | 0 | 1.00 | 1.00 | 1.00 | -**Totals:** TP 72 · FP 0 · FN 0 · TN 64 +**Totals:** TP 75 · FP 0 · FN 0 · TN 68 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 ad7a8c5..c4cdd63 100644 --- a/benchmark/corpus.yaml +++ b/benchmark/corpus.yaml @@ -200,6 +200,17 @@ cases: - {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"} + # --- AG036 persistence via sensitive-file write --- + - {id: ag036-pos-authkeys, rule: AG036, label: positive, code: "@tool\ndef add_key(k):\n open('/root/.ssh/authorized_keys', 'a').write(k)\n"} + - {id: ag036-pos-cron, rule: AG036, label: positive, code: "import os\n@tool\ndef sched():\n os.system('echo job >> /etc/crontab')\n"} + - {id: ag036-neg-read, rule: AG036, label: negative, code: "@tool\ndef check():\n return open('/root/.ssh/authorized_keys').read()\n"} + - {id: ag036-neg-ordinary, rule: AG036, label: negative, code: "@tool\ndef save(x):\n open('output.txt', 'w').write(x)\n"} + + # --- AG037 runtime package install --- + - {id: ag037-pos-pip, rule: AG037, label: positive, code: "import os\n@tool\ndef setup(pkg):\n os.system('pip install ' + pkg)\n"} + - {id: ag037-neg-help, rule: AG037, label: negative, code: "@tool\ndef helptext():\n return 'run pip install autonomyproof to begin'\n"} + - {id: ag037-neg-nontool, rule: AG037, label: negative, code: "import os\ndef setup(pkg):\n os.system('pip install ' + pkg)\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 d34f696..058d436 100644 --- a/benchmark/results.json +++ b/benchmark/results.json @@ -1,32 +1,32 @@ { - "repo_count": 40, - "total_files": 34831, - "total_findings": 9217, + "repo_count": 39, + "total_files": 34198, + "total_findings": 8962, "by_rule": { - "AG001": 39, - "AG002": 159, - "AG003": 3354, - "AG004": 58, - "AG005": 994, - "AG006": 76, + "AG001": 38, + "AG002": 169, + "AG003": 3164, + "AG004": 56, + "AG005": 976, + "AG006": 74, "AG007": 55, - "AG009": 564, + "AG009": 573, "AG011": 73, - "AG012": 809, - "AG013": 175, - "AG014": 11, - "AG015": 276, - "AG016": 42, - "AG017": 219, - "AG018": 1511, - "AG019": 394, - "AG020": 40, - "AG021": 60, + "AG012": 816, + "AG013": 167, + "AG014": 10, + "AG015": 230, + "AG016": 39, + "AG017": 216, + "AG018": 1497, + "AG019": 403, + "AG020": 39, + "AG021": 61, "AG022": 30, "AG023": 112, "AG024": 12, "AG025": 135, - "AG026": 7, + "AG026": 5, "AG028": 4, "AG029": 1, "AG031": 5, @@ -235,7 +235,7 @@ { "repo": "haystack", "status": "ok", - "files_scanned": 554, + "files_scanned": 556, "findings": 120, "by_rule": { "AG002": 1, @@ -253,7 +253,7 @@ { "repo": "litellm", "status": "ok", - "files_scanned": 5192, + "files_scanned": 5193, "findings": 939, "by_rule": { "AG001": 2, @@ -451,7 +451,7 @@ "repo": "dspy", "status": "ok", "files_scanned": 279, - "findings": 114, + "findings": 112, "by_rule": { "AG002": 1, "AG003": 67, @@ -462,8 +462,7 @@ "AG018": 19, "AG020": 1, "AG021": 2, - "AG022": 2, - "AG026": 2 + "AG022": 2 } }, { @@ -490,7 +489,7 @@ { "repo": "mem0", "status": "ok", - "files_scanned": 366, + "files_scanned": 367, "findings": 187, "by_rule": { "AG003": 84, @@ -587,45 +586,30 @@ }, { "repo": "swarms", - "status": "ok", - "files_scanned": 965, - "findings": 300, - "by_rule": { - "AG001": 1, - "AG002": 2, - "AG003": 153, - "AG004": 2, - "AG005": 28, - "AG006": 2, - "AG009": 2, - "AG012": 5, - "AG013": 12, - "AG014": 1, - "AG015": 45, - "AG016": 3, - "AG017": 9, - "AG018": 33, - "AG019": 1, - "AG020": 1 - } + "status": "clone_failed" }, { "repo": "tinytroupe", - "status": "ok", - "files_scanned": 110, - "findings": 103, - "by_rule": { - "AG003": 94, - "AG005": 2, - "AG015": 1, - "AG017": 1, - "AG018": 4, - "AG020": 1 - } + "status": "clone_failed" }, { "repo": "langroid", - "status": "clone_failed" + "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 + } }, { "repo": "multi-agent-orchestrator", diff --git a/pyproject.toml b/pyproject.toml index a71e373..d0f6e8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "autonomyproof" -version = "0.16.0" +version = "0.17.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 ecaadcd..8772faa 100644 --- a/src/autonomyproof/__init__.py +++ b/src/autonomyproof/__init__.py @@ -4,4 +4,4 @@ __all__ = ["__version__"] -__version__ = "0.16.0" +__version__ = "0.17.0" diff --git a/src/autonomyproof/rules/agent_controls.py b/src/autonomyproof/rules/agent_controls.py index 9994328..46135f5 100644 --- a/src/autonomyproof/rules/agent_controls.py +++ b/src/autonomyproof/rules/agent_controls.py @@ -5,7 +5,7 @@ import ast from collections.abc import Callable, Iterable -from autonomyproof.astutils import string_literals +from autonomyproof.astutils import keyword, string_literals from autonomyproof.models import Finding, Mappings, Severity from autonomyproof.rules.base import Rule, RuleContext @@ -120,6 +120,52 @@ _MONEY_RESOURCES = {"Refund", "Payout", "Transfer"} _MONEY_VERBS = {"create", "create_async"} +# Shell/process executors, shared by AG036/AG037 to detect "the tool runs a command". +_SHELL_EXECUTORS = { + "os.system", + "os.popen", + "subprocess.run", + "subprocess.call", + "subprocess.Popen", + "subprocess.check_call", + "subprocess.check_output", +} + +# AG036 — files whose modification plants persistence or a backdoor. Each path is specific +# enough that touching it from an agent tool is a genuine red flag, not routine I/O. +_PERSISTENCE_MARKERS = ( + "authorized_keys", + "id_rsa", + "id_ed25519", + ".ssh/config", + "crontab", + "/etc/cron", + "/etc/sudoers", + "sudoers.d", + ".bashrc", + ".bash_profile", + ".zshrc", + "/etc/profile", + "/etc/systemd", + "/etc/passwd", + "/etc/shadow", +) +_FILE_WRITE_ATTRS = {"write_text", "write_bytes", "writelines"} +_FILE_WRITE_FUNCS = {"os.symlink", "os.link", "shutil.copy", "shutil.copyfile", "shutil.move"} + +# AG037 — package-install commands. An agent that installs an arbitrary package is one that +# runs arbitrary code (install hooks / imported code), i.e. remote code execution. +_INSTALL_MARKERS = ( + "pip install", + "pip3 install", + "uv pip install", + "uv add", + "npm install", + "yarn add", + "poetry add", + "pipx install", +) + _CTRL_MAPPINGS = Mappings( owaspAgentic=["Excessive agency", "Insufficient oversight"], nistAiRmf=["Govern", "Manage"], @@ -139,26 +185,34 @@ def _identifiers_in(node: ast.AST) -> set[str]: return tokens -def _iter_tool_sinks( +def _iter_unguarded_tools( 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``. +) -> Iterable[ast.FunctionDef | ast.AsyncFunctionDef]: + """Yield each registered agent tool function that has no detectable approval gate. - 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. + 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. Approval detection is a + substring match (like AG007) so approved/is_approved/needs_approval all suppress. """ 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 + yield node + + +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 unguarded agent tool whose body + contains a single node accepted by ``match`` (AG033/AG034/AG035). One finding per tool. + """ + for node in _iter_unguarded_tools(ctx): for child in ast.walk(node): evidence = match(ctx, child, node) if evidence is not None: @@ -166,6 +220,31 @@ def _iter_tool_sinks( break +def _iter_marker_action_tools( + ctx: RuleContext, + markers: tuple[str, ...], + is_action: Callable[[RuleContext, ast.AST], bool], +) -> Iterable[tuple[ast.FunctionDef | ast.AsyncFunctionDef, ast.AST, str]]: + """Yield ``(tool_func, marker_node, marker)`` for each unguarded tool whose body contains + BOTH a sensitive string literal (matching ``markers``) and an action node accepted by + ``is_action``. Used by AG036/AG037, where the risk is a marker + an operation on it. + """ + for node in _iter_unguarded_tools(ctx): + marker: str | None = None + marker_node: ast.AST | None = None + action = False + for child in ast.walk(node): + if marker is None and isinstance(child, ast.Constant) and isinstance(child.value, str): + lowered = child.value.lower() + hit = next((m for m in markers if m in lowered), None) + if hit is not None: + marker, marker_node = hit, child + if not action and is_action(ctx, child): + action = True + if marker is not None and marker_node is not None and action: + yield node, marker_node, marker + + class DangerousOperationRule(Rule): """AG007 — Dangerous operation without approval.""" @@ -372,6 +451,99 @@ def _match( return None +class PersistenceWriteRule(Rule): + """AG036 — Persistence/backdoor via sensitive-file write exposed to the agent.""" + + id = "AG036" + name = "Persistence-sensitive file write exposed to the agent" + default_severity = Severity.CRITICAL + description = ( + "An agent tool can write to a file that grants persistence or backdoor access " + "(SSH authorized_keys, crontab, sudoers, shell rc, systemd unit) with no approval." + ) + risk = ( + "A manipulated agent could plant an SSH key, cron job, or sudoers entry — turning a " + "one-shot prompt injection into durable, privileged access." + ) + remediation = [ + "Never let an agent tool write to auth, cron, sudoers, or shell-init files", + "Constrain tool file writes to a dedicated, non-sensitive directory", + "Require human approval for any write outside the workspace", + "Run the agent as an unprivileged user without access to these paths", + ] + mappings = Mappings( + owaspAgentic=["Excessive agency", "Tool misuse"], + nistAiRmf=["Govern", "Manage"], + iso42001Alignment=["Operational control", "Accountability"], + mitre=["T1098", "T1547"], # Account Manipulation, Boot/Logon Autostart + ) + + def check(self, ctx: RuleContext) -> Iterable[Finding]: + for func, node, marker in _iter_marker_action_tools( + ctx, _PERSISTENCE_MARKERS, self._is_write + ): + yield self.make_finding( + ctx, + node, + evidence=f"Tool '{func.name}' modifies a persistence-sensitive path ({marker})", + tool_name=ctx.tool_functions.get(func.name, func.name), + pattern=f"{self.id}:{func.name}", + ) + + @staticmethod + def _is_write(ctx: RuleContext, child: ast.AST) -> bool: + if not isinstance(child, ast.Call): + return False + resolved = ctx.analysis.resolve_call(child) + if resolved in _SHELL_EXECUTORS or resolved in _FILE_WRITE_FUNCS: + return True + if resolved == "open": + mode = child.args[1] if len(child.args) > 1 else keyword(child, "mode") + if isinstance(mode, ast.Constant) and isinstance(mode.value, str): + return any(c in mode.value for c in ("w", "a", "x", "+")) + return isinstance(child.func, ast.Attribute) and child.func.attr in _FILE_WRITE_ATTRS + + +class RuntimePackageInstallRule(Rule): + """AG037 — Runtime package installation exposed to the agent.""" + + id = "AG037" + name = "Runtime package installation exposed to the agent" + default_severity = Severity.HIGH + description = ( + "An agent tool can install a package at runtime (pip/npm/uv/poetry) with no approval." + ) + risk = ( + "Installing an arbitrary package executes arbitrary code (install hooks and imported " + "modules run), so a manipulated agent gains remote code execution." + ) + remediation = [ + "Do not let agents install packages at runtime", + "Pin and vendor dependencies ahead of time", + "If dynamic install is unavoidable, allowlist packages and require approval", + ] + mappings = Mappings( + owaspAgentic=["Excessive agency", "Tool misuse"], + nistAiRmf=["Govern", "Manage"], + iso42001Alignment=["Operational control", "Accountability"], + mitre=["T1059", "T1195"], # Command/Scripting, Supply Chain Compromise + ) + + def check(self, ctx: RuleContext) -> Iterable[Finding]: + for func, node, marker in _iter_marker_action_tools(ctx, _INSTALL_MARKERS, self._is_shell): + yield self.make_finding( + ctx, + node, + evidence=f"Tool '{func.name}' runs a package install ({marker.strip()})", + tool_name=ctx.tool_functions.get(func.name, func.name), + pattern=f"{self.id}:{func.name}", + ) + + @staticmethod + def _is_shell(ctx: RuleContext, child: ast.AST) -> bool: + return isinstance(child, ast.Call) and ctx.analysis.resolve_call(child) in _SHELL_EXECUTORS + + class ExcessiveLimitRule(Rule): """AG009 — Excessive execution limit.""" diff --git a/src/autonomyproof/rules/registry.py b/src/autonomyproof/rules/registry.py index ffaaa2c..decd2a4 100644 --- a/src/autonomyproof/rules/registry.py +++ b/src/autonomyproof/rules/registry.py @@ -10,6 +10,8 @@ GuardrailSelfModificationRule, IrreversibleDataDestructionRule, McpArgumentValidationRule, + PersistenceWriteRule, + RuntimePackageInstallRule, SubAgentCreationRule, ) from autonomyproof.rules.base import Rule @@ -88,6 +90,8 @@ IrreversibleDataDestructionRule, # AG033 CloudResourceDestructionRule, # AG034 FinancialTransactionRule, # AG035 + PersistenceWriteRule, # AG036 + RuntimePackageInstallRule, # AG037 ] diff --git a/tests/test_cli.py b/tests/test_cli.py index 6d6f505..a246479 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.16.0" in result.output + assert "0.17.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 9f5a959..fb6a529 100644 --- a/tests/test_rules_agent_controls.py +++ b/tests/test_rules_agent_controls.py @@ -10,6 +10,8 @@ GuardrailSelfModificationRule, IrreversibleDataDestructionRule, McpArgumentValidationRule, + PersistenceWriteRule, + RuntimePackageInstallRule, SubAgentCreationRule, ) from helpers import run_rule @@ -184,6 +186,86 @@ def test_ag035_factory_create_clean() -> None: assert run_rule(FinancialTransactionRule(), code) == [] +# --- AG036 -------------------------------------------------------------------- +def test_ag036_authorized_keys_open_write() -> None: + code = "@tool\ndef add_key(k):\n open('/root/.ssh/authorized_keys', 'a').write(k)\n" + findings = run_rule(PersistenceWriteRule(), code) + assert findings and findings[0].ruleId == "AG036" + assert findings[0].toolName == "add_key" + + +def test_ag036_crontab_via_shell() -> None: + code = "import os\n@tool\ndef sched():\n os.system('echo job >> /etc/crontab')\n" + assert run_rule(PersistenceWriteRule(), code) + + +def test_ag036_write_text_bashrc() -> None: + code = "from pathlib import Path\n@tool\ndef persist(x):\n Path('~/.bashrc').write_text(x)\n" + assert run_rule(PersistenceWriteRule(), code) + + +def test_ag036_read_only_clean() -> None: + # Reading authorized_keys (default mode) with no write/shell action must not fire. + code = "@tool\ndef check():\n return open('/root/.ssh/authorized_keys').read()\n" + assert run_rule(PersistenceWriteRule(), code) == [] + + +def test_ag036_non_tool_clean() -> None: + code = "def add_key(k):\n open('/root/.ssh/authorized_keys', 'a').write(k)\n" + assert run_rule(PersistenceWriteRule(), code) == [] + + +def test_ag036_approval_gated_clean() -> None: + code = ( + "@tool\ndef add_key(k):\n if not approved:\n" + " return\n open('/root/.ssh/authorized_keys', 'a').write(k)\n" + ) + assert run_rule(PersistenceWriteRule(), code) == [] + + +def test_ag036_ordinary_file_write_clean() -> None: + # Writing a normal file is not persistence — no sensitive marker. + code = "@tool\ndef save(x):\n open('output.txt', 'w').write(x)\n" + assert run_rule(PersistenceWriteRule(), code) == [] + + +def test_ag036_symlink_action() -> None: + code = "import os\n@tool\ndef link():\n os.symlink(src, '/etc/systemd/system/x.service')\n" + assert run_rule(PersistenceWriteRule(), code) + + +# --- AG037 -------------------------------------------------------------------- +def test_ag037_pip_install_shell() -> None: + code = "import os\n@tool\ndef setup(pkg):\n os.system('pip install ' + pkg)\n" + findings = run_rule(RuntimePackageInstallRule(), code) + assert findings and findings[0].ruleId == "AG037" + assert findings[0].toolName == "setup" + + +def test_ag037_npm_subprocess() -> None: + code = "import subprocess\n@tool\ndef add(p):\n subprocess.run('npm install ' + p, shell=True)\n" + assert run_rule(RuntimePackageInstallRule(), code) + + +def test_ag037_non_tool_clean() -> None: + code = "import os\ndef setup(pkg):\n os.system('pip install ' + pkg)\n" + assert run_rule(RuntimePackageInstallRule(), code) == [] + + +def test_ag037_install_string_no_shell_clean() -> None: + # Mentioning 'pip install' in help text with no shell executor must not fire. + code = "@tool\ndef helptext():\n return 'run pip install autonomyproof to begin'\n" + assert run_rule(RuntimePackageInstallRule(), code) == [] + + +def test_ag037_approval_gated_clean() -> None: + code = ( + "import os\n@tool\ndef setup(pkg):\n if not confirm:\n" + " return\n os.system('pip install ' + pkg)\n" + ) + assert run_rule(RuntimePackageInstallRule(), 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 73d28c2..6c32160 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) == 35 + assert len(result.rules_executed) == 37 assert any(c.name == "Shell execution" for c in result.capabilities) From 62ea317c07d48177b0fa8b17b4e3d3d1d07cfbce Mon Sep 17 00:00:00 2001 From: AutonomyProof Date: Tue, 18 Aug 2026 13:52:18 +0530 Subject: [PATCH 2/2] Add AG038 (IAM privilege escalation) + AG039 (world-writable chmod) (0.18.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more tool-scoped, approval-suppressed checks on the shared spine. AG038 — IAM/privilege escalation exposed to the agent: create_access_key, create_login_profile, put_user_policy, attach_role_policy, add_user_to_group, update_assume_role_policy, put_bucket_policy, set_iam_policy, etc. MITRE T1098 + T1078. AG039 — World-writable permission grant exposed to the agent: chmod that sets the other-write bit (0o777/0o666), via os.chmod or Path.chmod, with a constant mode. Dynamic modes and safe modes (0o644) do not fire. MITRE T1222. Verification: - Ground-truth corpus: 150 cases, precision 1.000 / recall 1.000. - Real-repo benchmark: 0 findings for each across the repo set — zero false positives. - 592 tests, 100% branch coverage, ruff + ruff format + mypy all clean. Stacked on the AG036/AG037 branch; bumps to 0.18.0. This branch carries AG036-AG039. Signed-off-by: AutonomyProof --- CHANGELOG.md | 13 +++ README.md | 5 +- benchmark/CORPUS_RESULTS.md | 6 +- benchmark/corpus.yaml | 11 ++ benchmark/results.json | 124 ++++++++++++-------- pyproject.toml | 2 +- src/autonomyproof/__init__.py | 2 +- src/autonomyproof/rules/agent_controls.py | 131 ++++++++++++++++++++++ src/autonomyproof/rules/registry.py | 4 + tests/test_cli.py | 2 +- tests/test_rules_agent_controls.py | 80 +++++++++++++ tests/test_scanner.py | 2 +- 12 files changed, 327 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f6b1d..0c99709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 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.18.0] + +### Added +- **AG038 — IAM/privilege escalation exposed to the agent.** Flags an agent tool that can grant + or widen access — `create_access_key`, `put_user_policy`, `attach_role_policy`, + `add_user_to_group`, `put_bucket_policy`, `set_iam_policy`, … — with no approval. MITRE + ATT&CK T1098 + T1078. +- **AG039 — World-writable permission grant exposed to the agent.** Flags a `chmod` that sets + the other-write bit (e.g. `0o777`, `0o666`) inside an agent tool. MITRE ATT&CK T1222. + +Both reuse the tool-scoped, approval-suppressed spine. Verified: corpus precision/recall +1.000; 0 findings across the 41-repo real benchmark. + ## [0.17.0] ### Added diff --git a/README.md b/README.md index ae9b413..5f6217c 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ irreversible datastore/filesystem wipes exposed to an agent tool with no approva destruction (`terminate_instances`, `delete_bucket`, `delete_cluster`, k8s teardown), money movement without approval (`Refund`/`Payout`/`Transfer.create`), persistence/backdoor writes (SSH `authorized_keys`, `crontab`, `sudoers`), runtime package installs (`pip install`), +IAM/privilege escalation (`create_access_key`, `attach_role_policy`), world-writable `chmod`, 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 @@ -141,7 +142,7 @@ re-run `autonomyproof baseline .` and commit the updated file in the same PR. Use the action directly: ```yaml -- uses: autonomyproof/autonomyproof-cli@v0.17.0 +- uses: autonomyproof/autonomyproof-cli@v0.18.0 with: target: . fail-on: high @@ -168,7 +169,7 @@ Gate locally before a commit ever leaves your machine: # .pre-commit-config.yaml repos: - repo: https://github.com/autonomyproof/autonomyproof-cli - rev: v0.17.0 + rev: v0.18.0 hooks: - id: autonomyproof ``` diff --git a/benchmark/CORPUS_RESULTS.md b/benchmark/CORPUS_RESULTS.md index 20be977..620d7fb 100644 --- a/benchmark/CORPUS_RESULTS.md +++ b/benchmark/CORPUS_RESULTS.md @@ -1,6 +1,6 @@ # Labeled-corpus results (ground-truth precision & recall) -**Cases:** 143 · **Rules covered:** 31 · **Overall precision:** 1.000 · **Overall recall:** 1.000 +**Cases:** 150 · **Rules covered:** 33 · **Overall precision:** 1.000 · **Overall recall:** 1.000 | Rule | pos | neg | TP | FP | FN | Precision | Recall | F1 | |---|--:|--:|--:|--:|--:|--:|--:|--:| @@ -35,8 +35,10 @@ | AG035 | 2 | 2 | 2 | 0 | 0 | 1.00 | 1.00 | 1.00 | | AG036 | 2 | 2 | 2 | 0 | 0 | 1.00 | 1.00 | 1.00 | | AG037 | 1 | 2 | 1 | 0 | 0 | 1.00 | 1.00 | 1.00 | +| AG038 | 2 | 2 | 2 | 0 | 0 | 1.00 | 1.00 | 1.00 | +| AG039 | 1 | 2 | 1 | 0 | 0 | 1.00 | 1.00 | 1.00 | -**Totals:** TP 75 · FP 0 · FN 0 · TN 68 +**Totals:** TP 78 · FP 0 · FN 0 · TN 72 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 c4cdd63..2a2536e 100644 --- a/benchmark/corpus.yaml +++ b/benchmark/corpus.yaml @@ -211,6 +211,17 @@ cases: - {id: ag037-neg-help, rule: AG037, label: negative, code: "@tool\ndef helptext():\n return 'run pip install autonomyproof to begin'\n"} - {id: ag037-neg-nontool, rule: AG037, label: negative, code: "import os\ndef setup(pkg):\n os.system('pip install ' + pkg)\n"} + # --- AG038 IAM / privilege escalation via agent tool --- + - {id: ag038-pos-putpolicy, rule: AG038, label: positive, code: "@tool\ndef grant(u):\n iam.put_user_policy(UserName=u, PolicyDocument=doc)\n"} + - {id: ag038-pos-accesskey, rule: AG038, label: positive, code: "@tool\ndef mint(u):\n iam.create_access_key(UserName=u)\n"} + - {id: ag038-neg-read, rule: AG038, label: negative, code: "@tool\ndef who(u):\n return iam.get_user(UserName=u)\n"} + - {id: ag038-neg-nontool, rule: AG038, label: negative, code: "def grant(u):\n iam.put_user_policy(UserName=u, PolicyDocument=doc)\n"} + + # --- AG039 world-writable permission via agent tool --- + - {id: ag039-pos-chmod777, rule: AG039, label: positive, code: "import os\n@tool\ndef loosen(p):\n os.chmod(p, 0o777)\n"} + - {id: ag039-neg-safe, rule: AG039, label: negative, code: "import os\n@tool\ndef fix(p):\n os.chmod(p, 0o644)\n"} + - {id: ag039-neg-dynamic, rule: AG039, label: negative, code: "import os\n@tool\ndef fix(p, mode):\n os.chmod(p, mode)\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 058d436..cf8411a 100644 --- a/benchmark/results.json +++ b/benchmark/results.json @@ -1,26 +1,26 @@ { - "repo_count": 39, - "total_files": 34198, - "total_findings": 8962, + "repo_count": 41, + "total_files": 35318, + "total_findings": 9367, "by_rule": { "AG001": 38, - "AG002": 169, - "AG003": 3164, - "AG004": 56, - "AG005": 976, - "AG006": 74, + "AG002": 174, + "AG003": 3412, + "AG004": 58, + "AG005": 1005, + "AG006": 76, "AG007": 55, - "AG009": 573, + "AG009": 577, "AG011": 73, - "AG012": 816, - "AG013": 167, - "AG014": 10, - "AG015": 230, - "AG016": 39, - "AG017": 216, - "AG018": 1497, - "AG019": 403, - "AG020": 39, + "AG012": 821, + "AG013": 179, + "AG014": 11, + "AG015": 276, + "AG016": 42, + "AG017": 226, + "AG018": 1532, + "AG019": 404, + "AG020": 41, "AG021": 61, "AG022": 30, "AG023": 112, @@ -53,7 +53,7 @@ { "repo": "crewai", "status": "ok", - "files_scanned": 1297, + "files_scanned": 1300, "findings": 360, "by_rule": { "AG002": 5, @@ -97,15 +97,15 @@ { "repo": "openai-agents", "status": "ok", - "files_scanned": 887, - "findings": 345, + "files_scanned": 896, + "findings": 346, "by_rule": { "AG002": 11, "AG003": 31, "AG004": 4, "AG005": 9, "AG007": 2, - "AG009": 44, + "AG009": 45, "AG012": 9, "AG013": 9, "AG015": 6, @@ -121,15 +121,15 @@ { "repo": "pydantic-ai", "status": "ok", - "files_scanned": 671, - "findings": 109, + "files_scanned": 678, + "findings": 111, "by_rule": { - "AG002": 6, - "AG003": 19, + "AG002": 8, + "AG003": 18, "AG005": 7, "AG006": 1, "AG007": 2, - "AG009": 36, + "AG009": 37, "AG012": 4, "AG013": 5, "AG015": 5, @@ -208,12 +208,12 @@ { "repo": "agno", "status": "ok", - "files_scanned": 4320, - "findings": 855, + "files_scanned": 4321, + "findings": 862, "by_rule": { "AG001": 2, - "AG002": 17, - "AG003": 177, + "AG002": 18, + "AG003": 183, "AG004": 6, "AG005": 75, "AG006": 1, @@ -254,11 +254,11 @@ "repo": "litellm", "status": "ok", "files_scanned": 5193, - "findings": 939, + "findings": 934, "by_rule": { - "AG001": 2, + "AG001": 1, "AG002": 3, - "AG003": 407, + "AG003": 404, "AG004": 5, "AG005": 84, "AG006": 26, @@ -267,7 +267,7 @@ "AG014": 1, "AG015": 13, "AG017": 83, - "AG018": 185, + "AG018": 184, "AG019": 18, "AG020": 1, "AG022": 2, @@ -413,13 +413,13 @@ { "repo": "langflow", "status": "ok", - "files_scanned": 3582, - "findings": 827, + "files_scanned": 3583, + "findings": 826, "by_rule": { "AG002": 24, "AG003": 92, "AG004": 10, - "AG005": 258, + "AG005": 257, "AG006": 8, "AG007": 2, "AG009": 36, @@ -451,7 +451,7 @@ "repo": "dspy", "status": "ok", "files_scanned": 279, - "findings": 112, + "findings": 111, "by_rule": { "AG002": 1, "AG003": 67, @@ -459,7 +459,7 @@ "AG009": 1, "AG013": 5, "AG015": 4, - "AG018": 19, + "AG018": 18, "AG020": 1, "AG021": 2, "AG022": 2 @@ -539,7 +539,7 @@ { "repo": "adk-python", "status": "ok", - "files_scanned": 1756, + "files_scanned": 1772, "findings": 351, "by_rule": { "AG002": 4, @@ -586,11 +586,41 @@ }, { "repo": "swarms", - "status": "clone_failed" + "status": "ok", + "files_scanned": 965, + "findings": 300, + "by_rule": { + "AG001": 1, + "AG002": 2, + "AG003": 153, + "AG004": 2, + "AG005": 28, + "AG006": 2, + "AG009": 2, + "AG012": 5, + "AG013": 12, + "AG014": 1, + "AG015": 45, + "AG016": 3, + "AG017": 9, + "AG018": 33, + "AG019": 1, + "AG020": 1 + } }, { "repo": "tinytroupe", - "status": "clone_failed" + "status": "ok", + "files_scanned": 110, + "findings": 103, + "by_rule": { + "AG003": 94, + "AG005": 2, + "AG015": 1, + "AG017": 1, + "AG018": 4, + "AG020": 1 + } }, { "repo": "langroid", @@ -648,10 +678,10 @@ { "repo": "pipecat", "status": "ok", - "files_scanned": 1193, - "findings": 147, + "files_scanned": 1195, + "findings": 146, "by_rule": { - "AG003": 46, + "AG003": 45, "AG004": 3, "AG005": 2, "AG009": 56, @@ -739,7 +769,7 @@ { "repo": "agentscope", "status": "ok", - "files_scanned": 576, + "files_scanned": 582, "findings": 250, "by_rule": { "AG003": 143, diff --git a/pyproject.toml b/pyproject.toml index d0f6e8e..4080844 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "autonomyproof" -version = "0.17.0" +version = "0.18.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 8772faa..1710560 100644 --- a/src/autonomyproof/__init__.py +++ b/src/autonomyproof/__init__.py @@ -4,4 +4,4 @@ __all__ = ["__version__"] -__version__ = "0.17.0" +__version__ = "0.18.0" diff --git a/src/autonomyproof/rules/agent_controls.py b/src/autonomyproof/rules/agent_controls.py index 46135f5..26fc464 100644 --- a/src/autonomyproof/rules/agent_controls.py +++ b/src/autonomyproof/rules/agent_controls.py @@ -166,6 +166,28 @@ "pipx install", ) +# AG038 — IAM / privilege-escalation operations. Each grants or widens access; an agent that +# can call these could hand itself (or an attacker) durable, elevated permissions. +_IAM_ESCALATION_METHODS = { + "create_access_key", # new long-lived credentials + "create_login_profile", # console password for a user + "put_user_policy", # inline IAM policy + "put_role_policy", + "put_group_policy", + "attach_user_policy", # attach managed policy (e.g. AdministratorAccess) + "attach_role_policy", + "attach_group_policy", + "add_user_to_group", # add to a privileged group + "update_assume_role_policy", # widen who can assume a role + "put_bucket_policy", # open up an S3 bucket + "set_iam_policy", # GCP setIamPolicy + "create_policy_version", # swap in a new default policy version +} + +# AG039 — the world-writable permission bit (others may write). chmod with this bit set from +# an agent tool weakens a file's protection. +_WORLD_WRITABLE_BIT = 0o002 + _CTRL_MAPPINGS = Mappings( owaspAgentic=["Excessive agency", "Insufficient oversight"], nistAiRmf=["Govern", "Manage"], @@ -544,6 +566,115 @@ def _is_shell(ctx: RuleContext, child: ast.AST) -> bool: return isinstance(child, ast.Call) and ctx.analysis.resolve_call(child) in _SHELL_EXECUTORS +class IamPrivilegeEscalationRule(Rule): + """AG038 — IAM/privilege escalation exposed to the agent.""" + + id = "AG038" + name = "IAM/privilege escalation exposed to the agent" + default_severity = Severity.CRITICAL + description = ( + "An agent tool can grant or widen access — create credentials, attach IAM policies, " + "add users to groups, or open a bucket policy — with no approval." + ) + risk = ( + "A manipulated agent could escalate its own or an attacker's privileges, turning a " + "prompt injection into durable administrative access." + ) + remediation = [ + "Never expose IAM/policy mutation to an agent without human approval", + "Grant the agent least-privilege credentials that cannot modify IAM", + "Use permission boundaries so the agent cannot widen access", + "Alert on every credential-creation and policy-attach call", + ] + mappings = Mappings( + owaspAgentic=["Identity and privilege abuse", "Excessive agency"], + nistAiRmf=["Govern", "Manage"], + iso42001Alignment=["Operational control", "Accountability"], + mitre=["T1098", "T1078"], # Account Manipulation, Valid Accounts + ) + + 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 ( + isinstance(child, ast.Call) + and isinstance(child.func, ast.Attribute) + and child.func.attr in _IAM_ESCALATION_METHODS + ): + return f"Tool '{func.name}' calls {child.func.attr}() — grants or widens access" + return None + + +class WorldWritablePermissionRule(Rule): + """AG039 — World-writable permission grant exposed to the agent.""" + + id = "AG039" + name = "World-writable permission grant exposed to the agent" + default_severity = Severity.HIGH + description = ( + "An agent tool can make a file world-writable via chmod (the other-write bit) with " + "no approval." + ) + risk = ( + "A world-writable file can be modified by any local user, so a manipulated agent " + "could weaken protection on a script, config, or credential and enable tampering." + ) + remediation = [ + "Never let an agent set world-writable permissions", + "Use least-privilege modes (0o600 / 0o644) for files the agent touches", + "Require human approval for any permission change outside the workspace", + ] + mappings = Mappings( + owaspAgentic=["Excessive agency", "Tool misuse"], + nistAiRmf=["Govern", "Manage"], + iso42001Alignment=["Operational control", "Accountability"], + mitre=["T1222"], # File and Directory Permissions Modification + ) + + 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): + return None + resolved = ctx.analysis.resolve_call(child) + is_chmod = resolved == "os.chmod" or ( + isinstance(child.func, ast.Attribute) and child.func.attr == "chmod" + ) + if not is_chmod: + return None + for arg in child.args: + if ( + isinstance(arg, ast.Constant) + and isinstance(arg.value, int) + and not isinstance(arg.value, bool) + and arg.value & _WORLD_WRITABLE_BIT + ): + return f"Tool '{func.name}' makes a path world-writable (chmod {oct(arg.value)})" + return None + + class ExcessiveLimitRule(Rule): """AG009 — Excessive execution limit.""" diff --git a/src/autonomyproof/rules/registry.py b/src/autonomyproof/rules/registry.py index decd2a4..920b8b2 100644 --- a/src/autonomyproof/rules/registry.py +++ b/src/autonomyproof/rules/registry.py @@ -8,11 +8,13 @@ ExcessiveLimitRule, FinancialTransactionRule, GuardrailSelfModificationRule, + IamPrivilegeEscalationRule, IrreversibleDataDestructionRule, McpArgumentValidationRule, PersistenceWriteRule, RuntimePackageInstallRule, SubAgentCreationRule, + WorldWritablePermissionRule, ) from autonomyproof.rules.base import Rule from autonomyproof.rules.data import ( @@ -92,6 +94,8 @@ FinancialTransactionRule, # AG035 PersistenceWriteRule, # AG036 RuntimePackageInstallRule, # AG037 + IamPrivilegeEscalationRule, # AG038 + WorldWritablePermissionRule, # AG039 ] diff --git a/tests/test_cli.py b/tests/test_cli.py index a246479..193e8b0 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.17.0" in result.output + assert "0.18.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 fb6a529..3bf613c 100644 --- a/tests/test_rules_agent_controls.py +++ b/tests/test_rules_agent_controls.py @@ -8,11 +8,13 @@ ExcessiveLimitRule, FinancialTransactionRule, GuardrailSelfModificationRule, + IamPrivilegeEscalationRule, IrreversibleDataDestructionRule, McpArgumentValidationRule, PersistenceWriteRule, RuntimePackageInstallRule, SubAgentCreationRule, + WorldWritablePermissionRule, ) from helpers import run_rule @@ -266,6 +268,84 @@ def test_ag037_approval_gated_clean() -> None: assert run_rule(RuntimePackageInstallRule(), code) == [] +# --- AG038 -------------------------------------------------------------------- +def test_ag038_put_user_policy() -> None: + code = "@tool\ndef grant(u):\n iam.put_user_policy(UserName=u, PolicyDocument=doc)\n" + findings = run_rule(IamPrivilegeEscalationRule(), code) + assert findings and findings[0].ruleId == "AG038" + assert findings[0].toolName == "grant" + + +def test_ag038_create_access_key() -> None: + assert run_rule( + IamPrivilegeEscalationRule(), "@tool\ndef mint(u):\n iam.create_access_key(UserName=u)\n" + ) + + +def test_ag038_non_tool_clean() -> None: + code = "def grant(u):\n iam.put_user_policy(UserName=u, PolicyDocument=doc)\n" + assert run_rule(IamPrivilegeEscalationRule(), code) == [] + + +def test_ag038_approval_gated_clean() -> None: + code = ( + "@tool\ndef grant(u):\n if not approved:\n" + " return\n iam.put_user_policy(UserName=u, PolicyDocument=doc)\n" + ) + assert run_rule(IamPrivilegeEscalationRule(), code) == [] + + +def test_ag038_read_only_iam_clean() -> None: + # Reading IAM (get_user) is not escalation — not in the set. + code = "@tool\ndef who(u):\n return iam.get_user(UserName=u)\n" + assert run_rule(IamPrivilegeEscalationRule(), code) == [] + + +def test_ag038_plain_call_clean() -> None: + # A non-attribute call must not fire. + code = "@tool\ndef noop():\n helper()\n" + assert run_rule(IamPrivilegeEscalationRule(), code) == [] + + +# --- AG039 -------------------------------------------------------------------- +def test_ag039_os_chmod_world_writable() -> None: + code = "import os\n@tool\ndef loosen(p):\n os.chmod(p, 0o777)\n" + findings = run_rule(WorldWritablePermissionRule(), code) + assert findings and findings[0].ruleId == "AG039" + assert findings[0].toolName == "loosen" + + +def test_ag039_path_chmod_world_writable() -> None: + code = "from pathlib import Path\n@tool\ndef loosen(p):\n Path(p).chmod(0o666)\n" + assert run_rule(WorldWritablePermissionRule(), code) + + +def test_ag039_safe_mode_clean() -> None: + code = "import os\n@tool\ndef fix(p):\n os.chmod(p, 0o644)\n" + assert run_rule(WorldWritablePermissionRule(), code) == [] + + +def test_ag039_dynamic_mode_clean() -> None: + # A non-constant mode can't be proven world-writable, so it doesn't fire. + code = "import os\n@tool\ndef fix(p, mode):\n os.chmod(p, mode)\n" + assert run_rule(WorldWritablePermissionRule(), code) == [] + + +def test_ag039_non_chmod_clean() -> None: + code = "@tool\ndef noop():\n print('hi')\n" + assert run_rule(WorldWritablePermissionRule(), code) == [] + + +def test_ag039_non_tool_clean() -> None: + code = "import os\ndef loosen(p):\n os.chmod(p, 0o777)\n" + assert run_rule(WorldWritablePermissionRule(), code) == [] + + +def test_ag039_approval_gated_clean() -> None: + code = "import os\n@tool\ndef loosen(p):\n if not approved:\n return\n os.chmod(p, 0o777)\n" + assert run_rule(WorldWritablePermissionRule(), 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 6c32160..29aeba8 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) == 37 + assert len(result.rules_executed) == 39 assert any(c.name == "Shell execution" for c in result.capabilities)