Skip to content

Commit a61dca3

Browse files
authored
feat(ai-red-teaming): extensible guardrail policy table (provision + teardown) (#146)
Refactor the provisioning guardrail into a small policy table so new deny/redirect rules are one line. Add a teardown rule: shell `dreadnode|dn env teardown/delete/ destroy` is denied and redirected to the teardown_environment tool (destructive, tool-only per the skills). Provision subcommands redirect to provision_environment; a generic `dreadnode env` catch-all redirects to the environment tools. Scope stays narrow - legitimate CLI like `dn airt run` is unaffected (no skill forbids it). Bump to 1.16.3.
1 parent 61ec18c commit a61dca3

3 files changed

Lines changed: 79 additions & 25 deletions

File tree

‎capabilities/ai-red-teaming/capability.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
schema: 1
22
name: ai-red-teaming
3-
version: "1.16.2"
3+
version: "1.16.3"
44
description: >
55
Probe the security and safety of AI applications, agents, and foundation models.
66
Orchestrates adversarial attack workflows to discover vulnerabilities in LLMs,
Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,69 @@
1-
"""Structural guardrail: deny environment provisioning through the shell.
2-
3-
The `provisioning-and-lifecycle` skill instructs the agent to provision only via
4-
the `provision_environment` tool and to NEVER run `dreadnode env ...` in a shell.
5-
Skill prose is advisory - a weaker driver model can ignore it and fall back to
6-
the CLI (ENG-8477). This hook enforces the rule structurally: if the agent tries
7-
to provision an environment through a shell tool, the call is denied before it
8-
runs and the agent is told to use `provision_environment` (or stop and inform
9-
the user).
1+
"""Structural guardrails: deny shell fallbacks that bypass first-class tools.
2+
3+
The AIRT skills mandate tool-only paths for a few operations - most importantly,
4+
`provisioning-and-lifecycle` and `error-troubleshooting` both say to NEVER run
5+
`dreadnode env ...` in a shell and to use the `provision_environment` /
6+
`teardown_environment` tools instead. Skill prose is advisory; a weaker driver
7+
model can ignore it and fall back to the CLI (ENG-8477).
8+
9+
This module enforces those rules structurally with a small policy table. A
10+
`ToolStart` hook inspects every shell tool call and, when its command matches a
11+
rule, denies the call before it runs and redirects the agent to the correct
12+
tool (via `RetryWithFeedback`, which the runtime turns into a `[POLICY DENIED]`
13+
tool result). Rules are scoped narrowly so legitimate shell and CLI usage
14+
(for example `dn airt run`, which the skills permit) is never affected.
15+
16+
To add a guardrail, append a `_Rule` - one line per policy.
1017
"""
1118

1219
from __future__ import annotations
1320

1421
import json
1522
import re
1623
import typing as t
24+
from dataclasses import dataclass
1725

1826
from dreadnode.agents.events import ToolStart
1927
from dreadnode.agents.reactions import RetryWithFeedback
2028
from dreadnode.core.hook import hook
2129

2230
# Shell-style tools that can execute a CLI command.
23-
_SHELL_TOOLS = {"bash", "shell", "sh", "python", "python3", "run_command", "execute"}
31+
_SHELL_TOOLS = frozenset({"bash", "shell", "sh", "python", "python3", "run_command", "execute"})
32+
33+
34+
@dataclass(frozen=True)
35+
class _Rule:
36+
"""A single deny-and-redirect policy for shell tool calls."""
2437

25-
# `dreadnode env ...` / `dn env ...` / `dreadnode environment ...` in a shell.
26-
# Matches the exact pattern the skill forbids without touching other CLI usage.
27-
_CLI_PROVISION = re.compile(r"\b(?:dreadnode|dn)\s+env(?:ironment)?\b", re.IGNORECASE)
38+
tools: frozenset[str]
39+
pattern: re.Pattern[str]
40+
feedback: str
2841

29-
_FEEDBACK = (
30-
"Provisioning an environment through the shell/CLI is not permitted. "
31-
"Use the `provision_environment` tool instead - it resolves the catalog and "
32-
"credentials for you. If `provision_environment` is unavailable, STOP and "
33-
"inform the user; do not fall back to the shell."
42+
43+
# NOTE: order matters - the first matching rule wins, so list specific rules
44+
# (provision / teardown) before the generic `dreadnode env` catch-all.
45+
_RULES: tuple[_Rule, ...] = (
46+
_Rule(
47+
_SHELL_TOOLS,
48+
re.compile(r"\b(?:dreadnode|dn)\s+env(?:ironment)?\s+(?:provision|create|up|start|new)\b", re.IGNORECASE),
49+
"Provisioning an environment through the shell/CLI is not permitted. Use the "
50+
"`provision_environment` tool instead - it resolves the catalog and credentials for you. "
51+
"If it is unavailable, STOP and inform the user; do not fall back to the shell.",
52+
),
53+
_Rule(
54+
_SHELL_TOOLS,
55+
re.compile(r"\b(?:dreadnode|dn)\s+env(?:ironment)?\s+(?:teardown|delete|destroy|down|stop|rm|remove)\b", re.IGNORECASE),
56+
"Tearing down an environment through the shell/CLI is not permitted. Use the "
57+
"`teardown_environment` tool instead. If it is unavailable, STOP and inform the user; "
58+
"do not fall back to the shell.",
59+
),
60+
_Rule(
61+
_SHELL_TOOLS,
62+
re.compile(r"\b(?:dreadnode|dn)\s+env(?:ironment)?\b", re.IGNORECASE),
63+
"Managing environments through the shell/CLI is not permitted. Use the environment tools "
64+
"(`provision_environment`, `list_environments`, `teardown_environment`) instead. If they "
65+
"are unavailable, STOP and inform the user; do not fall back to the shell.",
66+
),
3467
)
3568

3669

@@ -46,19 +79,27 @@ def _iter_strings(value: t.Any) -> t.Iterator[str]:
4679
yield from _iter_strings(item)
4780

4881

82+
def _match(tool_name: str, args: t.Any) -> _Rule | None:
83+
"""Return the first rule that denies this tool call, if any."""
84+
name = tool_name.lower()
85+
texts = list(_iter_strings(args))
86+
for rule in _RULES:
87+
if name in rule.tools and any(rule.pattern.search(text) for text in texts):
88+
return rule
89+
return None
90+
91+
4992
@hook(ToolStart)
5093
async def block_cli_provisioning(event: ToolStart) -> RetryWithFeedback | None:
51-
"""Deny shell-based environment provisioning and redirect to the tool."""
52-
if event.tool_call.name.lower() not in _SHELL_TOOLS:
53-
return None
54-
94+
"""Deny shell calls that bypass first-class tools and redirect the agent."""
5595
raw = event.tool_call.function.arguments
5696
try:
5797
args: t.Any = json.loads(raw)
5898
except (json.JSONDecodeError, TypeError):
5999
args = raw
60100

61-
if any(_CLI_PROVISION.search(text) for text in _iter_strings(args)):
62-
return RetryWithFeedback(feedback=_FEEDBACK, tool_call_id=event.tool_call.id)
101+
rule = _match(event.tool_call.name, args)
102+
if rule is not None:
103+
return RetryWithFeedback(feedback=rule.feedback, tool_call_id=event.tool_call.id)
63104

64105
return None

‎capabilities/ai-red-teaming/tests/test_block_cli_provisioning.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
import asyncio
66
import importlib.util
77
import json
8+
import sys
89
from pathlib import Path
910

1011
_HOOK_PATH = Path(__file__).resolve().parents[1] / "hooks" / "block_cli_provisioning.py"
1112
_spec = importlib.util.spec_from_file_location("airt_block_cli_provisioning", _HOOK_PATH)
1213
assert _spec and _spec.loader
1314
_mod = importlib.util.module_from_spec(_spec)
15+
# Register before exec so dataclasses can resolve the module namespace under
16+
# `from __future__ import annotations` (dataclasses looks the module up in sys.modules).
17+
sys.modules[_spec.name] = _mod
1418
_spec.loader.exec_module(_mod)
1519

1620

@@ -51,8 +55,17 @@ def test_blocks_dn_env_and_environment_variants() -> None:
5155
assert _run("python", json.dumps({"code": "os.system('dreadnode env provision m')"})) is not None
5256

5357

58+
def test_blocks_teardown_via_cli_and_redirects_to_teardown_tool() -> None:
59+
r = _run("bash", json.dumps({"command": "dreadnode env teardown finops-mesh"}))
60+
assert r is not None
61+
assert "teardown_environment" in r.feedback
62+
# destroy/delete variants also redirect to the teardown tool
63+
assert "teardown_environment" in _run("bash", json.dumps({"command": "dn env delete soc-mesh"})).feedback
64+
65+
5466
def test_allows_non_provisioning_shell_commands() -> None:
5567
assert _run("bash", json.dumps({"command": "ls -la /home/user"})) is None
68+
# dn airt run is a permitted CLI (no skill forbids it) - must not be blocked
5669
assert _run("bash", json.dumps({"command": "dn airt run --goal x --attack tap"})) is None
5770
assert _run("bash", json.dumps({"command": "python attack.py"})) is None
5871

0 commit comments

Comments
 (0)