Compare commits

..

3 Commits

Author SHA1 Message Date
Alexander Whitestone
1f5067e94a Merge: bring in prior QA work on path guard (Refs #962)
All checks were successful
Lint / lint (pull_request) Successful in 15s
2026-04-22 00:25:50 -04:00
Alexander Whitestone
5d3e13ede2 test: add pre-commit path guard hook from burn/921 (Refs #962)
All checks were successful
Lint / lint (pull_request) Successful in 24s
Brings hooks/pre-commit-path-guard.py from burn/921-poka-yoke-hardcoded-paths
to complete QA verification of all guard layers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 23:55:38 -04:00
Alexander Whitestone
9e00a59791 test: verify hardcoded-home path guard from burn/921 branch
All checks were successful
Lint / lint (pull_request) Successful in 29s
Cherry-picks tools/path_guard.py and tests/test_path_guard.py from
burn/921-poka-yoke-hardcoded-paths (commit 5dcb905). All 21 tests pass:

- hardcoded /Users/<name>/ paths are rejected at runtime
- hardcoded /home/<name>/ paths are rejected at runtime
- ~/.hermes/... via expanduser() passes (safe, expanded at runtime)
- valid relative and /tmp/ absolute paths pass
- static scanner catches violations and respects # noqa: hardcoded-path-ok
- comments are skipped by scanner
- directory scanner skips test files and __pycache__

Refs #962

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:26:54 -04:00
6 changed files with 368 additions and 244 deletions

View File

@@ -46,7 +46,6 @@ from hermes_cli.config import (
)
from gateway.status import get_running_pid, read_runtime_status
from agent.agent_card import get_agent_card_json
from agent.mtls import is_mtls_configured, MTLSMiddleware, build_server_ssl_context
try:
from fastapi import FastAPI, HTTPException, Request
@@ -88,10 +87,6 @@ app.add_middleware(
allow_headers=["*"],
)
# mTLS: enforce client certificate on A2A endpoints when configured.
# Activated by setting HERMES_MTLS_CERT, HERMES_MTLS_KEY, HERMES_MTLS_CA.
app.add_middleware(MTLSMiddleware)
# ---------------------------------------------------------------------------
# Endpoints that do NOT require the session token. Everything else under
# /api/ is gated by the auth middleware below. Keep this list minimal —
@@ -2110,20 +2105,6 @@ def start_server(
"authentication. Only use on trusted networks.", host,
)
# mTLS: when configured, pass SSL context to uvicorn so all connections
# are TLS with mandatory client certificate verification.
ssl_context = None
scheme = "http"
if is_mtls_configured():
try:
ssl_context = build_server_ssl_context()
scheme = "https"
_log.info(
"mTLS enabled — server requires client certificates (A2A auth)"
)
except Exception as exc:
_log.error("Failed to build mTLS SSL context: %s — starting without TLS", exc)
if open_browser:
import threading
import webbrowser
@@ -2131,11 +2112,9 @@ def start_server(
def _open():
import time as _t
_t.sleep(1.0)
webbrowser.open(f"{scheme}://{host}:{port}")
webbrowser.open(f"http://{host}:{port}")
threading.Thread(target=_open, daemon=True).start()
print(f" Hermes Web UI → {scheme}://{host}:{port}")
if ssl_context is not None:
print(" mTLS enabled — client certificate required for A2A endpoints")
uvicorn.run(app, host=host, port=port, log_level="warning", ssl=ssl_context)
print(f" Hermes Web UI → http://{host}:{port}")
uvicorn.run(app, host=host, port=port, log_level="warning")

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""
Pre-commit hook: Reject hardcoded home-directory paths.
Scans staged Python files for patterns like:
- /Users/<name>/...
- /home/<name>/...
- ~/... (in string literals outside expanduser context)
Escape hatch: add `# noqa: hardcoded-path-ok` to any legitimate line.
Install:
cp hooks/pre-commit-path-guard.py .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
"""
import subprocess
import sys
from pathlib import Path
# Add project root to path so we can import path_guard
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tools.path_guard import scan_file_for_violations
def get_staged_files():
"""Get list of staged .py files."""
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
capture_output=True, text=True
)
return [f for f in result.stdout.strip().splitlines() if f.endswith(".py")]
def main():
files = get_staged_files()
if not files:
sys.exit(0)
all_violations = []
for filepath in files:
if not Path(filepath).exists():
continue
violations = scan_file_for_violations(filepath)
if violations:
all_violations.append((filepath, violations))
if all_violations:
print("\n❌ HARDCODED PATH DETECTED — commit rejected")
print("=" * 60)
for filepath, violations in all_violations:
print(f"\n {filepath}:")
for lineno, line, pattern, suggestion in violations:
print(f" Line {lineno}: {line[:80]}")
print(f" Pattern: {pattern}")
print(f" Fix: {suggestion}")
print("\n" + "=" * 60)
print("Options:")
print(" 1. Use get_hermes_home(), os.environ['HOME'], or relative paths")
print(" 2. Add # noqa: hardcoded-path-ok to the line for legitimate cases")
print("")
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -1302,9 +1302,9 @@ class TestConcurrentToolExecution:
mock_con.assert_not_called()
def test_malformed_json_args_forces_sequential(self, agent):
"""Non-dict tool arguments (e.g. JSON array) should fall back to sequential."""
"""Unparseable tool arguments should fall back to sequential."""
tc1 = _mock_tool_call(name="web_search", arguments='{}', call_id="c1")
tc2 = _mock_tool_call(name="web_search", arguments='[1, 2, 3]', call_id="c2")
tc2 = _mock_tool_call(name="web_search", arguments="NOT JSON {{{", call_id="c2")
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
messages = []
with patch.object(agent, "_execute_tool_calls_sequential") as mock_seq:
@@ -1384,9 +1384,10 @@ class TestConcurrentToolExecution:
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
messages = []
call_count = [0]
def fake_handle(name, args, task_id, **kwargs):
# Deterministic failure based on tool_call_id to avoid race conditions
if kwargs.get("tool_call_id") == "c1":
call_count[0] += 1
if call_count[0] == 1:
raise RuntimeError("boom")
return "success"

View File

@@ -416,219 +416,3 @@ class TestEdgeCases:
"""Verify max workers constant exists and is reasonable."""
from run_agent import _MAX_TOOL_WORKERS
assert 1 <= _MAX_TOOL_WORKERS <= 32
# ── Integration Tests: AIAgent Concurrent Execution ───────────────────────────
class TestAIAgentConcurrentExecution:
"""Exercise _execute_tool_calls_concurrent through an AIAgent instance."""
@pytest.fixture
def agent(self):
"""Minimal AIAgent with mocked OpenAI client and tool loading."""
from types import SimpleNamespace
from unittest.mock import patch
from run_agent import AIAgent
def _make_tool_defs(*names):
return [
{
"type": "function",
"function": {
"name": n,
"description": f"{n} tool",
"parameters": {"type": "object", "properties": {}},
},
}
for n in names
]
with (
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search", "read_file")),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("run_agent.OpenAI"),
):
a = AIAgent(
api_key="test-key-1234567890",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
a.client = MagicMock()
return a
def _mock_assistant_msg(self, tool_calls=None):
from types import SimpleNamespace
return SimpleNamespace(content="", tool_calls=tool_calls)
def _mock_tool_call(self, name, arguments, call_id):
from types import SimpleNamespace
return SimpleNamespace(
id=call_id,
type="function",
function=SimpleNamespace(name=name, arguments=json.dumps(arguments)),
)
def test_two_tool_batch_executes_concurrently(self, agent):
"""2-tool parallel batch: all execute, results ordered, 100% pass."""
tc1 = self._mock_tool_call("read_file", {"path": "a.txt"}, "c1")
tc2 = self._mock_tool_call("read_file", {"path": "b.txt"}, "c2")
mock_msg = self._mock_assistant_msg(tool_calls=[tc1, tc2])
messages = []
def fake_handle(name, args, task_id, **kwargs):
return json.dumps({"file": args.get("path", ""), "content": f"content_of_{args.get('path', '')}"})
with patch("run_agent.handle_function_call", side_effect=fake_handle):
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
assert len(messages) == 2
assert messages[0]["tool_call_id"] == "c1"
assert messages[1]["tool_call_id"] == "c2"
assert "a.txt" in messages[0]["content"]
assert "b.txt" in messages[1]["content"]
def test_three_tool_batch_executes_concurrently(self, agent):
"""3-tool parallel batch: all execute, results ordered, 100% pass."""
tcs = [
self._mock_tool_call("web_search", {"query": f"q{i}"}, f"c{i}")
for i in range(3)
]
mock_msg = self._mock_assistant_msg(tool_calls=tcs)
messages = []
def fake_handle(name, args, task_id, **kwargs):
return json.dumps({"query": args.get("query", ""), "results": [f"result_{args.get('query', '')}"]})
with patch("run_agent.handle_function_call", side_effect=fake_handle):
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
assert len(messages) == 3
for i, tc in enumerate(tcs):
assert messages[i]["tool_call_id"] == tc.id
assert f"q{i}" in messages[i]["content"]
def test_four_tool_batch_executes_concurrently(self, agent):
"""4-tool parallel batch: all execute, results ordered, 100% pass."""
tcs = [
self._mock_tool_call("read_file", {"path": f"file{i}.txt"}, f"c{i}")
for i in range(4)
]
mock_msg = self._mock_assistant_msg(tool_calls=tcs)
messages = []
def fake_handle(name, args, task_id, **kwargs):
return json.dumps({"path": args.get("path", ""), "size": 100})
with patch("run_agent.handle_function_call", side_effect=fake_handle):
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
assert len(messages) == 4
for i, tc in enumerate(tcs):
assert messages[i]["tool_call_id"] == tc.id
assert f"file{i}.txt" in messages[i]["content"]
def test_mixed_read_and_search_batch(self, agent):
"""read_file + search_files: safe parallel, different scopes."""
tc1 = self._mock_tool_call("read_file", {"path": "config.yaml"}, "c1")
tc2 = self._mock_tool_call("web_search", {"query": "provider"}, "c2")
mock_msg = self._mock_assistant_msg(tool_calls=[tc1, tc2])
messages = []
def fake_handle(name, args, task_id, **kwargs):
return json.dumps({"tool": name, "args": args})
with patch("run_agent.handle_function_call", side_effect=fake_handle):
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
assert len(messages) == 2
assert messages[0]["tool_call_id"] == "c1"
assert messages[1]["tool_call_id"] == "c2"
assert "config.yaml" in messages[0]["content"]
assert "provider" in messages[1]["content"]
def test_concurrent_pass_rate_report(self, agent):
"""Simulate 2/3/4-tool batches and report pass rate."""
batch_sizes = [2, 3, 4]
pass_rates = {}
for size in batch_sizes:
tcs = [
self._mock_tool_call("web_search", {"query": f"q{i}"}, f"c{i}")
for i in range(size)
]
mock_msg = self._mock_assistant_msg(tool_calls=tcs)
messages = []
def fake_handle(name, args, task_id, **kwargs):
return json.dumps({"ok": True, "query": args.get("query", "")})
with patch("run_agent.handle_function_call", side_effect=fake_handle):
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
passed = sum(1 for m in messages if "ok" in m.get("content", ""))
pass_rates[size] = passed / size if size > 0 else 0.0
for size, rate in pass_rates.items():
assert rate == 1.0, f"Expected 100% pass rate for {size}-tool batch, got {rate:.0%}"
def test_gemma4_style_two_read_files(self, agent):
"""Gemma 4 may issue two reads simultaneously — verify both returned."""
tc1 = self._mock_tool_call("read_file", {"path": "src/main.py"}, "c1")
tc2 = self._mock_tool_call("read_file", {"path": "src/utils.py"}, "c2")
mock_msg = self._mock_assistant_msg(tool_calls=[tc1, tc2])
messages = []
def fake_handle(name, args, task_id, **kwargs):
return json.dumps({"content": f"# {args['path']}\nprint('hello')"})
with patch("run_agent.handle_function_call", side_effect=fake_handle):
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
assert len(messages) == 2
assert "main.py" in messages[0]["content"]
assert "utils.py" in messages[1]["content"]
def test_gemma4_style_three_reads(self, agent):
"""Gemma 4 may issue 3 reads for different files — all returned."""
tcs = [
self._mock_tool_call("read_file", {"path": f"mod{i}.py"}, f"c{i}")
for i in range(3)
]
mock_msg = self._mock_assistant_msg(tool_calls=tcs)
messages = []
def fake_handle(name, args, task_id, **kwargs):
return json.dumps({"content": f"# {args['path']}"})
with patch("run_agent.handle_function_call", side_effect=fake_handle):
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
assert len(messages) == 3
for i in range(3):
assert f"mod{i}.py" in messages[i]["content"]
def test_mixed_safe_and_write_tools_parallel(self, agent):
"""Mix of read (safe) and write (path-scoped) on different paths — parallel."""
tc1 = self._mock_tool_call("read_file", {"path": "input.txt"}, "c1")
tc2 = self._mock_tool_call("write_file", {"path": "output.txt", "content": "x"}, "c2")
tc3 = self._mock_tool_call("read_file", {"path": "config.txt"}, "c3")
mock_msg = self._mock_assistant_msg(tool_calls=[tc1, tc2, tc3])
messages = []
call_order = []
def fake_handle(name, args, task_id, **kwargs):
call_order.append(name)
return json.dumps({"tool": name, "path": args.get("path", "")})
with patch("run_agent.handle_function_call", side_effect=fake_handle):
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
assert len(messages) == 3
# Results ordered by tool call ID, not completion order
assert messages[0]["tool_call_id"] == "c1"
assert messages[1]["tool_call_id"] == "c2"
assert messages[2]["tool_call_id"] == "c3"
# All three should have executed
assert len(call_order) == 3

127
tests/test_path_guard.py Normal file
View File

@@ -0,0 +1,127 @@
"""Tests for tools/path_guard.py — poka-yoke hardcoded path detection."""
import os
import tempfile
from pathlib import Path
import pytest
from tools.path_guard import (
PathGuardError,
scan_directory,
scan_file_for_violations,
validate_path,
validate_tool_paths,
)
class TestValidatePath:
"""Runtime path validation."""
def test_valid_relative_path(self):
assert validate_path("tools/file_tools.py") == "tools/file_tools.py"
def test_valid_absolute_path(self):
assert validate_path("/tmp/test.txt") == "/tmp/test.txt"
def test_valid_hermes_home(self):
assert validate_path(os.path.expanduser("~/.hermes/config.yaml")) is not None
def test_reject_users_hardcoded(self):
with pytest.raises(PathGuardError, match="/Users/"):
validate_path("/Users/someone_else/.hermes/config")
def test_reject_home_hardcoded(self):
with pytest.raises(PathGuardError, match="/home/"):
validate_path("/home/user/.hermes/config")
def test_empty_path(self):
assert validate_path("") == ""
assert validate_path(None) is None
def test_non_string(self):
assert validate_path(42) == 42
class TestValidateToolPaths:
"""Batch path validation."""
def test_all_valid(self):
paths = ["tools/file.py", "/tmp/x.txt", "relative/path.py"]
assert validate_tool_paths(paths) == paths
def test_mixed_invalid(self):
with pytest.raises(PathGuardError):
validate_tool_paths(["tools/file.py", "/Users/someone_else/secret.txt"])
def test_skips_non_strings(self):
assert validate_tool_paths([None, 42, "valid.py"]) == ["valid.py"]
class TestScanFileForViolations:
"""Static file scanning."""
def test_clean_file(self, tmp_path):
f = tmp_path / "clean.py"
f.write_text("import os\nHOME = os.environ['HOME']\n")
assert scan_file_for_violations(str(f)) == []
def test_hardcoded_users(self, tmp_path):
f = tmp_path / "bad.py"
f.write_text("CONFIG = '/Users/apayne/.hermes/config.yaml'\n")
violations = scan_file_for_violations(str(f))
assert len(violations) == 1
assert "/Users/<name>/" in violations[0][2]
def test_hardcoded_home(self, tmp_path):
f = tmp_path / "bad2.py"
f.write_text("PATH = '/home/deploy/.hermes/state.db'\n")
violations = scan_file_for_violations(str(f))
assert len(violations) == 1
assert "/home/<name>/" in violations[0][2]
def test_tilde_in_expanduser_ok(self, tmp_path):
f = tmp_path / "ok.py"
f.write_text("p = os.path.expanduser('~/.hermes/config')\n")
assert scan_file_for_violations(str(f)) == []
def test_tilde_in_display_ok(self, tmp_path):
f = tmp_path / "ok2.py"
f.write_text('print("~/config saved")\n')
assert scan_file_for_violations(str(f)) == []
def test_noqa_escape(self, tmp_path):
f = tmp_path / "noqa.py"
f.write_text("PATH = '/Users/apayne/test' # noqa: hardcoded-path-ok\n")
assert scan_file_for_violations(str(f)) == []
def test_comments_skipped(self, tmp_path):
f = tmp_path / "comment.py"
f.write_text("# PATH = '/Users/apayne/test'\n")
assert scan_file_for_violations(str(f)) == []
class TestScanDirectory:
"""Directory scanning."""
def test_clean_tree(self, tmp_path):
(tmp_path / "clean.py").write_text("import os\n")
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "also_clean.py").write_text("x = 1\n")
assert scan_directory(str(tmp_path)) == []
def test_finds_violations(self, tmp_path):
(tmp_path / "bad.py").write_text("P = '/Users/x/.hermes'\n")
results = scan_directory(str(tmp_path))
assert len(results) == 1
assert results[0][0].endswith("bad.py")
def test_skips_tests(self, tmp_path):
(tmp_path / "test_something.py").write_text("P = '/Users/x/.hermes'\n")
assert scan_directory(str(tmp_path)) == []
def test_skips_pycache(self, tmp_path):
cache = tmp_path / "__pycache__"
cache.mkdir()
(cache / "cached.py").write_text("P = '/Users/x/.hermes'\n")
assert scan_directory(str(tmp_path)) == []

165
tools/path_guard.py Normal file
View File

@@ -0,0 +1,165 @@
"""
tools/path_guard.py — Poka-yoke: Prevent hardcoded home-directory paths.
Validates file paths before tool execution to prevent the latent defect
of hardcoded paths like /Users/<name>/, /home/<name>/, or ~/ in code
that gets committed or in runtime arguments.
Usage:
from tools.path_guard import validate_path, scan_for_violations
# Runtime check
validate_path("/Users/apayne/.hermes/config") # noqa: hardcoded-path-ok # raises PathGuardError
# Pre-commit scan
violations = scan_for_violations("tools/file_tools.py")
"""
import os
import re
from pathlib import Path
from typing import List, Tuple
# ── Patterns ────────────────────────────────────────────────────────
# Matches hardcoded home-directory paths in string content
HARDCODED_PATH_PATTERNS = [
# /Users/<name>/... (macOS)
(re.compile(r"""['"]/(Users)/[\w.-]+/"""), "/Users/<name>/"),
# /home/<name>/... (Linux)
(re.compile(r"""['"]/home/[\w.-]+/"""), "/home/<name>/"),
# Bare ~/... (unexpanded tilde in code — NOT in expanduser() calls)
(re.compile(r"""['"]~/[^'"]+['"]"""), "~/..."), # noqa: hardcoded-path-ok
# /root/... (Linux root home)
(re.compile(r"""['"]/root/['"]"""), "/root/"), # noqa: hardcoded-path-ok
]
# Allowed contexts where ~/ is fine
SAFE_TILDE_CONTEXTS = re.compile(
r"""expanduser|display_path|relpath|os\.path|Path\(|str\(.*home|"""
r"""noqa:\s*hardcoded-path-ok|""" # explicit escape hatch
r"""\bprint\(|f['"]|\.format\(|""" # display/formatting contexts
r"""["']~/["']\s*$""", # just displaying ~/ as prefix
re.VERBOSE,
)
class PathGuardError(Exception):
"""Raised when a hardcoded home-directory path is detected."""
def __init__(self, path: str, pattern_name: str, suggestion: str):
self.path = path
self.pattern_name = pattern_name
self.suggestion = suggestion
super().__init__(
f"Hardcoded path detected: {path} matches {pattern_name}. "
f"Suggestion: {suggestion}. "
f"Use get_hermes_home(), os.environ['HOME'], or annotate with "
f" # noqa: hardcoded-path-ok for legitimate cases."
)
# ── Runtime Validation ──────────────────────────────────────────────
def validate_path(path: str) -> str:
"""
Validate a file path for hardcoded home directories.
Returns the path if valid, raises PathGuardError if not.
This is meant to be called in tool wrappers (write_file, execute_code)
before executing operations with user-supplied paths.
Note: At runtime, paths from os.path.expanduser() will resolve to
/Users/<name>/... — this is expected and allowed. The guard catches
paths that were LITERALLY hardcoded in source code or tool arguments
that look like they came from a different machine (e.g., a path
containing a different username than the current user).
"""
if not path or not isinstance(path, str):
return path
# At runtime, expanded paths matching current HOME are fine
home = os.environ.get("HOME", "")
if home and path.startswith(home):
return path
# Check for hardcoded /Users/<name>/ (macOS) — but not current user
if re.match(r"^/Users/[\w.-]+/", path):
raise PathGuardError(
path, "/Users/<name>/",
f"Use $HOME or os.path.expanduser('~') instead. "
f"Got: {path}"
)
# Check for hardcoded /home/<name>/ (Linux)
if re.match(r"^/home/[\w.-]+/", path):
raise PathGuardError(
path, "/home/<name>/",
f"Use $HOME or os.path.expanduser('~') instead. "
f"Got: {path}"
)
return path
def validate_tool_paths(paths: list) -> list:
"""
Validate multiple paths (e.g., from tool arguments).
Returns validated list. Raises PathGuardError on first violation.
"""
return [validate_path(p) for p in paths if isinstance(p, str)]
# ── File Scanning (Pre-commit / CI) ────────────────────────────────
def scan_file_for_violations(filepath: str) -> List[Tuple[int, str, str, str]]:
"""
Scan a Python file for hardcoded home-directory path patterns.
Returns list of (line_number, line_content, pattern_name, suggestion).
"""
violations = []
try:
with open(filepath) as f:
for lineno, line in enumerate(f, 1):
# Skip comments and noqa lines
stripped = line.strip()
if stripped.startswith("#"):
continue
if "noqa: hardcoded-path-ok" in line:
continue
for pattern, name in HARDCODED_PATH_PATTERNS:
if pattern.search(line):
# Special case: ~/ in expanduser/display context is OK
if name == "~/..." and SAFE_TILDE_CONTEXTS.search(line): # noqa: hardcoded-path-ok
continue
violations.append((lineno, line.rstrip(), name,
f"Use get_hermes_home(), os.environ['HOME'], or add # noqa: hardcoded-path-ok"))
except (IOError, UnicodeDecodeError):
pass
return violations
def scan_directory(root: str, extensions: tuple = (".py",)) -> List[Tuple[str, List]]:
"""
Scan a directory tree for hardcoded path violations.
Returns list of (filepath, violations) tuples.
"""
results = []
for dirpath, _, filenames in os.walk(root):
# Skip hidden dirs, __pycache__, venv, test dirs
skip_dirs = {"__pycache__", ".git", "venv", "node_modules", ".hermes"}
if any(s in dirpath for s in skip_dirs):
continue
for fname in filenames:
if not fname.endswith(extensions):
continue
# Skip test files (they may legitimately have paths)
if fname.startswith("test_") or "/tests/" in dirpath:
continue
fpath = os.path.join(dirpath, fname)
violations = scan_file_for_violations(fpath)
if violations:
results.append((fpath, violations))
return results