From a898e6890c2f8b9ca519bf66675edd26294d437a Mon Sep 17 00:00:00 2001 From: himanshupatro-334 Date: Sat, 26 Sep 2026 18:06:45 +0530 Subject: [PATCH] fix(skillgen): prevent monolith watch path injection --- graphify/skill-aider.md | 12 +- graphify/skill-devin.md | 12 +- tests/test_skillgen_input_path_injection.py | 154 +++++++++++++++--- .../expected/graphify__skill-aider.md | 12 +- .../expected/graphify__skill-devin.md | 12 +- tools/skillgen/fragments/core/aider.md | 12 +- tools/skillgen/fragments/core/devin.md | 12 +- tools/skillgen/gen.py | 40 +++++ 8 files changed, 229 insertions(+), 37 deletions(-) diff --git a/graphify/skill-aider.md b/graphify/skill-aider.md index 4996beb78..f156f1298 100644 --- a/graphify/skill-aider.md +++ b/graphify/skill-aider.md @@ -90,6 +90,14 @@ fi # Write interpreter path for all subsequent steps (persists across invocations) mkdir -p graphify-out "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +# Save scan root so `graphify update` (no args) knows where to look next time. +# INPUT_PATH is passed through a quoted heredoc, never substituted into the +# command line itself: a bare `cd INPUT_PATH` (or an unquoted heredoc, which +# still expands $()/backticks in its body) would let a malicious path execute +# as shell code the moment this line runs. +"$PYTHON" -c "import os, sys; out_path = os.path.abspath('graphify-out/.graphify_root'); os.chdir(sys.stdin.readline().rstrip('\n')); open(out_path, 'w', encoding='utf-8').write(os.getcwd())" <<'GRAPHIFY_ROOT_EOF' +INPUT_PATH +GRAPHIFY_ROOT_EOF ``` If the import succeeds, print nothing and move straight to Step 2. @@ -1226,10 +1234,10 @@ Supported URL types (auto-detected): Start a background watcher that monitors a folder and auto-updates the graph when files change. ```bash -python3 -m graphify.watch INPUT_PATH --debounce 3 +$(cat graphify-out/.graphify_python) -m graphify.watch "$(cat graphify-out/.graphify_root)" --debounce 3 ``` -Replace INPUT_PATH with the folder to watch. Behavior depends on what changed: +This watches the same folder graphify extracted, read from the trusted `graphify-out/.graphify_root` that Step 1 resolved - there is no path to substitute, so a scan root containing shell metacharacters can never be re-interpreted here. Behavior depends on what changed: - **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically. - **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required). diff --git a/graphify/skill-devin.md b/graphify/skill-devin.md index f9be846cb..0f256842e 100644 --- a/graphify/skill-devin.md +++ b/graphify/skill-devin.md @@ -103,6 +103,14 @@ mkdir -p graphify-out "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" # Force UTF-8 I/O on Windows (prevents garbled CJK/non-ASCII output) export PYTHONUTF8=1 +# Save scan root so `graphify update` (no args) knows where to look next time. +# INPUT_PATH is passed through a quoted heredoc, never substituted into the +# command line itself: a bare `cd INPUT_PATH` (or an unquoted heredoc, which +# still expands $()/backticks in its body) would let a malicious path execute +# as shell code the moment this line runs. +"$PYTHON" -c "import os, sys; out_path = os.path.abspath('graphify-out/.graphify_root'); os.chdir(sys.stdin.readline().rstrip('\n')); open(out_path, 'w', encoding='utf-8').write(os.getcwd())" <<'GRAPHIFY_ROOT_EOF' +INPUT_PATH +GRAPHIFY_ROOT_EOF ``` If the import succeeds, print nothing and move straight to Step 2. @@ -1358,10 +1366,10 @@ Supported URL types (auto-detected): Start a background watcher that monitors a folder and auto-updates the graph when files change. ```bash -python3 -m graphify.watch INPUT_PATH --debounce 3 +$(cat graphify-out/.graphify_python) -m graphify.watch "$(cat graphify-out/.graphify_root)" --debounce 3 ``` -Replace INPUT_PATH with the folder to watch. Behavior depends on what changed: +This watches the same folder graphify extracted, read from the trusted `graphify-out/.graphify_root` that Step 1 resolved - there is no path to substitute, so a scan root containing shell metacharacters can never be re-interpreted here. Behavior depends on what changed: - **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically. - **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required). diff --git a/tests/test_skillgen_input_path_injection.py b/tests/test_skillgen_input_path_injection.py index babb4e656..35933763c 100644 --- a/tests/test_skillgen_input_path_injection.py +++ b/tests/test_skillgen_input_path_injection.py @@ -1,18 +1,17 @@ -"""#3642: Step 1's INPUT_PATH substitution into a bash command is a command +"""#3642, #3742, #3844: INPUT_PATH substitution into bash commands is a command injection vector. -INPUT_PATH is a literal placeholder in every generated skill file, meant to +INPUT_PATH is a literal placeholder in generated skill files, meant to be substituted by the agent following the instructions with the resolved -scan path before it runs the bash block. A malicious or merely -untrusted-source path substituted into `echo "$(cd INPUT_PATH && pwd)" ...` -executed as shell code the moment Step 1 ran, before any Python code was -ever reached -- the outer double quotes do not stop `$()`/backtick -expansion, and the bare `cd INPUT_PATH` was itself unquoted. +scan path before it runs bash blocks. A malicious or merely +untrusted-source path substituted into an unquoted shell command line +executes as shell code the moment the line runs, before any Python code +is reached. -These tests extract the actual Step 1 bash block from a committed, -generated skill file, substitute INPUT_PATH the way an agent would, and -execute it for real, so the fix is verified against the artifact users -actually receive rather than against the fragment source alone. +These tests extract the actual Step 1 and --watch bash blocks from committed, +generated skill files (including the Aider and Devin monoliths), verify that +the artifacts users actually receive are safe against hostile inputs, and +execute Step 1 with hostile paths to prove no injected commands run. """ from __future__ import annotations @@ -27,14 +26,28 @@ import pytest REPO_ROOT = Path(__file__).resolve().parent.parent SKILL_MD = REPO_ROOT / "graphify" / "skill.md" +MONOLITH_SKILL_FILES = ("skill-aider.md", "skill-devin.md") +ALL_TESTED_SKILL_FILES = ("skill.md", "skill-aider.md", "skill-devin.md") -def _extract_step1_bash_block() -> str: - text = SKILL_MD.read_text(encoding="utf-8") + +def _extract_step1_bash_block(filename: str = "skill.md") -> str: + text = (REPO_ROOT / "graphify" / filename).read_text(encoding="utf-8") for match in re.finditer(r"```bash\n(.*?)\n```", text, re.DOTALL): block = match.group(1) if "graphify_root" in block: return block - raise AssertionError("could not find the Step 1 bash block in skill.md") + raise AssertionError(f"could not find the Step 1 bash block in {filename}") + + +def _extract_watch_bash_block(filename: str) -> str: + text = (REPO_ROOT / "graphify" / filename).read_text(encoding="utf-8") + m = re.search(r"## For --watch.*?(```bash\n.*?\n```)", text, re.DOTALL) + if not m: + raise AssertionError(f"could not find the --watch section in {filename}") + block = re.search(r"```bash\n(.*?)\n```", m.group(1), re.DOTALL) + if not block: + raise AssertionError(f"could not find the --watch bash block in {filename}") + return block.group(1).strip() @pytest.fixture() @@ -43,7 +56,22 @@ def step1_script() -> str: def _run_step1(script: str, input_path_value: str, cwd: Path) -> subprocess.CompletedProcess: - substituted = script.replace("INPUT_PATH", input_path_value) + if sys.platform == "win32" and len(input_path_value) > 2 and input_path_value[1] == ":": + # Convert Windows absolute path (C:\...) to WSL mount path (/mnt/c/...) + drive = input_path_value[0].lower() + rest = input_path_value[2:].replace("\\", "/") + input_path_value = f"/mnt/{drive}{rest}" + substituted = script.replace("INPUT_PATH", input_path_value).replace("\r\n", "\n") + if sys.platform == "win32": + # On Windows, writing to a temp script file avoids CreateProcess quote-escaping + # mangling double quotes inside `bash -c "..."`. Must use LF newlines for bash. + script_file = cwd / "_run_step1.sh" + script_file.write_text(substituted, encoding="utf-8", newline="\n") + return subprocess.run( + ["bash", "_run_step1.sh"], + cwd=cwd, capture_output=True, text=True, + env={**os.environ, "PATH": os.environ.get("PATH", "")}, + ) return subprocess.run( ["bash", "-c", substituted], cwd=cwd, capture_output=True, text=True, @@ -51,7 +79,69 @@ def _run_step1(script: str, input_path_value: str, cwd: Path) -> subprocess.Comp ) -def test_step1_does_not_execute_a_command_substitution_in_input_path(tmp_path): +# --- #3844: Monolith --watch contract tests ---------------------------------- + + +@pytest.mark.parametrize("skill_file", MONOLITH_SKILL_FILES) +def test_monolith_watch_does_not_contain_raw_input_path_placeholder(skill_file: str): + """The monolith --watch block must not interpolate raw INPUT_PATH into shell code.""" + block = _extract_watch_bash_block(skill_file) + assert "INPUT_PATH" not in block, ( + f"{skill_file} --watch command still contains raw INPUT_PATH placeholder: {block!r}" + ) + + +@pytest.mark.parametrize("skill_file", MONOLITH_SKILL_FILES) +def test_monolith_watch_uses_trusted_graphify_root(skill_file: str): + """The monolith --watch block must read from trusted .graphify_root and .graphify_python.""" + block = _extract_watch_bash_block(skill_file) + assert "graphify-out/.graphify_root" in block, ( + f"{skill_file} --watch command does not reference graphify-out/.graphify_root" + ) + assert "graphify-out/.graphify_python" in block, ( + f"{skill_file} --watch command does not reference graphify-out/.graphify_python" + ) + assert block == ( + '$(cat graphify-out/.graphify_python) -m graphify.watch ' + '"$(cat graphify-out/.graphify_root)" --debounce 3' + ) + + +# --- Step 1 hostile INPUT_PATH injection resistance -------------------------- + + +HOSTILE_PAYLOADS = [ + ("cmd_subst", lambda s: f"$(touch {s})"), + ("backticks", lambda s: f"`touch {s}`"), + ("semicolon", lambda s: f"nonexistent; touch {s} #"), + ("and_chain", lambda s: f"nonexistent && touch {s}"), + ("pipe_chain", lambda s: f"nonexistent | touch {s}"), + ("newline", lambda s: f"nonexistent\ntouch {s}\n"), +] + + +@pytest.mark.parametrize("skill_file", ALL_TESTED_SKILL_FILES) +@pytest.mark.parametrize("attack_name,payload_fn", HOSTILE_PAYLOADS) +def test_step1_does_not_execute_hostile_input_path( + tmp_path: Path, skill_file: str, attack_name: str, payload_fn +): + """A hostile INPUT_PATH containing shell metacharacters must never execute.""" + script = _extract_step1_bash_block(skill_file) + sentinel = tmp_path / f"PWNED_{attack_name}_{skill_file.replace('.', '_')}" + malicious = payload_fn(sentinel) + + _run_step1(script, malicious, cwd=tmp_path) + + assert not sentinel.exists(), ( + f"hostile {attack_name} inside substituted INPUT_PATH for {skill_file} " + f"must never execute as shell code" + ) + + +# --- Backwards-compatible legacy test entry points --------------------------- + + +def test_step1_does_not_execute_a_command_substitution_in_input_path(tmp_path: Path): """A malicious path containing $(...) must never run as shell code.""" script = _extract_step1_bash_block() sentinel = tmp_path / "PWNED" @@ -65,7 +155,7 @@ def test_step1_does_not_execute_a_command_substitution_in_input_path(tmp_path): ) -def test_step1_does_not_execute_a_semicolon_separated_command_in_input_path(tmp_path): +def test_step1_does_not_execute_a_semicolon_separated_command_in_input_path(tmp_path: Path): """A malicious path using `;` to chain a second command must never run.""" script = _extract_step1_bash_block() sentinel = tmp_path / "PWNED2" @@ -79,26 +169,40 @@ def test_step1_does_not_execute_a_semicolon_separated_command_in_input_path(tmp_ ) -def test_step1_still_resolves_a_legitimate_path(tmp_path): - """The fix must not break the ordinary, non-malicious case.""" - script = _extract_step1_bash_block() - project = tmp_path / "my project" +# --- Legitimate path handling & non-existent path failure --------------------- + + +@pytest.mark.parametrize("skill_file", ALL_TESTED_SKILL_FILES) +def test_step1_still_resolves_a_legitimate_path(tmp_path: Path, skill_file: str): + """The fix must not break ordinary paths, including paths with spaces.""" + script = _extract_step1_bash_block(skill_file) + project = tmp_path / "my project with spaces" project.mkdir() result = _run_step1(script, str(project), cwd=tmp_path) marker = tmp_path / "graphify-out" / ".graphify_root" assert marker.exists(), ( - f"a legitimate path must still be resolved and written; " + f"a legitimate path must still be resolved and written for {skill_file}; " f"stdout={result.stdout!r} stderr={result.stderr!r}" ) - assert marker.read_text(encoding="utf-8") == str(project.resolve()) + marker_content = marker.read_text(encoding="utf-8").strip() + drive_letter = project.drive[0].lower() + if sys.platform == "win32" and marker_content.startswith(f"/mnt/{drive_letter}/"): + resolved_marker = Path(f"{drive_letter.upper()}:{marker_content[6:]}").resolve() + elif sys.platform == "win32" and marker_content.startswith(f"/{drive_letter}/"): + resolved_marker = Path(f"{drive_letter.upper()}:{marker_content[2:]}").resolve() + else: + resolved_marker = Path(marker_content).resolve() + assert resolved_marker == project.resolve() + assert marker_content.endswith("my project with spaces") -def test_step1_still_fails_loudly_on_a_nonexistent_path(tmp_path): +@pytest.mark.parametrize("skill_file", ALL_TESTED_SKILL_FILES) +def test_step1_still_fails_loudly_on_a_nonexistent_path(tmp_path: Path, skill_file: str): """A path that does not exist must still fail, matching the original `cd INPUT_PATH` behavior, not silently write a bogus marker.""" - script = _extract_step1_bash_block() + script = _extract_step1_bash_block(skill_file) result = _run_step1(script, "does/not/exist", cwd=tmp_path) diff --git a/tools/skillgen/expected/graphify__skill-aider.md b/tools/skillgen/expected/graphify__skill-aider.md index 4996beb78..f156f1298 100644 --- a/tools/skillgen/expected/graphify__skill-aider.md +++ b/tools/skillgen/expected/graphify__skill-aider.md @@ -90,6 +90,14 @@ fi # Write interpreter path for all subsequent steps (persists across invocations) mkdir -p graphify-out "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +# Save scan root so `graphify update` (no args) knows where to look next time. +# INPUT_PATH is passed through a quoted heredoc, never substituted into the +# command line itself: a bare `cd INPUT_PATH` (or an unquoted heredoc, which +# still expands $()/backticks in its body) would let a malicious path execute +# as shell code the moment this line runs. +"$PYTHON" -c "import os, sys; out_path = os.path.abspath('graphify-out/.graphify_root'); os.chdir(sys.stdin.readline().rstrip('\n')); open(out_path, 'w', encoding='utf-8').write(os.getcwd())" <<'GRAPHIFY_ROOT_EOF' +INPUT_PATH +GRAPHIFY_ROOT_EOF ``` If the import succeeds, print nothing and move straight to Step 2. @@ -1226,10 +1234,10 @@ Supported URL types (auto-detected): Start a background watcher that monitors a folder and auto-updates the graph when files change. ```bash -python3 -m graphify.watch INPUT_PATH --debounce 3 +$(cat graphify-out/.graphify_python) -m graphify.watch "$(cat graphify-out/.graphify_root)" --debounce 3 ``` -Replace INPUT_PATH with the folder to watch. Behavior depends on what changed: +This watches the same folder graphify extracted, read from the trusted `graphify-out/.graphify_root` that Step 1 resolved - there is no path to substitute, so a scan root containing shell metacharacters can never be re-interpreted here. Behavior depends on what changed: - **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically. - **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required). diff --git a/tools/skillgen/expected/graphify__skill-devin.md b/tools/skillgen/expected/graphify__skill-devin.md index f9be846cb..0f256842e 100644 --- a/tools/skillgen/expected/graphify__skill-devin.md +++ b/tools/skillgen/expected/graphify__skill-devin.md @@ -103,6 +103,14 @@ mkdir -p graphify-out "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" # Force UTF-8 I/O on Windows (prevents garbled CJK/non-ASCII output) export PYTHONUTF8=1 +# Save scan root so `graphify update` (no args) knows where to look next time. +# INPUT_PATH is passed through a quoted heredoc, never substituted into the +# command line itself: a bare `cd INPUT_PATH` (or an unquoted heredoc, which +# still expands $()/backticks in its body) would let a malicious path execute +# as shell code the moment this line runs. +"$PYTHON" -c "import os, sys; out_path = os.path.abspath('graphify-out/.graphify_root'); os.chdir(sys.stdin.readline().rstrip('\n')); open(out_path, 'w', encoding='utf-8').write(os.getcwd())" <<'GRAPHIFY_ROOT_EOF' +INPUT_PATH +GRAPHIFY_ROOT_EOF ``` If the import succeeds, print nothing and move straight to Step 2. @@ -1358,10 +1366,10 @@ Supported URL types (auto-detected): Start a background watcher that monitors a folder and auto-updates the graph when files change. ```bash -python3 -m graphify.watch INPUT_PATH --debounce 3 +$(cat graphify-out/.graphify_python) -m graphify.watch "$(cat graphify-out/.graphify_root)" --debounce 3 ``` -Replace INPUT_PATH with the folder to watch. Behavior depends on what changed: +This watches the same folder graphify extracted, read from the trusted `graphify-out/.graphify_root` that Step 1 resolved - there is no path to substitute, so a scan root containing shell metacharacters can never be re-interpreted here. Behavior depends on what changed: - **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically. - **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required). diff --git a/tools/skillgen/fragments/core/aider.md b/tools/skillgen/fragments/core/aider.md index 4996beb78..f156f1298 100644 --- a/tools/skillgen/fragments/core/aider.md +++ b/tools/skillgen/fragments/core/aider.md @@ -90,6 +90,14 @@ fi # Write interpreter path for all subsequent steps (persists across invocations) mkdir -p graphify-out "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +# Save scan root so `graphify update` (no args) knows where to look next time. +# INPUT_PATH is passed through a quoted heredoc, never substituted into the +# command line itself: a bare `cd INPUT_PATH` (or an unquoted heredoc, which +# still expands $()/backticks in its body) would let a malicious path execute +# as shell code the moment this line runs. +"$PYTHON" -c "import os, sys; out_path = os.path.abspath('graphify-out/.graphify_root'); os.chdir(sys.stdin.readline().rstrip('\n')); open(out_path, 'w', encoding='utf-8').write(os.getcwd())" <<'GRAPHIFY_ROOT_EOF' +INPUT_PATH +GRAPHIFY_ROOT_EOF ``` If the import succeeds, print nothing and move straight to Step 2. @@ -1226,10 +1234,10 @@ Supported URL types (auto-detected): Start a background watcher that monitors a folder and auto-updates the graph when files change. ```bash -python3 -m graphify.watch INPUT_PATH --debounce 3 +$(cat graphify-out/.graphify_python) -m graphify.watch "$(cat graphify-out/.graphify_root)" --debounce 3 ``` -Replace INPUT_PATH with the folder to watch. Behavior depends on what changed: +This watches the same folder graphify extracted, read from the trusted `graphify-out/.graphify_root` that Step 1 resolved - there is no path to substitute, so a scan root containing shell metacharacters can never be re-interpreted here. Behavior depends on what changed: - **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically. - **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required). diff --git a/tools/skillgen/fragments/core/devin.md b/tools/skillgen/fragments/core/devin.md index f9be846cb..0f256842e 100644 --- a/tools/skillgen/fragments/core/devin.md +++ b/tools/skillgen/fragments/core/devin.md @@ -103,6 +103,14 @@ mkdir -p graphify-out "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" # Force UTF-8 I/O on Windows (prevents garbled CJK/non-ASCII output) export PYTHONUTF8=1 +# Save scan root so `graphify update` (no args) knows where to look next time. +# INPUT_PATH is passed through a quoted heredoc, never substituted into the +# command line itself: a bare `cd INPUT_PATH` (or an unquoted heredoc, which +# still expands $()/backticks in its body) would let a malicious path execute +# as shell code the moment this line runs. +"$PYTHON" -c "import os, sys; out_path = os.path.abspath('graphify-out/.graphify_root'); os.chdir(sys.stdin.readline().rstrip('\n')); open(out_path, 'w', encoding='utf-8').write(os.getcwd())" <<'GRAPHIFY_ROOT_EOF' +INPUT_PATH +GRAPHIFY_ROOT_EOF ``` If the import succeeds, print nothing and move straight to Step 2. @@ -1358,10 +1366,10 @@ Supported URL types (auto-detected): Start a background watcher that monitors a folder and auto-updates the graph when files change. ```bash -python3 -m graphify.watch INPUT_PATH --debounce 3 +$(cat graphify-out/.graphify_python) -m graphify.watch "$(cat graphify-out/.graphify_root)" --debounce 3 ``` -Replace INPUT_PATH with the folder to watch. Behavior depends on what changed: +This watches the same folder graphify extracted, read from the trusted `graphify-out/.graphify_root` that Step 1 resolved - there is no path to substitute, so a scan root containing shell metacharacters can never be re-interpreted here. Behavior depends on what changed: - **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically. - **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required). diff --git a/tools/skillgen/gen.py b/tools/skillgen/gen.py index 09e19ede0..a44fa3dd4 100644 --- a/tools/skillgen/gen.py +++ b/tools/skillgen/gen.py @@ -1142,6 +1142,44 @@ def _is_community_label_export_fix_line(line: str) -> bool: ) +def _is_step1_root_marker_fix_line(line: str) -> bool: + """Whether a line is part of Step 1 writing .graphify_root via quoted heredoc (#3642/#3844). + + The v8 monoliths did not persist .graphify_root in Step 1. The fix adds the + safe quoted-heredoc scan-root writer to Step 1, persisting + graphify-out/.graphify_root so that `graphify update` and `--watch` can consume + the trusted resolved path. + """ + stripped = line.strip() + return ( + stripped.startswith('"$PYTHON" -c "import os, sys; out_path = os.path.abspath(\'graphify-out/.graphify_root\')') + or stripped in ("INPUT_PATH", "GRAPHIFY_ROOT_EOF") + or stripped == "# Save scan root so `graphify update` (no args) knows where to look next time." + or stripped == "# INPUT_PATH is passed through a quoted heredoc, never substituted into the" + or stripped == "# command line itself: a bare `cd INPUT_PATH` (or an unquoted heredoc, which" + or stripped == "# still expands $()/backticks in its body) would let a malicious path execute" + or stripped == "# as shell code the moment this line runs." + ) + + +def _is_watch_injection_fix_line(line: str) -> bool: + """Whether a line is part of the --watch INPUT_PATH injection fix (#3844). + + The v8 monoliths passed the raw, agent-substituted INPUT_PATH placeholder + unquoted to `graphify.watch` in bash (`python3 -m graphify.watch INPUT_PATH --debounce 3`), + which executed command substitutions in hostile paths. The fix consumes the + trusted `graphify-out/.graphify_root` resolved in Step 1 via $(cat ...). + Both the removed v8 command/prose and the added trusted-root command/prose match here. + """ + stripped = line.strip() + return ( + stripped == "python3 -m graphify.watch INPUT_PATH --debounce 3" + or stripped == '$(cat graphify-out/.graphify_python) -m graphify.watch "$(cat graphify-out/.graphify_root)" --debounce 3' + or stripped == "Replace INPUT_PATH with the folder to watch. Behavior depends on what changed:" + or stripped == "This watches the same folder graphify extracted, read from the trusted `graphify-out/.graphify_root` that Step 1 resolved - there is no path to substitute, so a scan root containing shell metacharacters can never be re-interpreted here. Behavior depends on what changed:" + ) + + # Every line that may differ between a rendered monolith and its pristine v8 # baseline. Each predicate documents one sanctioned change-class; a blank line is # allowed because the multi-line fix blocks insert spacing. Anything else failing @@ -1163,6 +1201,8 @@ _SANCTIONED_MONOLITH_DIFFS = ( _is_uv_from_interpreter_fix_line, _is_semantic_cache_scope_fix_line, _is_community_label_export_fix_line, + _is_step1_root_marker_fix_line, + _is_watch_injection_fix_line, )