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
1219from __future__ import annotations
1320
1421import json
1522import re
1623import typing as t
24+ from dataclasses import dataclass
1725
1826from dreadnode .agents .events import ToolStart
1927from dreadnode .agents .reactions import RetryWithFeedback
2028from 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 )
5093async 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
0 commit comments