diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb3fb1..fb0e6cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2.0.3 + +- Fixed raw-terminal compound-command classification so every pipeline, logical, semicolon, and newline segment is checked independently, and package/source exemptions require every URL in the segment to use an approved source host. + ## 2.0.2 - Restricted all GitHub Actions workflow tokens to read-only repository contents, resolving the three least-privilege code-scanning alerts without changing workflow behavior. diff --git a/distribution.yaml b/distribution.yaml index b7ac148..5708f9a 100644 --- a/distribution.yaml +++ b/distribution.yaml @@ -1,6 +1,6 @@ # violin - supervised agentic Hermes pentest profile name: violin -version: 2.0.2 +version: 2.0.3 description: "A supervised agentic Hermes penetration testing profile for authorised Kali/Parrot-based security assessment, reconnaissance, exploit validation, and reporting workflows." hermes_requires: ">=0.18.0" author: "Violin contributors" diff --git a/plugins/violin_guard/plugin.yaml b/plugins/violin_guard/plugin.yaml index 4b0d95d..08448aa 100644 --- a/plugins/violin_guard/plugin.yaml +++ b/plugins/violin_guard/plugin.yaml @@ -1,5 +1,5 @@ name: violin-guard -version: "2.0.2" +version: "2.0.3" description: Typed scope guards and an execute-and-record boundary with bounded synchronization windows. kind: standalone provides_tools: diff --git a/plugins/violin_guard/terminal_policy.py b/plugins/violin_guard/terminal_policy.py index 3765727..e0fe624 100644 --- a/plugins/violin_guard/terminal_policy.py +++ b/plugins/violin_guard/terminal_policy.py @@ -55,11 +55,18 @@ _DOMAIN_RE = re.compile( re.IGNORECASE, ) _URL_RE = re.compile(r"\b(?:https?|ftp|wss?|file)://[^\s'\"<>]+", re.IGNORECASE) -_KNOWN_SOURCE_HOST_RE = re.compile( - r"https?://(?:[^/]*\.)?(?:github\.com|gitlab\.com|bitbucket\.org|" - r"pypi\.org|files\.pythonhosted\.org|registry\.npmjs\.org|" - r"crates\.io|proxy\.golang\.org|go\.dev)(?::\d+)?(?:/|$)", - re.IGNORECASE, +_KNOWN_SOURCE_HOSTS = frozenset( + { + "bitbucket.org", + "crates.io", + "files.pythonhosted.org", + "github.com", + "gitlab.com", + "go.dev", + "proxy.golang.org", + "pypi.org", + "registry.npmjs.org", + } ) _NETWORK_PATH_RE = re.compile(r"/(?:dev/)?(?:tcp|udp)/", re.IGNORECASE) _NETWORK_MODULE_RE = re.compile( @@ -123,6 +130,13 @@ def _url_hosts(command: str) -> list[str]: return hosts +def _is_known_source_host(host: str) -> bool: + normalized = host.lower().rstrip(".") + return any( + normalized == known or normalized.endswith(f".{known}") for known in _KNOWN_SOURCE_HOSTS + ) + + def _has_target_literal(command: str) -> bool: """Inspect shell arguments, not arbitrary source code or file paths.""" for segment in _COMMAND_SPLIT_RE.split(command): @@ -163,6 +177,42 @@ def _has_target_literal(command: str) -> bool: return False +def _block_terminal_segment(segment: str) -> str | None: + if _NETWORK_PATH_RE.search(segment): + return _message("network socket path detected in the raw terminal command") + + executable = _first_executable(segment) + if executable in _SCRIPT_INTERPRETERS and _NETWORK_MODULE_RE.search(segment): + return _message("network-capable script primitive detected in the raw terminal command") + + # Package/source retrieval is allowed for local setup (for example git + # clone or pip install). URLs and host literals in all other commands are + # treated as target interaction and must use the typed guard. + is_source_command = _is_package_or_source_command(segment) + url_hosts = _url_hosts(segment) + if not is_source_command and url_hosts: + return _message("URL detected in a non-package raw terminal command") + + # Public package/source URLs are host-local setup, not assessment traffic. + # Keep numeric authorities conservative: a clone/install from an IP may be + # an engagement target and must go through the typed guard. + if ( + is_source_command + and url_hosts + and all(_is_known_source_host(host) for host in url_hosts) + and not _IPV4_RE.search(segment) + ): + return None + + if executable not in _LOCAL_COMMANDS and _has_target_literal(segment): + return _message("target host literal detected in the raw terminal command") + + if executable in _SCRIPT_INTERPRETERS and _SUSPICIOUS_SCRIPT_RE.search(segment): + return _message("assessment script detected in the raw terminal command") + + return None + + def block_terminal_command(command: str) -> str | None: """Return a block message for clearly target-touching raw terminal calls. @@ -174,37 +224,9 @@ def block_terminal_command(command: str) -> str | None: if not isinstance(command, str) or not command.strip(): return None - if _NETWORK_PATH_RE.search(command): - return _message("network socket path detected in the raw terminal command") - - executable = _first_executable(command) - if executable in _SCRIPT_INTERPRETERS and _NETWORK_MODULE_RE.search(command): - return _message("network-capable script primitive detected in the raw terminal command") - - # Package/source retrieval is allowed for local setup (for example git - # clone or pip install). URLs and host literals in all other commands are - # treated as target interaction and must use the typed guard. - url_hosts = _url_hosts(command) - if not _is_package_or_source_command(command) and url_hosts: - return _message("URL detected in a non-package raw terminal command") - - # Public package/source URLs are host-local setup, not assessment traffic. - # Keep numeric authorities conservative: a clone/install from an IP may be - # an engagement target and must go through the typed guard. - if ( - _is_package_or_source_command(command) - and url_hosts - and _KNOWN_SOURCE_HOST_RE.search(command) - and not _IPV4_RE.search(command) - ): - return None - - if executable not in _LOCAL_COMMANDS and _has_target_literal(command): - return _message("target host literal detected in the raw terminal command") - - if executable in _SCRIPT_INTERPRETERS and _SUSPICIOUS_SCRIPT_RE.search(command): - return _message("assessment script detected in the raw terminal command") - + for segment in _COMMAND_SPLIT_RE.split(command): + if message := _block_terminal_segment(segment): + return message return None diff --git a/pyproject.toml b/pyproject.toml index e59db27..6349e61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "violin" -version = "2.0.2" +version = "2.0.3" description = "Supervised agentic Hermes penetration-testing profile" requires-python = ">=3.11" dependencies = ["filelock>=3.13,<4"] diff --git a/tests/guard/test_terminal_policy.py b/tests/guard/test_terminal_policy.py index 571fab0..e71b584 100644 --- a/tests/guard/test_terminal_policy.py +++ b/tests/guard/test_terminal_policy.py @@ -113,6 +113,36 @@ def test_local_source_retrieval_remains_available() -> None: 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", + [ + "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", diff --git a/uv.lock b/uv.lock index 70ed9fe..f9128c9 100644 --- a/uv.lock +++ b/uv.lock @@ -154,7 +154,7 @@ wheels = [ [[package]] name = "violin" -version = "2.0.2" +version = "2.0.3" source = { virtual = "." } dependencies = [ { name = "filelock" },