Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/dippy/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 25 additions & 3 deletions src/dippy/cli/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
28 changes: 24 additions & 4 deletions src/dippy/core/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
117 changes: 115 additions & 2 deletions tests/cli/test_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
6 changes: 3 additions & 3 deletions tests/cli/test_uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading