From e5e258fa0bac86adbc6b3e078eeae17e86c61db7 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 1 Aug 2026 22:09:33 +0100 Subject: [PATCH] Fix dotted target parsing and network bookkeeping (#60) Closes #17 --- CHANGELOG.md | 1 + plugins/violin_guard/bash_ast.py | 19 ++++++++++++++++++- plugins/violin_guard/state.py | 12 ++++++++++-- .../guard/guards/test_scope_authorization.py | 12 ++++++++++++ tests/guard/state/test_sync_credit_window.py | 2 ++ 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf54d0..6ef8d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 3.0.0 +- Fixed target extraction for dotted identifiers and direct `/dev/tcp`/`/dev/udp` redirections, and prevented network-capable local-looking commands from bypassing execution accounting. - Made interrupted skill preparation recoverable with expiring reservations and stale-owner protection; batch review now remains tied to the delivered execution receipt. - Stabilized the core engagement workflow: domain/URL-only scopes now validate, runtime execution cannot substitute another scope file, PTT/review CLI contracts carry skill metadata, and review reuses the active delivered binding. - Added receipt-backed skill routing, delivery, task binding, browser enforcement, Kali auto-backend selection, proof-based finding review, and semantic anti-stuck enforcement. diff --git a/plugins/violin_guard/bash_ast.py b/plugins/violin_guard/bash_ast.py index aec6575..93ad994 100644 --- a/plugins/violin_guard/bash_ast.py +++ b/plugins/violin_guard/bash_ast.py @@ -14,6 +14,7 @@ class CommandSegment: raw_text: str words: list[str] = field(default_factory=list) executable: str = "" + redirects: list[str] = field(default_factory=list) class _CommandVisitor: @@ -31,8 +32,19 @@ class _CommandVisitor: segment_text = self.command[start:end] words = self._collect_words(node) executable = self._extract_executable(words) + redirects = [ + str(getattr(getattr(part, "output", None), "word", "")) + for part in getattr(node, "parts", []) + if getattr(part, "kind", None) == "redirect" + and getattr(getattr(part, "output", None), "word", None) + ] self.segments.append( - CommandSegment(raw_text=segment_text, words=words, executable=executable) + CommandSegment( + raw_text=segment_text, + words=words, + executable=executable, + redirects=redirects, + ) ) if hasattr(node, "parts"): @@ -52,6 +64,11 @@ class _CommandVisitor: if kind == "word" and hasattr(n, "word"): words.append(n.word) self.words.append(n.word) + elif kind == "redirect": + output = getattr(n, "output", None) + if output is not None and hasattr(output, "word"): + words.append(output.word) + self.words.append(output.word) if hasattr(n, "parts"): for child in n.parts: collect(child) diff --git a/plugins/violin_guard/state.py b/plugins/violin_guard/state.py index 1f95fbd..cd9e069 100644 --- a/plugins/violin_guard/state.py +++ b/plugins/violin_guard/state.py @@ -170,8 +170,16 @@ def mutate_json(path: Path, mutation) -> Any: def is_local_bookkeeping_command(command: str) -> bool: """Whether a command is a harmless local bookkeeping action.""" - leading = command.strip().split(maxsplit=1) - return bool(leading) and leading[0] in LOCAL_TOOLS + from .bash_ast import parse_bash_segments + from .targets import extract_target_candidates + + segments = parse_bash_segments(command) + if len(segments) != 1: + return False + segment = segments[0] + if segment.executable not in LOCAL_TOOLS or segment.redirects: + return False + return not extract_target_candidates(command) # Sync credit / pending sync diff --git a/tests/guard/guards/test_scope_authorization.py b/tests/guard/guards/test_scope_authorization.py index a02e9e1..5a9c110 100644 --- a/tests/guard/guards/test_scope_authorization.py +++ b/tests/guard/guards/test_scope_authorization.py @@ -140,3 +140,15 @@ def test_callback_hosts_are_secondary_only_and_exclusions_still_win(tmp_path: Pa scope, "nc -l -v -s 10.10.10.99 4444", primary_target="10.10.10.10" ) assert any("excluded target 10.10.10.99" in error for error in excluded.errors) + + +def test_direct_dev_tcp_redirection_is_checked_and_not_bookkeeping(tmp_path: Path) -> None: + scope = tmp_path / "scope.yaml" + _write_scope(scope, callback_hosts="10.10.10.10") + + result = check_scope_targets( + scope, + "echo ready > /dev/tcp/10.10.10.99/4444", + primary_target="10.10.10.10", + ) + assert any("10.10.10.99" in error for error in result.errors) diff --git a/tests/guard/state/test_sync_credit_window.py b/tests/guard/state/test_sync_credit_window.py index c38a04f..85ce101 100644 --- a/tests/guard/state/test_sync_credit_window.py +++ b/tests/guard/state/test_sync_credit_window.py @@ -33,6 +33,8 @@ def test_network_clients_are_not_local_bookkeeping() -> None: for command in ("curl https://10.10.10.10", "dig 10.10.10.10", "host 10.10.10.10"): assert not state.is_local_bookkeeping_command(command) assert state.is_local_bookkeeping_command("echo local-note") + assert not state.is_local_bookkeeping_command("echo ping > /dev/tcp/10.10.10.10/80") + assert not state.is_local_bookkeeping_command("cat < /dev/tcp/10.10.10.10/80") def test_phase_window_runs_without_yolo_then_next_command_blocks(