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
4 changes: 3 additions & 1 deletion capabilities/ai-red-teaming/capability.yaml
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,6 +20,8 @@ skills:
- skills/
tools:
- tools/
hooks:
- hooks/block_cli_provisioning.py

author:
name: Dreadnode
Expand Down
64 changes: 64 additions & 0 deletions capabilities/ai-red-teaming/hooks/block_cli_provisioning.py
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions capabilities/ai-red-teaming/tests/test_block_cli_provisioning.py
Original file line number Diff line number Diff line change
@@ -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
Loading