diff --git a/src/dippy/cli/__init__.py b/src/dippy/cli/__init__.py index 37b462e..bdadafc 100644 --- a/src/dippy/cli/__init__.py +++ b/src/dippy/cli/__init__.py @@ -24,6 +24,8 @@ class HandlerContext: tokens: list[str] config: Config | None = None + word_has_expansions: tuple[bool, ...] = () + """Per-token flag: True if the original word contained bash expansions ($VAR, $(cmd), etc.).""" @dataclass(frozen=True) diff --git a/src/dippy/cli/python.py b/src/dippy/cli/python.py index 2a7ff75..7457c47 100644 --- a/src/dippy/cli/python.py +++ b/src/dippy/cli/python.py @@ -792,10 +792,11 @@ def classify(ctx: HandlerContext) -> Classification: Auto-approves: - Version/help flags + - -c inline code that passes static analysis (no bash expansions) - Scripts that pass static analysis (no I/O, no dangerous imports) Requires confirmation: - - -c (inline code) + - -c inline code that fails analysis or contains bash expansions - -m (module execution) - Scripts that fail analysis or can't be read - Interactive mode @@ -819,9 +820,30 @@ def classify(ctx: HandlerContext) -> Classification: if token in SAFE_FLAGS: return Classification("allow", description=desc) - # Check for -c (inline code) - too hard to analyze reliably + # Check for -c (inline code) - analyze if possible if "-c" in tokens: - return Classification("ask", description=desc) + idx = tokens.index("-c") + if idx + 1 >= len(tokens): + return Classification("ask", description=desc) + # If the -c argument contains bash expansions ($VAR, $(cmd), etc.), + # we can't reliably analyze it since bash modifies the code at runtime. + code_token_idx = idx + 1 + if ( + ctx.word_has_expansions + and code_token_idx < len(ctx.word_has_expansions) + and ctx.word_has_expansions[code_token_idx] + ): + return Classification("ask", description=f"{desc} (bash expansion)") + code = tokens[code_token_idx] + if not code.strip(): + return Classification("ask", description=desc) + violations = analyze_python_source( + code, extra_safe_modules=extra_safe, extra_deny_modules=extra_deny + ) + if not violations: + return Classification("allow", description=f"{desc} (analyzed)") + v = violations[0] + return Classification("ask", description=f"{desc}: {v.kind}: {v.detail}") # Check for -m (module) - could run arbitrary code if "-m" in tokens: diff --git a/src/dippy/core/analyzer.py b/src/dippy/core/analyzer.py index 5fc4128..8db68db 100644 --- a/src/dippy/core/analyzer.py +++ b/src/dippy/core/analyzer.py @@ -231,6 +231,8 @@ def _analyze_command( # Get base command for injection check words = [_get_word_value(w) for w in node.words] + # Track which words contain bash expansions (param, cmdsub, procsub) + word_has_expansions = tuple(bool(getattr(w, "parts", [])) for w in node.words) # Skip env var assignments to find base command base_idx = 0 while ( @@ -321,7 +323,9 @@ def _analyze_command( decisions.append(Decision("allow", "conditional test")) return _combine(decisions) - cmd_decision = _analyze_simple_command(words, config, cwd, remote=remote) + cmd_decision = _analyze_simple_command( + words, config, cwd, remote=remote, word_has_expansions=word_has_expansions + ) decisions.append(cmd_decision) return _combine(decisions) @@ -384,7 +388,12 @@ def _analyze_redirects( def _analyze_simple_command( - words: list[str], config: Config, cwd: Path, *, remote: bool = False + words: list[str], + config: Config, + cwd: Path, + *, + remote: bool = False, + word_has_expansions: tuple[bool, ...] = (), ) -> Decision: """Analyze a simple command (list of words).""" if not words: @@ -400,6 +409,9 @@ def _analyze_simple_command( base = words[i] tokens = words[i:] + # Keep the per-token expansion flags aligned with `tokens` after slicing off + # leading env assignments, so handlers index them against the same list. + token_expansions = word_has_expansions[i:] # 1. Check config rules first (highest priority) from dippy.core.config import SimpleCommand, match_command @@ -436,7 +448,13 @@ def _analyze_simple_command( break if j < len(tokens): - return _analyze_simple_command(tokens[j:], config, cwd, remote=remote) + return _analyze_simple_command( + tokens[j:], + config, + cwd, + remote=remote, + word_has_expansions=token_expansions[j:], + ) return Decision("ask", base) # 3. Simple safe commands @@ -450,7 +468,9 @@ def _analyze_simple_command( # 5. CLI-specific handlers handler = get_handler(base) if handler: - result = handler.classify(HandlerContext(tokens, config=config)) + result = handler.classify( + HandlerContext(tokens, config=config, word_has_expansions=token_expansions) + ) desc = result.description or get_description(tokens, base) # Check handler-provided redirect targets against config (skip in remote mode) if result.redirect_targets and not remote: diff --git a/tests/cli/test_python.py b/tests/cli/test_python.py index ca40386..67adbb8 100644 --- a/tests/cli/test_python.py +++ b/tests/cli/test_python.py @@ -41,9 +41,7 @@ class TestPythonCodeExecution: @pytest.mark.parametrize( "cmd", [ - "python -c 'print(1)'", "python3 -c 'import os; os.system(\"ls\")'", - "python -c 'x=1'", "python -m http.server", "python -m pip install foo", "python -m pytest", @@ -1465,3 +1463,118 @@ def test_allow_override_pathlib(self): "from pathlib import Path", extra_safe_modules=frozenset({"pathlib"}) ) assert len(violations) == 0 + + +class TestPythonInlineCode: + """Tests for python -c inline code analysis.""" + + @pytest.mark.parametrize( + "cmd", + [ + "python -c 'print(1)'", + "python -c 'x=1'", + "python -c 'import json; json.dumps({})'", + "python -c 'import math; print(math.sqrt(2))'", + "python -c '[x**2 for x in range(10)]'", + ], + ) + def test_safe_inline_code_approved(self, check, cmd): + """Safe inline code should be auto-approved.""" + result = check(cmd) + assert is_approved(result), f"Expected approve: {cmd}" + + @pytest.mark.parametrize( + "cmd", + [ + "python -c 'import os'", + "python -c 'import subprocess; subprocess.run([])'", + "python -c 'open(\"foo.txt\")'", + "python -c 'eval(\"1+1\")'", + "python3 -c 'import os; os.system(\"ls\")'", + ], + ) + def test_dangerous_inline_code_needs_confirmation(self, check, cmd): + """Dangerous inline code should need confirmation.""" + result = check(cmd) + assert needs_confirmation(result), f"Expected confirm: {cmd}" + + def test_c_no_argument_needs_confirmation(self, check): + """python -c with no code argument should need confirmation.""" + result = check("python -c") + assert needs_confirmation(result) + + def test_c_empty_string_needs_confirmation(self, check): + """python -c '' should need confirmation.""" + result = check("python -c ''") + assert needs_confirmation(result) + + def test_config_modules_with_inline_code(self, check): + """Config modules should apply to -c inline code too.""" + config = Config(python_allow_modules=["numpy"]) + result = check("python -c 'import numpy'", config=config) + assert is_approved(result), "numpy should be approved in -c via config" + + def test_config_deny_modules_with_inline_code(self, check): + """Config deny modules should apply to -c inline code too.""" + config = Config(python_deny_modules=["json"]) + result = check("python -c 'import json'", config=config) + assert needs_confirmation(result), "json should be denied in -c via config" + + def test_allow_override_in_inline_code(self, check): + """Allow override should also work for -c inline code.""" + config = Config(python_allow_modules=["pathlib"]) + result = check("python -c 'from pathlib import Path'", config=config) + assert is_approved(result), ( + "pathlib in -c should be approved via config override" + ) + + +class TestPythonInlineExpansions: + """Tests for bash expansion detection in -c inline code.""" + + def test_c_with_bash_variable_needs_confirmation(self, check_single): + """python -c with $VAR should fall back to ask.""" + decision, reason = check_single('python -c "print($HOME)"') + assert decision != "approve" or "expansion" in reason + + def test_c_with_command_substitution_needs_confirmation(self, check_single): + """python -c with $(cmd) should fall back to ask.""" + decision, reason = check_single('python -c "print($(whoami))"') + assert decision != "approve" + + def test_c_safe_no_expansion_approved(self, check_single): + """python -c with no expansions should be approved if safe.""" + decision, reason = check_single("python -c 'print(1)'") + assert decision == "approve" + + def test_c_single_quoted_no_expansion(self, check_single): + """Single-quoted -c code has no expansions (bash doesn't expand in single quotes).""" + decision, reason = check_single("python -c 'print(1+1)'") + assert decision == "approve" + + def test_c_expansion_detected_with_env_prefix(self, check_single): + """The expansion guard must stay aligned past leading env assignments. + + Regression: word_has_expansions was indexed against the un-stripped word + list, so FOO=bar shifted it and the guard read the wrong token. We assert + the *expansion* reason fires, not the incidental syntax-error backstop. + """ + decision, reason = check_single('FOO=bar python -c "print($HOME)"') + assert decision != "approve" + assert "expansion" in reason + + def test_c_safe_approved_with_env_prefix(self, check_single): + """A safe -c body still auto-approves when an env assignment precedes it.""" + decision, reason = check_single("FOO=bar python -c 'print(1)'") + assert decision == "approve" + + def test_c_expansion_detected_through_wrapper(self, check_single): + """The flags must be re-sliced through wrapper recursion (time/timeout/...).""" + decision, reason = check_single('time python -c "print($HOME)"') + assert decision != "approve" + assert "expansion" in reason + + def test_c_safe_approved_through_wrapper(self, check_single): + """A safe -c body still auto-approves behind a wrapper command.""" + decision, reason = check_single("timeout 5 python -c 'print(1)'") + assert decision == "approve" diff --git a/tests/cli/test_uv.py b/tests/cli/test_uv.py index 5b0055f..c298b2a 100644 --- a/tests/cli/test_uv.py +++ b/tests/cli/test_uv.py @@ -141,9 +141,9 @@ # === Delegation with special characters (issue #109) === ( "uv run python -c 'print(1)'", - False, - ), # delegates to python handler, not parse error - ('uv run python -c "print(1)"', False), # double-quoted variant + True, + ), # delegates to python handler, safe -c code auto-approved + ('uv run python -c "print(1)"', True), # double-quoted variant # # === UNSAFE: uv tool === ("uv tool run ruff check", False),