Files

191 lines
6.2 KiB
Python

"""
R97 Command Firewall.
Implements the runtime safety layer for connector chat:
- Canonical command parsing (assistant output -> internal structure).
- Allowlist/Denylist validation for flags and values.
- Normalized safe rendering (internal structure -> user-facing command string).
"""
import logging
import re
import shlex
from dataclasses import dataclass, field
from typing import Dict, List
logger = logging.getLogger(__name__)
_DANGEROUS_PATTERNS = (r";", r"`", r"\$\(", r"\|")
_VALID_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
@dataclass
class NormalizedCommand:
command: str
args: List[str] = field(default_factory=list)
flags: Dict[str, str] = field(default_factory=dict)
is_safe: bool = False
safety_reason: str = "unvalidated"
code: str = "unvalidated"
severity: str = "medium"
action: str = "deny"
def to_string(self) -> str:
"""Render deterministic safe command string."""
parts = [self.command]
# Canonical flag order
for k in sorted(self.flags.keys()):
v = self.flags[k]
# Simple quoting heuristic
if " " in v or not v:
v = f'"{v}"'
parts.append(f"{k}={v}")
# Positional args
parts.extend(self.args)
return " ".join(parts)
def to_contract(self) -> Dict[str, str]:
return {
"code": self.code,
"severity": self.severity,
"action": self.action,
"reason": self.safety_reason,
}
class CommandFirewall:
"""
Validates and normalizes assistant-generated command suggestions.
"""
def __init__(self):
# TODO: Load policy from config
self.allowed_commands = {"/run", "/status", "/help", "/jobs"}
self.unsafe_pattern_deny = set(_DANGEROUS_PATTERNS)
def validate_suggestion(self, raw_suggestion: str) -> NormalizedCommand:
"""
Parse and validate a raw command string from LLM output.
Returns a NormalizedCommand object marked safe or unsafe.
"""
clean_text = raw_suggestion.strip()
# 0. Pre-parsing unsafe pattern check (Denylist)
for pattern in self.unsafe_pattern_deny:
if re.search(pattern, clean_text):
return NormalizedCommand(
command="error",
is_safe=False,
safety_reason=f"unsafe_pattern_detected: {pattern}",
code="firewall_unsafe_pattern",
severity="high",
action="deny",
)
# 1. Basic Parse
try:
lexer = shlex.shlex(clean_text, posix=True)
lexer.whitespace_split = True
lexer.quotes = '"' # strict double quotes per router contract
parts = list(lexer)
except ValueError as e:
return NormalizedCommand(
command="error",
is_safe=False,
safety_reason=f"parse_error: {str(e)}",
code="firewall_parse_error",
severity="medium",
action="deny",
)
if not parts:
return NormalizedCommand(
command="",
is_safe=False,
safety_reason="empty_command",
code="firewall_empty_command",
severity="medium",
action="deny",
)
cmd = parts[0].lower()
# 2. Allowlist Check
if cmd not in self.allowed_commands:
return NormalizedCommand(
command=cmd,
is_safe=False,
safety_reason=f"command_not_allowed: {cmd}",
code="firewall_command_not_allowed",
severity="high",
action="deny",
)
# 3. Argument Parsing & Normalization
args = parts[1:]
clean_args = []
flags = {}
for arg in args:
if arg.startswith("-"):
if "=" in arg and not arg.startswith("--"):
k, v = arg.split("=", 1)
if not _VALID_KEY_RE.match(k):
return NormalizedCommand(
command=cmd,
is_safe=False,
safety_reason=f"invalid_key: {k}",
code="firewall_invalid_key",
severity="medium",
action="deny",
)
if len(v) > 1000:
return NormalizedCommand(
command=cmd,
is_safe=False,
safety_reason=f"value_too_long: {k}",
code="firewall_value_too_long",
severity="medium",
action="deny",
)
flags[k] = v
elif arg.startswith("--"):
clean_args.append(arg)
else:
clean_args.append(arg)
elif "=" in arg:
k, v = arg.split("=", 1)
if not _VALID_KEY_RE.match(k):
return NormalizedCommand(
command=cmd,
is_safe=False,
safety_reason=f"invalid_key: {k}",
code="firewall_invalid_key",
severity="medium",
action="deny",
)
if len(v) > 1000:
return NormalizedCommand(
command=cmd,
is_safe=False,
safety_reason=f"value_too_long: {k}",
code="firewall_value_too_long",
severity="medium",
action="deny",
)
flags[k] = v
else:
clean_args.append(arg)
return NormalizedCommand(
command=cmd,
args=clean_args,
flags=flags,
is_safe=True,
safety_reason="valid",
code="firewall_allow",
severity="info",
action="allow",
)