From 3c4be45d34180b48ab2357d74942e0c7fe86babe Mon Sep 17 00:00:00 2001 From: oyasumi Date: Sat, 8 Aug 2026 17:57:08 +0000 Subject: [PATCH] feat(safety): collect target-list files passed via -w/-l flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recon tools route their target list through a flag — `ffuf -w wordlist.txt`, `httpx -l hosts.txt`, `nuclei --list targets.txt` — not the `<` redirect the input-file collector already handled, so the reviewer kept blocking "probes every host listed in hosts.txt" because the list was never in the packet. Parse the value of the common list-file flags and collect it the same bounded way, alongside redirect inputs. The value is only read when it resolves to a workspace file, so a boolean `-l` (grep, wc) whose next token is not a file collects nothing and never makes the packet incomplete. Co-Authored-By: Claude Opus 5 (1M context) --- docs/usage/safety-modes.mdx | 13 ++++++----- strix/safety/evidence.py | 41 +++++++++++++++++++++++++++++++++ tests/test_safety_evidence.py | 43 +++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/docs/usage/safety-modes.mdx b/docs/usage/safety-modes.mdx index 91a763f8..6aadb389 100644 --- a/docs/usage/safety-modes.mdx +++ b/docs/usage/safety-modes.mdx @@ -82,12 +82,13 @@ A command that runs code Strix cannot resolve to an inspectable script — an unrecognized interpreter, or an interpreter given no script — is blocked rather than reviewed against an empty evidence packet. -When a command reads a workspace data file through input redirection (for -example a host list consumed by `while read … done < hosts.txt`), that file's -contents are attached to the packet so the reviewer can check the entries — -queried hosts, fuzz inputs — against scope instead of blocking because it can't -see them. Only workspace-resident files are read; an oversize file is attached -truncated. An authorized domain covers its subdomains. +When a command reads a workspace data file — through input redirection +(`while read … done < hosts.txt`) or a target-list flag (`ffuf -w words.txt`, +`httpx -l hosts.txt`) — that file's contents are attached to the packet so the +reviewer can check the entries, queried hosts or fuzz inputs, against scope +instead of blocking because it can't see them. Only workspace-resident files are +read; an oversize file is attached truncated. An authorized domain covers its +subdomains. Browser automation inside scripts is blocked in safety modes. Issue browser operations as individual raw `agent-browser` commands so each action can be diff --git a/strix/safety/evidence.py b/strix/safety/evidence.py index 7dbc59f3..7c5abd19 100644 --- a/strix/safety/evidence.py +++ b/strix/safety/evidence.py @@ -33,6 +33,24 @@ _SHELL_SEPARATOR_CHARS = frozenset(";&|\n\r") _REDIRECT_INPUT_RE = re.compile( r"""(?"[^"]+"|'[^']+'|[^\s;|&<>()]+)""", ) +# Flags whose value is a file of targets a command reads — wordlists, host lists. +# Recon tools (ffuf, httpx, nuclei, subfinder, dnsx, gobuster) route their input this +# way rather than through a `<` redirect, so the same evidence must be collected. The +# value is only read when it resolves to a workspace file, so a boolean `-l` (wc, grep) +# whose next token is not a file collects nothing. +_LIST_FILE_FLAGS = frozenset( + { + "-w", + "-l", + "-iL", + "-list", + "-wordlist", + "--list", + "--wordlist", + "--input-file", + "--input", + } +) _SCRIPT_SUFFIXES = (".py", ".sh", ".bash", ".js", ".mjs", ".rb", ".pl") # `awk` is intentionally absent: its program is an inline positional argument, not a # `-c`/script-file the entrypoint reader can resolve, so it belongs with the data tools. @@ -810,6 +828,9 @@ def parse_command(command: str) -> CommandPlan: executable = PurePosixPath(tokens[index]).name args = tokens[index + 1 :] plan.executable = executable + for candidate in _list_flag_files(args): + if candidate not in plan.input_files: + plan.input_files.append(candidate) if executable in _OPAQUE_WRAPPERS: plan.parse_error = ( @@ -980,6 +1001,26 @@ def _redirect_input_files(command: str) -> list[str]: return files +def _list_flag_files(args: list[str]) -> list[str]: + """Files named as the value of a target-list flag (`-w wordlist`, `-l hosts`).""" + files: list[str] = [] + index = 0 + while index < len(args): + name, separator, inline = args[index].partition("=") + if name in _LIST_FILE_FLAGS: + if separator: + value = inline + elif index + 1 < len(args): + value = args[index + 1] + index += 1 + else: + value = "" + if value and not value.startswith("-") and value not in files: + files.append(value) + index += 1 + return files + + async def _read_sandbox_file(session: Any, path: PurePosixPath, limit: int) -> bytes: stream = await session.read(Path(path.as_posix())) try: diff --git a/tests/test_safety_evidence.py b/tests/test_safety_evidence.py index 0464bea3..2032edef 100644 --- a/tests/test_safety_evidence.py +++ b/tests/test_safety_evidence.py @@ -916,3 +916,46 @@ def test_data_tools_reading_script_named_files_are_not_execution(command: str) - """A read/transfer/text tool takes a script-named file as data, not as a program to run, so it must not trip the unresolved-execution guard.""" assert parse_command(command).parse_error is None + + +@pytest.mark.parametrize( + ("command", "expected"), + [ + ("ffuf -w /workspace/words.txt -u https://x/FUZZ", ["/workspace/words.txt"]), + ("httpx -l hosts.txt -sc -title", ["hosts.txt"]), + ("nuclei --list targets.txt -severity high", ["targets.txt"]), + ("ffuf -w=words.txt -u https://x/FUZZ", ["words.txt"]), + ("subfinder -d x -o out.txt", []), # -o is output, not a list input + ], +) +def test_list_flag_files_are_parsed(command: str, expected: list[str]) -> None: + assert parse_command(command).input_files == expected + + +@pytest.mark.asyncio +async def test_wordlist_flag_file_is_attached_for_scope_review() -> None: + """Recon tools route their target list through `-w`/`-l`, not a `<` redirect, so the + same evidence must be collected for the reviewer to check it against scope.""" + bundle = await _compile( + "ffuf -w /workspace/paths.txt -u https://api.fiuu.com/FUZZ -mc all", + {"/workspace/paths.txt": "admin\napi\nlogin\n"}, + workdir="/workspace", + ) + try: + inputs = [a for a in bundle.packet["artifacts"] if a.get("role") == "input"] + assert [a["path"] for a in inputs] == ["/workspace/paths.txt"] + assert "admin" in inputs[0]["source"] + finally: + bundle.cleanup() + + +@pytest.mark.asyncio +async def test_list_flag_value_that_is_not_a_workspace_file_collects_nothing() -> None: + """A boolean `-l` (grep, wc) whose next token is not a workspace file must not make + the packet incomplete or attach anything.""" + bundle = await _compile("grep -l pattern /workspace/app.py", workdir="/workspace") + try: + assert [a for a in bundle.packet["artifacts"] if a.get("role") == "input"] == [] + assert bundle.deterministic_block is None + finally: + bundle.cleanup()