diff --git a/plugins/violin_guard/command.py b/plugins/violin_guard/command.py index 381a46b..da9aee4 100644 --- a/plugins/violin_guard/command.py +++ b/plugins/violin_guard/command.py @@ -332,6 +332,60 @@ def check_local_artifact_paths(command: str) -> CheckResult: return result +_HTTP_CLIENT_RE = re.compile(r"\b(?:curl|wget)\b", re.I) +_HTTP_URL_RE = re.compile(r"https?://\S+", re.I) +_HTTP_LONG_FLAG_RE = re.compile( + r"-(?:include|verbose|head|dump-header|write-out|output|remote-name|output-document)\b", + re.I, +) +# Offline captures (`-o file`, `-O`, `-e/--output`, `> file`) are not +# interactive HTTP evidence; status probes via `-w` are separately exempted. +_HTTP_OFFLINE_CAPTURE_RE = re.compile( + r"-(?:o|O|output|remote-name|output-document)\b|\s>\s*[^\s|]+", re.I +) + + +def _has_short_flag(command: str, *flags: str) -> bool: + """True if any single-dash short-flag cluster contains one of `flags`. + + Handles combined clusters (`-si`, `-sv`, `-Dk`) and separate tokens + (`-s -i`). Case-sensitive on purpose: `-D` (dump-header) counts while + `-d` (POST data) does not. Guarded to letters-only tokens to avoid + matching data payloads or stray dashes. + """ + wanted = set(flags) + return any( + any(ch in wanted for ch in token) + for token in re.findall(r"(? CheckResult: + """Review-level guard: HTTP probes must capture the response status/headers. + + SKILL.md §4 mandates `-i` or `-sv` when testing HTTP endpoints so evidence + files carry empirical status lines. Receipts from plain `curl -s` (no `-i`) + make `has_decisive_proof` fail and burn real confirmations at scoring time + (benchmark-run-20260811_175611: only 6/194 receipts carried HTTP/1.1). + Exempted: status probes (`-w %{http_code}`), HEAD (`-I`), header dumps + (`-D`), and offline captures (`-o`/`-O`/`> file`) that are not interactive + HTTP evidence. + """ + result = CheckResult() + if not _HTTP_CLIENT_RE.search(command) or not _HTTP_URL_RE.search(command): + return result + if _HTTP_LONG_FLAG_RE.search(command) or _HTTP_OFFLINE_CAPTURE_RE.search(command): + return result + if _has_short_flag(command, "i", "v", "I", "D", "w", "o", "O"): + return result + result.add_warning( + "HTTP probe without status/headers capture: add `-i` (or `-sv`) to curl " + "so the evidence file records the response status line — plain `-s` " + "produces no HTTP/1.1 line and fails decisive-proof scoring" + ) + return result + + def check_cross_engagement_paths(command: str, active_eng_dir: Path) -> CheckResult: """Block commands that reference a foreign engagement directory under engagements/.""" result = CheckResult() @@ -626,6 +680,11 @@ def check_command(args: CheckCommandArgs) -> CheckResult: destructive_result = check_destructive_patterns(args.command) result.errors.extend(destructive_result.errors) + # 2c2. HTTP proof flags (review): `-i`/`-sv` so receipts are decisive + proof_result = check_http_proof_flags(args.command) + result.warnings.extend(proof_result.warnings) + result.infos.extend(proof_result.infos) + artifact_result = check_local_artifact_paths(args.command) result.infos.extend(artifact_result.infos) diff --git a/tests/guard/guards/test_http_proof_flags.py b/tests/guard/guards/test_http_proof_flags.py new file mode 100644 index 0000000..75ff00f --- /dev/null +++ b/tests/guard/guards/test_http_proof_flags.py @@ -0,0 +1,54 @@ +"""HTTP proof-flag guard tests — decisiveness of curl receipts at scoring time. + +Regression: benchmark-run-20260811_175611 wrote 194 receipts but only 6 +carried HTTP/1.1 status lines (plain `curl -s` without `-i`), so the +decisive-proof scorer rejected most of them. SKILL.md §4 mandates `-i`/`-sv`; +this guard makes the requirement a review gate. +""" + +from __future__ import annotations + +from plugins.violin_guard.command import check_http_proof_flags + + +def test_plain_silent_curl_warns_review() -> None: + result = check_http_proof_flags( + "curl -s https://duck-store.escape.tech/api/v1/products" + ) + assert result.errors == [] + assert len(result.warnings) == 1 + assert "-i" in result.warnings[0] + assert result.exit_code() == 2 # review, not hard block + + +def test_curl_with_status_capture_is_clean() -> None: + for command in ( + "curl -si https://duck-store.escape.tech/api/v1/products", + "curl -i -X POST https://duck-store.escape.tech/api/v1/auth/login -d '{}'", + "curl -sv https://duck-store.escape.tech/api/v1/orders/1", + "curl -I https://duck-store.escape.tech/", + "curl -D headers.txt https://duck-store.escape.tech/api/v1/products", + ): + result = check_http_proof_flags(command) + assert result.errors == [] and result.warnings == [], command + assert result.exit_code() == 0, command + + +def test_status_probe_and_offline_capture_exempt() -> None: + for command in ( + "curl -s -o /dev/null -w '%{http_code}' https://duck-store.escape.tech/api/v1/auth/login", + "curl -sO https://duck-store.escape.tech/bundle.js", + "curl -s https://duck-store.escape.tech/api/v1/products > evidence/recon/products.json", + ): + result = check_http_proof_flags(command) + assert result.errors == [] and result.warnings == [], command + + +def test_non_http_or_non_curl_commands_untouched() -> None: + for command in ( + "nmap -sV 10.10.10.10", + "grep -i admin evidence/recon/products.json", + "python3 tools/burst.py --target https://duck-store.escape.tech", + ): + result = check_http_proof_flags(command) + assert result.errors == [] and result.warnings == [], command