From 9444479d7f6af566a2907f49557e95230156a948 Mon Sep 17 00:00:00 2001 From: Raja Sekhar Rao Dheekonda Date: Tue, 15 Sep 2026 15:24:20 -0700 Subject: [PATCH] feat(ai-red-teaming): guardrail hook to block CLI/shell environment provisioning (ENG-8477) The provisioning-and-lifecycle skill tells the agent to provision only via the provision_environment tool and never via `dreadnode env` in a shell, but skill prose is advisory - a weak driver can ignore it and fall back to the CLI. Add a ToolStart hook that structurally enforces the rule: shell tool calls (bash/shell/python/...) whose command matches `dreadnode|dn env[ironment]` are denied before execution and redirected to provision_environment (via RetryWithFeedback, which the runtime turns into a [POLICY DENIED] tool result). Bump to 1.16.2. --- capabilities/ai-red-teaming/capability.yaml | 4 +- .../hooks/block_cli_provisioning.py | 64 +++++++++++++++++++ .../tests/test_block_cli_provisioning.py | 62 ++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 capabilities/ai-red-teaming/hooks/block_cli_provisioning.py create mode 100644 capabilities/ai-red-teaming/tests/test_block_cli_provisioning.py diff --git a/capabilities/ai-red-teaming/capability.yaml b/capabilities/ai-red-teaming/capability.yaml index 23ba01c..8bf39a3 100644 --- a/capabilities/ai-red-teaming/capability.yaml +++ b/capabilities/ai-red-teaming/capability.yaml @@ -1,6 +1,6 @@ schema: 1 name: ai-red-teaming -version: "1.16.1" +version: "1.16.2" description: > Probe the security and safety of AI applications, agents, and foundation models. Orchestrates adversarial attack workflows to discover vulnerabilities in LLMs, @@ -20,6 +20,8 @@ skills: - skills/ tools: - tools/ +hooks: + - hooks/block_cli_provisioning.py author: name: Dreadnode diff --git a/capabilities/ai-red-teaming/hooks/block_cli_provisioning.py b/capabilities/ai-red-teaming/hooks/block_cli_provisioning.py new file mode 100644 index 0000000..1abb5a7 --- /dev/null +++ b/capabilities/ai-red-teaming/hooks/block_cli_provisioning.py @@ -0,0 +1,64 @@ +"""Structural guardrail: deny environment provisioning through the shell. + +The `provisioning-and-lifecycle` skill instructs the agent to provision only via +the `provision_environment` tool and to NEVER run `dreadnode env ...` in a shell. +Skill prose is advisory - a weaker driver model can ignore it and fall back to +the CLI (ENG-8477). This hook enforces the rule structurally: if the agent tries +to provision an environment through a shell tool, the call is denied before it +runs and the agent is told to use `provision_environment` (or stop and inform +the user). +""" + +from __future__ import annotations + +import json +import re +import typing as t + +from dreadnode.agents.events import ToolStart +from dreadnode.agents.reactions import RetryWithFeedback +from dreadnode.core.hook import hook + +# Shell-style tools that can execute a CLI command. +_SHELL_TOOLS = {"bash", "shell", "sh", "python", "python3", "run_command", "execute"} + +# `dreadnode env ...` / `dn env ...` / `dreadnode environment ...` in a shell. +# Matches the exact pattern the skill forbids without touching other CLI usage. +_CLI_PROVISION = re.compile(r"\b(?:dreadnode|dn)\s+env(?:ironment)?\b", re.IGNORECASE) + +_FEEDBACK = ( + "Provisioning an environment through the shell/CLI is not permitted. " + "Use the `provision_environment` tool instead - it resolves the catalog and " + "credentials for you. If `provision_environment` is unavailable, STOP and " + "inform the user; do not fall back to the shell." +) + + +def _iter_strings(value: t.Any) -> t.Iterator[str]: + """Yield every string in an arbitrarily nested arguments payload.""" + if isinstance(value, str): + yield value + elif isinstance(value, dict): + for item in value.values(): + yield from _iter_strings(item) + elif isinstance(value, (list, tuple)): + for item in value: + yield from _iter_strings(item) + + +@hook(ToolStart) +async def block_cli_provisioning(event: ToolStart) -> RetryWithFeedback | None: + """Deny shell-based environment provisioning and redirect to the tool.""" + if event.tool_call.name.lower() not in _SHELL_TOOLS: + return None + + raw = event.tool_call.function.arguments + try: + args: t.Any = json.loads(raw) + except (json.JSONDecodeError, TypeError): + args = raw + + if any(_CLI_PROVISION.search(text) for text in _iter_strings(args)): + return RetryWithFeedback(feedback=_FEEDBACK, tool_call_id=event.tool_call.id) + + return None diff --git a/capabilities/ai-red-teaming/tests/test_block_cli_provisioning.py b/capabilities/ai-red-teaming/tests/test_block_cli_provisioning.py new file mode 100644 index 0000000..d4efa25 --- /dev/null +++ b/capabilities/ai-red-teaming/tests/test_block_cli_provisioning.py @@ -0,0 +1,62 @@ +"""Tests for the block_cli_provisioning guardrail hook (ENG-8477).""" + +from __future__ import annotations + +import asyncio +import importlib.util +import json +from pathlib import Path + +_HOOK_PATH = Path(__file__).resolve().parents[1] / "hooks" / "block_cli_provisioning.py" +_spec = importlib.util.spec_from_file_location("airt_block_cli_provisioning", _HOOK_PATH) +assert _spec and _spec.loader +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + + +class _Fn: + def __init__(self, arguments: str) -> None: + self.arguments = arguments + + +class _Call: + def __init__(self, name: str, arguments: str, call_id: str = "call_1") -> None: + self.name = name + self.id = call_id + self.function = _Fn(arguments) + + +class _Event: + def __init__(self, name: str, arguments: str) -> None: + self.tool_call = _Call(name, arguments) + + +def _run(name: str, arguments: str): + # The @hook decorator wraps the function in a Hook; call the underlying func. + hook_obj = _mod.block_cli_provisioning + fn = getattr(hook_obj, "func", hook_obj) + return asyncio.run(fn(_Event(name, arguments))) + + +def test_blocks_dreadnode_env_provision_via_bash() -> None: + r = _run("bash", json.dumps({"command": "dreadnode env provision finops-mesh"})) + assert r is not None + assert type(r).__name__ == "RetryWithFeedback" + assert "provision_environment" in r.feedback + + +def test_blocks_dn_env_and_environment_variants() -> None: + assert _run("bash", json.dumps({"command": "dn env create soc-mesh"})) is not None + assert _run("shell", json.dumps({"command": "dreadnode environment up x"})) is not None + assert _run("python", json.dumps({"code": "os.system('dreadnode env provision m')"})) is not None + + +def test_allows_non_provisioning_shell_commands() -> None: + assert _run("bash", json.dumps({"command": "ls -la /home/user"})) is None + assert _run("bash", json.dumps({"command": "dn airt run --goal x --attack tap"})) is None + assert _run("bash", json.dumps({"command": "python attack.py"})) is None + + +def test_ignores_non_shell_tools() -> None: + # A real provision_environment call must never be blocked. + assert _run("provision_environment", json.dumps({"name": "finops-mesh"})) is None