shellsafe 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {shellsafe-0.2.0 → shellsafe-0.3.0}/.github/workflows/ci.yml +0 -1
  2. {shellsafe-0.2.0 → shellsafe-0.3.0}/CHANGELOG.md +27 -0
  3. {shellsafe-0.2.0 → shellsafe-0.3.0}/PKG-INFO +22 -1
  4. {shellsafe-0.2.0 → shellsafe-0.3.0}/README.md +21 -0
  5. {shellsafe-0.2.0 → shellsafe-0.3.0}/pyproject.toml +2 -5
  6. shellsafe-0.3.0/src/shellsafe/_version.py +1 -0
  7. shellsafe-0.3.0/src/shellsafe/audit/__init__.py +6 -0
  8. shellsafe-0.3.0/src/shellsafe/audit/scanner.py +232 -0
  9. shellsafe-0.3.0/src/shellsafe/cli.py +70 -0
  10. {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/execute.py +5 -15
  11. shellsafe-0.3.0/src/shellsafe/platforms.py +25 -0
  12. {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/raw.py +0 -1
  13. {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/render.py +5 -16
  14. shellsafe-0.3.0/tests/fixtures/audit/au001_sample.py +31 -0
  15. shellsafe-0.3.0/tests/integration/test_cli_audit.py +66 -0
  16. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/integration/test_exec_posix.py +0 -6
  17. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/property/test_invariants.py +0 -2
  18. shellsafe-0.3.0/tests/unit/test_audit.py +56 -0
  19. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/unit/test_payload_corpus.py +0 -2
  20. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/unit/test_render.py +0 -2
  21. shellsafe-0.2.0/src/shellsafe/_version.py +0 -1
  22. shellsafe-0.2.0/src/shellsafe/audit/__init__.py +0 -10
  23. shellsafe-0.2.0/src/shellsafe/audit/rules.py +0 -0
  24. shellsafe-0.2.0/src/shellsafe/cli.py +0 -44
  25. shellsafe-0.2.0/src/shellsafe/platforms.py +0 -35
  26. shellsafe-0.2.0/src/shellsafe/reporters.py +0 -1
  27. {shellsafe-0.2.0 → shellsafe-0.3.0}/.github/workflows/release.yml +0 -0
  28. {shellsafe-0.2.0 → shellsafe-0.3.0}/.gitignore +0 -0
  29. {shellsafe-0.2.0 → shellsafe-0.3.0}/CONTRIBUTING.md +0 -0
  30. {shellsafe-0.2.0 → shellsafe-0.3.0}/LICENSE +0 -0
  31. {shellsafe-0.2.0 → shellsafe-0.3.0}/examples/demo.py +0 -0
  32. {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/__init__.py +0 -0
  33. {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/__main__.py +0 -0
  34. {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/errors.py +0 -0
  35. {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/exitcodes.py +0 -0
  36. {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/py.typed +0 -0
  37. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/golden/.gitkeep +0 -0
  38. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/integration/.gitkeep +0 -0
  39. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/README.md +0 -0
  40. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_01_semicolon.txt +0 -0
  41. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_02_substitution.txt +0 -0
  42. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_03_backticks.txt +0 -0
  43. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_04_background_chain.txt +0 -0
  44. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_05_or_chain.txt +0 -0
  45. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
  46. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
  47. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
  48. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
  49. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
  50. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
  51. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_12_newline.txt +0 -0
  52. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
  53. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_14_whitespace.txt +0 -0
  54. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
  55. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/property/.gitkeep +0 -0
  56. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/unit/test_cli.py +0 -0
  57. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/unit/test_errors.py +0 -0
  58. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/unit/test_public_surface.py +0 -0
  59. {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/unit/test_raw.py +0 -0
@@ -25,4 +25,3 @@ jobs:
25
25
  - run: uv sync
26
26
  - run: uv run pytest -q
27
27
  - run: uv run ruff check .
28
- - run: uv run mypy src/
@@ -3,6 +3,33 @@
3
3
  All notable changes to this project are documented here. Format follows
4
4
  Keep a Changelog; versioning follows SemVer.
5
5
 
6
+ ## [0.3.0] - 2026-08-29
7
+
8
+ ### Added
9
+
10
+ - Offline audit scanner: AST-based detection of f-strings passed to shell
11
+ executors (`os.system`, `subprocess.run`, `subprocess.call`, etc.)
12
+ - AU001 rule: flags f-string interpolation in shell executor arguments with
13
+ fix-hint suggesting template strings
14
+ - Import alias tracking: handles `import subprocess as sp`, `from os import
15
+ system`, etc.
16
+ - CLI `shellsafe audit` command with terminal table and JSON output
17
+ - `--json` flag for machine-readable findings
18
+ - `--severity` flag to filter by minimum severity level
19
+ - Audit fixture corpus: 6 positive cases + 4 safe cases
20
+ - Unit tests for scanner, integration tests for CLI audit
21
+
22
+ ### Changed
23
+
24
+ - Collapse `audit/rules.py` and `reporters.py` into single `audit/scanner.py`
25
+ - CLI refactored with dedicated `_run_audit` handler
26
+
27
+ ### Removed
28
+
29
+ - mypy (ruff catches real bugs)
30
+ - `cast()` calls on values that already have the right type
31
+
32
+
6
33
  ## [0.2.0] - 2026-08-27
7
34
 
8
35
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shellsafe
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Run shell commands safely using Python 3.14 template strings. Values can never turn into commands.
5
5
  Project-URL: Repository, https://github.com/rahulXs/shellsafe
6
6
  Project-URL: Issues, https://github.com/rahulXs/shellsafe/issues
@@ -108,6 +108,27 @@ print(plan(t"git commit -m {message}"))
108
108
  `RAW(...)` marks content you have already made safe by hand. It is loud and easy
109
109
  to find in code review, so trust is never hidden.
110
110
 
111
+ ## Find old dangerous patterns
112
+
113
+ Already have code using f-strings in shell commands? The scanner finds them:
114
+
115
+ ```bash
116
+ shellsafe audit src/
117
+ ```
118
+
119
+ It detects f-strings passed to `os.system`, `subprocess.run(shell=True)`, and
120
+ other shell executors. Get machine-readable output:
121
+
122
+ ```bash
123
+ shellsafe audit src/ --json
124
+ ```
125
+
126
+ Filter by severity:
127
+
128
+ ```bash
129
+ shellsafe audit src/ --severity warning
130
+ ```
131
+
111
132
  ## Limits
112
133
 
113
134
  - Shell features (pipes, redirections) work on Linux and macOS only. Windows
@@ -86,6 +86,27 @@ print(plan(t"git commit -m {message}"))
86
86
  `RAW(...)` marks content you have already made safe by hand. It is loud and easy
87
87
  to find in code review, so trust is never hidden.
88
88
 
89
+ ## Find old dangerous patterns
90
+
91
+ Already have code using f-strings in shell commands? The scanner finds them:
92
+
93
+ ```bash
94
+ shellsafe audit src/
95
+ ```
96
+
97
+ It detects f-strings passed to `os.system`, `subprocess.run(shell=True)`, and
98
+ other shell executors. Get machine-readable output:
99
+
100
+ ```bash
101
+ shellsafe audit src/ --json
102
+ ```
103
+
104
+ Filter by severity:
105
+
106
+ ```bash
107
+ shellsafe audit src/ --severity warning
108
+ ```
109
+
89
110
  ## Limits
90
111
 
91
112
  - Shell features (pipes, redirections) work on Linux and macOS only. Windows
@@ -50,10 +50,6 @@ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF", "C901"]
50
50
  [tool.ruff.lint.mccabe]
51
51
  max-complexity = 10
52
52
 
53
- [tool.mypy]
54
- strict = true
55
- python_version = "3.14"
56
-
57
53
  [tool.pytest.ini_options]
58
54
  testpaths = ["tests"]
59
55
  addopts = "-q --tb=short"
@@ -62,12 +58,13 @@ addopts = "-q --tb=short"
62
58
  # RAW() is a deliberate loud trust marker; the uppercase name is the API.
63
59
  "src/shellsafe/raw.py" = ["N802"]
64
60
  "src/shellsafe/__init__.py" = ["N999"]
61
+ # Fixture files contain planted violations and undefined names on purpose.
62
+ "tests/fixtures/**" = ["F821", "F841"]
65
63
 
66
64
  [dependency-groups]
67
65
  dev = [
68
66
  "pytest>=8",
69
67
  "ruff>=0.5",
70
- "mypy>=1.10",
71
68
  "hypothesis>=6",
72
69
  "build>=1.2",
73
70
  "twine>=6",
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,6 @@
1
+ """Offline AST audit for dangerous command construction."""
2
+
3
+
4
+ from .scanner import Finding, scan, scan_report
5
+
6
+ __all__ = ["Finding", "scan", "scan_report"]
@@ -0,0 +1,232 @@
1
+ """Offline AST scanner for dangerous command construction.
2
+
3
+ Never imports from the scanned project. Reads code only.
4
+ """
5
+
6
+ import ast
7
+ import json
8
+ import sys
9
+ import time
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Finding:
18
+ """One audit finding."""
19
+
20
+ rule_id: str
21
+ title: str
22
+ severity: str
23
+ confidence: float
24
+ path: str
25
+ lineno: int
26
+ col: int
27
+ message: str
28
+ evidence: dict[str, Any] = field(default_factory=dict)
29
+ fix_hint: str = ""
30
+ ignored: bool = False
31
+ ignore_reason: str | None = None
32
+
33
+
34
+ _EXECUTORS = {
35
+ "system": "os.system",
36
+ "popen": "os.popen",
37
+ "run": "subprocess.run",
38
+ "call": "subprocess.call",
39
+ "check_call": "subprocess.check_call",
40
+ "check_output": "subprocess.check_output",
41
+ "Popen": "subprocess.Popen",
42
+ "getoutput": "subprocess.getoutput",
43
+ }
44
+
45
+ _IMPORT_ALIAS = {
46
+ "os": "os",
47
+ "subprocess": "subprocess",
48
+ }
49
+
50
+
51
+ def _resolve_callee(node: ast.expr, aliases: dict[str, str]) -> str | None:
52
+ """Resolve a Call.func to an executor label like 'os.system'."""
53
+ if (
54
+ isinstance(node, ast.Attribute)
55
+ and isinstance(node.value, ast.Name)
56
+ and node.value.id in _IMPORT_ALIAS
57
+ ):
58
+ module = _IMPORT_ALIAS[node.value.id]
59
+ if node.attr in _EXECUTORS:
60
+ return f"{module}.{node.attr}"
61
+ if isinstance(node, ast.Name):
62
+ real = aliases.get(node.id, node.id)
63
+ if real in _EXECUTORS:
64
+ return _EXECUTORS[real]
65
+ if "." in real:
66
+ module, func = real.rsplit(".", 1)
67
+ if func in _EXECUTORS:
68
+ return f"{module}.{func}"
69
+ return None
70
+
71
+
72
+ def au001(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
73
+ """f-string passed to a shell-executing call."""
74
+ callee = _resolve_callee(node.func, aliases)
75
+ if callee is None:
76
+ return None
77
+ for arg in node.args:
78
+ if isinstance(arg, ast.JoinedStr) and any(
79
+ isinstance(v, ast.FormattedValue) for v in arg.values
80
+ ):
81
+ return Finding(
82
+ rule_id="AU001",
83
+ title="f-string passed to shell-executing call",
84
+ severity="error",
85
+ confidence=0.95,
86
+ path=path,
87
+ lineno=node.lineno,
88
+ col=node.col_offset,
89
+ message=f"{callee} receives an f-string with interpolated value(s)",
90
+ evidence={
91
+ "callee": callee,
92
+ "interpolations": sum(
93
+ 1 for v in arg.values if isinstance(v, ast.FormattedValue)
94
+ ),
95
+ },
96
+ fix_hint='use shellsafe.run(t"...") or pass an argv list',
97
+ )
98
+ return None
99
+
100
+
101
+ RULES: list[tuple[str, Callable[..., Finding | None]]] = [
102
+ ("AU001", au001),
103
+ ]
104
+
105
+
106
+ def _discover(paths: list[str]) -> list[Path]:
107
+ files: list[Path] = []
108
+ for raw in paths:
109
+ p = Path(raw)
110
+ if p.is_file() and p.suffix == ".py":
111
+ files.append(p)
112
+ elif p.is_dir():
113
+ files.extend(
114
+ f
115
+ for f in sorted(p.rglob("*.py"))
116
+ if not any(part.startswith(".") or part == "__pycache__" for part in f.parts)
117
+ )
118
+ return files
119
+
120
+
121
+ def _import_aliases(tree: ast.Module) -> dict[str, str]:
122
+ """Map local names to real module.function for executor imports."""
123
+ aliases: dict[str, str] = {}
124
+ for node in ast.walk(tree):
125
+ if isinstance(node, ast.Import):
126
+ for alias in node.names:
127
+ local = alias.asname or alias.name
128
+ aliases[local] = alias.name
129
+ elif isinstance(node, ast.ImportFrom):
130
+ module = node.module or ""
131
+ for alias in node.names:
132
+ local = alias.asname or alias.name
133
+ aliases[local] = f"{module}.{alias.name}"
134
+ return aliases
135
+
136
+
137
+ def scan(paths: list[str]) -> list[Finding]:
138
+ """Scan Python files for audit findings."""
139
+ findings: list[Finding] = []
140
+ for path in _discover(paths):
141
+ try:
142
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
143
+ except (SyntaxError, UnicodeDecodeError):
144
+ continue
145
+ aliases = _import_aliases(tree)
146
+ rel = str(path)
147
+ for node in ast.walk(tree):
148
+ if isinstance(node, ast.Call):
149
+ for _, rule in RULES:
150
+ result = rule(node, aliases, rel)
151
+ if result is not None:
152
+ findings.append(result)
153
+ findings.sort(key=lambda f: (f.severity != "error", f.path, f.lineno))
154
+ return findings
155
+
156
+
157
+ _COLORS = {"error": "\033[31m", "warning": "\033[33m", "info": "\033[36m", "reset": "\033[0m"}
158
+
159
+
160
+ def _color(text: str, severity: str) -> str:
161
+ c = _COLORS.get(severity, "")
162
+ return f"{c}{text}{_COLORS['reset']}" if c else text
163
+
164
+
165
+ def report_terminal(findings: list[Finding]) -> str:
166
+ """Render findings as a terminal table."""
167
+ if not findings:
168
+ return "no findings"
169
+ use_color = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
170
+ lines = [
171
+ f"{'SEV':<9} {'RULE':<7} {'FILE':<40} {'LINE':>5} MESSAGE",
172
+ "-" * 90,
173
+ ]
174
+ for f in findings:
175
+ sev = _color(f.severity.upper(), f.severity) if use_color else f.severity.upper()
176
+ path = f.path if len(f.path) <= 40 else "..." + f.path[-37:]
177
+ lines.append(f"{sev:<18} {f.rule_id:<7} {path:<40} {f.lineno:>5} {f.message}")
178
+ return "\n".join(lines)
179
+
180
+
181
+ def report_json(report: dict[str, object]) -> str:
182
+ """Render the full report as JSON."""
183
+ return json.dumps(report, indent=2)
184
+
185
+
186
+ def scan_report(paths: list[str]) -> dict[str, object]:
187
+ """Scan and return a report dict matching schema v1."""
188
+ start = time.monotonic()
189
+ findings = scan(paths)
190
+ duration_ms = round((time.monotonic() - start) * 1000, 1)
191
+
192
+ errors = sum(1 for f in findings if f.severity == "error" and not f.ignored)
193
+ warnings = sum(1 for f in findings if f.severity == "warning" and not f.ignored)
194
+ info = sum(1 for f in findings if f.severity == "info" and not f.ignored)
195
+ ignored = sum(1 for f in findings if f.ignored)
196
+
197
+ from .._version import __version__
198
+
199
+ return {
200
+ "schema_version": 1,
201
+ "tool": {"name": "shellsafe", "version": __version__},
202
+ "run": {
203
+ "duration_ms": duration_ms,
204
+ "host_python": f"{sys.version_info.major}.{sys.version_info.minor}"
205
+ f".{sys.version_info.micro}",
206
+ "paths": paths,
207
+ },
208
+ "summary": {
209
+ "errors": errors,
210
+ "warnings": warnings,
211
+ "info": info,
212
+ "ignored": ignored,
213
+ "verdict": "fail" if errors > 0 else "pass",
214
+ },
215
+ "findings": [
216
+ {
217
+ "rule_id": f.rule_id,
218
+ "title": f.title,
219
+ "severity": f.severity,
220
+ "confidence": f.confidence,
221
+ "path": f.path,
222
+ "lineno": f.lineno,
223
+ "col": f.col,
224
+ "message": f.message,
225
+ "evidence": f.evidence,
226
+ "fix_hint": f.fix_hint,
227
+ "ignored": f.ignored,
228
+ "ignore_reason": f.ignore_reason,
229
+ }
230
+ for f in findings
231
+ ],
232
+ }
@@ -0,0 +1,70 @@
1
+ """CLI: audit, version. Business logic lives in the stages."""
2
+
3
+ import argparse
4
+ import platform
5
+ import sys
6
+
7
+ from . import exitcodes
8
+ from ._version import __version__
9
+
10
+ _SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2}
11
+
12
+
13
+ def _build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(prog="shellsafe")
15
+ parser.add_argument("-V", "--version", action="store_true")
16
+ sub = parser.add_subparsers(dest="command")
17
+
18
+ audit = sub.add_parser("audit", help="scan code for dangerous command construction")
19
+ audit.add_argument("paths", nargs="*", default=["."], help="files or directories to scan")
20
+ audit.add_argument("--json", action="store_true", dest="json_output", help="output JSON report")
21
+ audit.add_argument(
22
+ "--severity",
23
+ choices=["error", "warning", "info"],
24
+ default="warning",
25
+ help="minimum severity to report (default: warning)",
26
+ )
27
+
28
+ sub.add_parser("version", help="detailed version and capability matrix")
29
+ return parser
30
+
31
+
32
+ def _run_audit(args: argparse.Namespace) -> int:
33
+ from .audit.scanner import Finding, report_json, report_terminal, scan_report
34
+
35
+ report = scan_report(args.paths)
36
+ findings = report.get("findings", [])
37
+
38
+ min_sev = _SEVERITY_ORDER.get(args.severity, 1)
39
+ filtered = [
40
+ f for f in findings
41
+ if _SEVERITY_ORDER.get(str(f.get("severity", "")), 9) <= min_sev
42
+ ]
43
+
44
+ if args.json_output:
45
+ print(report_json(report))
46
+ else:
47
+ print(report_terminal([Finding(**f) for f in filtered]))
48
+
49
+ summary = report.get("summary", {})
50
+ return exitcodes.FINDINGS if summary.get("errors", 0) > 0 else exitcodes.OK
51
+
52
+
53
+ def main(argv: list[str] | None = None) -> int:
54
+ parser = _build_parser()
55
+ args = parser.parse_args(argv)
56
+
57
+ if args.version:
58
+ print(f"shellsafe {__version__}")
59
+ return exitcodes.OK
60
+ if args.command == "version":
61
+ py = platform.python_version()
62
+ print(f"shellsafe {__version__} . python {py} . {sys.platform}")
63
+ print("argv-mode: available")
64
+ print("shell-mode: available (posix)")
65
+ print("audit: available (AU001)")
66
+ return exitcodes.OK
67
+ if args.command == "audit":
68
+ return _run_audit(args)
69
+ parser.print_help()
70
+ return exitcodes.OK
@@ -1,11 +1,9 @@
1
1
  """Subprocess wrappers over rendered ExecutionPlans."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  import subprocess
6
4
  import sys
7
5
  from dataclasses import dataclass
8
- from typing import Any, cast
6
+ from typing import Any
9
7
 
10
8
  from .errors import ArgvOnlyError, ShellSafeError
11
9
  from .raw import Raw # noqa: F401 (re-exported through the package root)
@@ -29,7 +27,7 @@ _ALLOWED_KWARGS = frozenset(
29
27
  )
30
28
 
31
29
 
32
- def _validate_kwargs(kwargs: dict[str, object]) -> None:
30
+ def _validate_kwargs(kwargs: dict[str, object]):
33
31
  if "shell" in kwargs:
34
32
  raise ShellSafeError(
35
33
  "shellsafe never passes shell=True; use shx() for pipes and "
@@ -51,11 +49,7 @@ def plan(template: object) -> ExecutionPlan:
51
49
 
52
50
 
53
51
  def _pass_through(kwargs: dict[str, object]) -> dict[str, Any]:
54
- """Filter kwargs to the allowed subprocess.run set."""
55
- return cast(
56
- "dict[str, Any]",
57
- {k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS},
58
- )
52
+ return {k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS}
59
53
 
60
54
 
61
55
  def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
@@ -78,8 +72,7 @@ def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[st
78
72
  "run() to execute pipes and redirections"
79
73
  )
80
74
  assert rendered.argv is not None
81
- result = subprocess.run(rendered.argv, **_pass_through(kwargs))
82
- return cast("subprocess.CompletedProcess[str]", result)
75
+ return subprocess.run(rendered.argv, **_pass_through(kwargs))
83
76
 
84
77
 
85
78
  @dataclass(frozen=True, slots=True)
@@ -98,8 +91,6 @@ def capture(template: object, /, **kwargs: object) -> CaptureResult:
98
91
  kwargs["text"] = True
99
92
  kwargs["encoding"] = "utf-8"
100
93
  completed = run(template, **kwargs)
101
- assert isinstance(completed.stdout, str)
102
- assert isinstance(completed.stderr, str)
103
94
  return CaptureResult(
104
95
  stdout=completed.stdout,
105
96
  stderr=completed.stderr,
@@ -122,8 +113,7 @@ def shx(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[st
122
113
  "template contains no shell metacharacters; use run() instead"
123
114
  )
124
115
  assert rendered.shell_line is not None
125
- result = subprocess.run(
116
+ return subprocess.run(
126
117
  ["/bin/sh", "-c", rendered.shell_line],
127
118
  **_pass_through(kwargs),
128
119
  )
129
- return cast("subprocess.CompletedProcess[str]", result)
@@ -0,0 +1,25 @@
1
+ """Platform policy tables and route decision."""
2
+
3
+ import sys
4
+
5
+ METACHARACTERS = frozenset("|&;()<>$`\"'*?!#\\\n\r\t")
6
+
7
+ IS_WINDOWS = sys.platform.startswith("win")
8
+
9
+
10
+ def route_for(static_text: str) -> str:
11
+ """Return "argv" or "shell" for the given static template text.
12
+
13
+ Raises UnsupportedPlatformError on Windows when shell features are requested.
14
+ """
15
+ if not any(ch in METACHARACTERS for ch in static_text):
16
+ return "argv"
17
+ if IS_WINDOWS:
18
+ from .errors import UnsupportedPlatformError
19
+
20
+ raise UnsupportedPlatformError(
21
+ "shell metacharacters in a shellsafe template require posix sh; "
22
+ "windows cmd.exe quoting cannot be made injection-safe. "
23
+ "restructure without pipes/redirections, or run under wsl."
24
+ )
25
+ return "shell"
@@ -1,6 +1,5 @@
1
1
  """RAW marker: the single explicit trust boundary."""
2
2
 
3
- from __future__ import annotations
4
3
 
5
4
  from .errors import RawUsageError
6
5
 
@@ -1,4 +1,4 @@
1
- """Template rendering: t-string in, ExecutionPlan out. Pure and deterministic.
1
+ """Template rendering: t-string in, ExecutionPlan out.
2
2
 
3
3
  Security invariants (property-tested, see docs/07_rendering_engine_spec.md):
4
4
  - INV-1: every interpolation contributes exactly one argv element equal to its
@@ -7,8 +7,6 @@ Security invariants (property-tested, see docs/07_rendering_engine_spec.md):
7
7
  - INV-3: element count equals static words plus interpolations plus RAW splices.
8
8
  """
9
9
 
10
- from __future__ import annotations
11
-
12
10
  import shlex
13
11
  from dataclasses import dataclass
14
12
  from string.templatelib import Interpolation, Template
@@ -29,25 +27,17 @@ class ExecutionPlan:
29
27
  shell_line: str | None = None
30
28
 
31
29
  def __repr__(self) -> str:
32
- if self.mode == "argv":
33
- assert self.argv is not None
30
+ if self.mode == "argv" and self.argv is not None:
34
31
  body = ",".join(repr(a) for a in self.argv)
35
32
  return f"argv: [{body}]"
36
- assert self.shell_line is not None
37
33
  return f"shell: {self.shell_line}"
38
34
 
39
35
 
40
36
  Segment = str | Interpolation[str] | Raw
41
37
 
42
38
 
43
- def _walk(
44
- template: Template, seen: frozenset[int]
45
- ) -> list[Segment]:
46
- """Flatten a template into an ordered list of static text and interpolations.
47
-
48
- Nested templates (a t-string interpolated inside a t-string) splice their
49
- segments at the interpolation position. Cycle-guarded via object ids.
50
- """
39
+ def _walk(template: Template, seen: frozenset[int]) -> list[Segment]:
40
+ """Flatten a Template into segments, recursing into nested templates."""
51
41
  parts: list[Segment] = []
52
42
  for element in template:
53
43
  if isinstance(element, str):
@@ -96,11 +86,10 @@ def _resolve(interpolation: Interpolation[str]) -> str:
96
86
  return format(converted, format_spec)
97
87
  if conversion is None:
98
88
  return str(converted)
99
- assert isinstance(converted, str)
100
89
  return converted
101
90
 
102
91
 
103
- def _reject_nul(resolved: str) -> None:
92
+ def _reject_nul(resolved: str):
104
93
  if _NUL in resolved:
105
94
  raise ShellSafeTypeError(
106
95
  "interpolated value contains a NUL byte after formatting; "
@@ -0,0 +1,31 @@
1
+ """AU001 fixture: planted violations and safe twins for the f-string rule."""
2
+
3
+ import os
4
+ import subprocess
5
+ from os import system
6
+ from subprocess import run as subprocess_run
7
+
8
+ # --- positive cases (should produce AU001 findings) ---
9
+
10
+ os.system(f"echo {user_input}")
11
+
12
+ subprocess.run(f"echo {user_input}", shell=True)
13
+
14
+ subprocess.call(f"cat {filename}")
15
+
16
+ system(f"rm -rf {path}")
17
+
18
+ subprocess_run(f"grep {pattern} {file}")
19
+
20
+ subprocess.getoutput(f"echo {user_input}")
21
+
22
+
23
+ # --- safe cases (should NOT produce findings) ---
24
+
25
+ subprocess.run(["echo", user_input])
26
+
27
+ os.system("echo hello")
28
+
29
+ subprocess.run(["cat", filename], shell=True)
30
+
31
+ subprocess.run("echo hello")
@@ -0,0 +1,66 @@
1
+ """CLI integration tests for the audit command."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ FIXTURES = Path(__file__).parent.parent / "fixtures" / "audit"
9
+
10
+
11
+ def test_audit_cli_finds_issues():
12
+ out = subprocess.run(
13
+ [sys.executable, "-m", "shellsafe", "audit", str(FIXTURES / "au001_sample.py")],
14
+ capture_output=True,
15
+ text=True,
16
+ )
17
+ assert out.returncode == 1
18
+ assert "AU001" in out.stdout
19
+
20
+
21
+ def test_audit_cli_clean_dir(tmp_path: Path):
22
+ out = subprocess.run(
23
+ [sys.executable, "-m", "shellsafe", "audit", str(tmp_path)],
24
+ capture_output=True,
25
+ text=True,
26
+ )
27
+ assert out.returncode == 0
28
+ assert "no findings" in out.stdout
29
+
30
+
31
+ def test_audit_cli_json_output():
32
+ out = subprocess.run(
33
+ [
34
+ sys.executable,
35
+ "-m",
36
+ "shellsafe",
37
+ "audit",
38
+ str(FIXTURES / "au001_sample.py"),
39
+ "--json",
40
+ ],
41
+ capture_output=True,
42
+ text=True,
43
+ )
44
+ assert out.returncode == 1
45
+ report = json.loads(out.stdout)
46
+ assert report["schema_version"] == 1
47
+ assert report["summary"]["errors"] == 6
48
+ assert len(report["findings"]) == 6
49
+
50
+
51
+ def test_audit_cli_severity_filter():
52
+ out = subprocess.run(
53
+ [
54
+ sys.executable,
55
+ "-m",
56
+ "shellsafe",
57
+ "audit",
58
+ str(FIXTURES / "au001_sample.py"),
59
+ "--severity",
60
+ "info",
61
+ ],
62
+ capture_output=True,
63
+ text=True,
64
+ )
65
+ assert out.returncode == 1
66
+ assert "AU001" in out.stdout
@@ -7,8 +7,6 @@ import pytest
7
7
  from shellsafe import capture, run, shx
8
8
  from shellsafe.errors import ArgvOnlyError, ShellSafeError, UnsupportedPlatformError
9
9
 
10
- # --- argv mode ---
11
-
12
10
 
13
11
  @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
14
12
  def test_capture_round_trip():
@@ -33,7 +31,6 @@ def test_interpolation_with_spaces_stays_one_argument():
33
31
  assert res.stdout.strip() == "two words"
34
32
 
35
33
 
36
- # --- shell mode execution ---
37
34
 
38
35
 
39
36
  @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
@@ -68,7 +65,6 @@ def test_shx_output_capture():
68
65
  assert res.stdout.strip() == "WORLD"
69
66
 
70
67
 
71
- # --- run() rejects shell mode ---
72
68
 
73
69
 
74
70
  @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
@@ -77,7 +73,6 @@ def test_run_rejects_shell_metacharacters():
77
73
  run(t"echo hello | wc -l")
78
74
 
79
75
 
80
- # --- shx rejects argv-only templates ---
81
76
 
82
77
 
83
78
  @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
@@ -86,7 +81,6 @@ def test_shx_rejects_argv_only_template():
86
81
  shx(t"echo hello")
87
82
 
88
83
 
89
- # --- Windows policy ---
90
84
 
91
85
 
92
86
  def test_shell_mode_execution_not_yet_available_on_windows():
@@ -14,7 +14,6 @@ from shellsafe.render import plan
14
14
  text_values = st.text(st.characters(blacklist_characters="\x00"), max_size=200)
15
15
 
16
16
 
17
- # --- argv mode invariants ---
18
17
 
19
18
 
20
19
  @settings(max_examples=300)
@@ -41,7 +40,6 @@ def test_repr_round_trip_is_byte_stable(value: str):
41
40
  assert repr(p1) == repr(p2)
42
41
 
43
42
 
44
- # --- shell mode invariants ---
45
43
 
46
44
 
47
45
  @pytest.mark.skipif(sys.platform == "win32", reason="shell mode is posix-only")
@@ -0,0 +1,56 @@
1
+ """Audit scanner tests: AU001 rule detection against fixture corpus."""
2
+
3
+ from pathlib import Path
4
+
5
+ from shellsafe.audit.scanner import scan
6
+
7
+ FIXTURES = Path(__file__).parent.parent / "fixtures" / "audit"
8
+
9
+
10
+ def test_au001_detects_positive_cases():
11
+ findings = scan([str(FIXTURES / "au001_sample.py")])
12
+ au001 = [f for f in findings if f.rule_id == "AU001"]
13
+ assert len(au001) == 6
14
+ callees = {f.evidence["callee"] for f in au001}
15
+ assert "os.system" in callees
16
+ assert "subprocess.run" in callees
17
+ assert "subprocess.call" in callees
18
+ assert "subprocess.getoutput" in callees
19
+
20
+
21
+ def test_au001_ignores_safe_cases():
22
+ findings = scan([str(FIXTURES / "au001_sample.py")])
23
+ flagged_lines = {f.lineno for f in findings}
24
+ # safe cases are on lines 26-32; none should appear
25
+ for line in range(26, 33):
26
+ assert line not in flagged_lines, f"false positive on line {line}"
27
+
28
+
29
+ def test_au001_confidence():
30
+ findings = scan([str(FIXTURES / "au001_sample.py")])
31
+ for f in findings:
32
+ assert f.confidence == 0.95
33
+
34
+
35
+ def test_au001_severity():
36
+ findings = scan([str(FIXTURES / "au001_sample.py")])
37
+ for f in findings:
38
+ assert f.severity == "error"
39
+
40
+
41
+ def test_scan_returns_sorted():
42
+ findings = scan([str(FIXTURES / "au001_sample.py")])
43
+ severities = [f.severity for f in findings]
44
+ assert severities == ["error"] * len(findings)
45
+
46
+
47
+ def test_scan_empty_dir(tmp_path: Path):
48
+ findings = scan([str(tmp_path)])
49
+ assert findings == []
50
+
51
+
52
+ def test_scan_syntax_error_skipped(tmp_path: Path):
53
+ bad = tmp_path / "bad.py"
54
+ bad.write_text("def x(\n")
55
+ findings = scan([str(tmp_path)])
56
+ assert findings == []
@@ -33,7 +33,6 @@ def payloads() -> list[tuple[str, str]]:
33
33
  return out
34
34
 
35
35
 
36
- # --- argv mode ---
37
36
 
38
37
 
39
38
  @pytest.mark.parametrize(
@@ -47,7 +46,6 @@ def test_payload_stays_single_argument(name: str, payload: str):
47
46
  assert p.argv == ("echo", payload)
48
47
 
49
48
 
50
- # --- shell mode ---
51
49
 
52
50
 
53
51
  @pytest.mark.parametrize(
@@ -59,7 +59,6 @@ def test_nested_template_splices():
59
59
  assert p.argv == ("git", "commit", "-m", "msg with spaces")
60
60
 
61
61
 
62
- # --- refusals ---
63
62
 
64
63
 
65
64
  def test_non_template_refused():
@@ -134,7 +133,6 @@ def test_raw_inside_real_tstring_syntax():
134
133
  assert p.argv == ("echo", "a", "b", "done")
135
134
 
136
135
 
137
- # --- shell route: rendering works now; execution arrives in 0.2 ---
138
136
 
139
137
 
140
138
  @pytest.mark.skipif(
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"
@@ -1,10 +0,0 @@
1
- """Offline AST audit for dangerous command construction. Ships with v0.3.0."""
2
-
3
- from __future__ import annotations
4
-
5
- from ..errors import ShellSafeError
6
-
7
-
8
- def scan(paths: list[str]) -> list[dict[str, object]]:
9
- """Scan paths for AU001-AU004 findings. Arrives in shellsafe 0.3."""
10
- raise ShellSafeError("audit arrives in shellsafe 0.3")
File without changes
@@ -1,44 +0,0 @@
1
- """CLI wiring: audit, demo, version. Business logic lives in the stages."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import platform
7
- import sys
8
-
9
- from . import exitcodes
10
- from ._version import __version__
11
-
12
-
13
- def _build_parser() -> argparse.ArgumentParser:
14
- parser = argparse.ArgumentParser(prog="shellsafe")
15
- parser.add_argument("-V", "--version", action="store_true")
16
- sub = parser.add_subparsers(dest="command")
17
-
18
- sub.add_parser("audit", help="scan code for dangerous command construction (v0.3)")
19
- sub.add_parser("version", help="detailed version and capability matrix")
20
- return parser
21
-
22
-
23
- def _print_version_matrix() -> None:
24
- py = platform.python_version()
25
- print(f"shellsafe {__version__} · python {py} · {sys.platform}")
26
- print("argv-mode: available")
27
- print("shell-mode: available (posix)")
28
- print("audit: arriving in 0.3")
29
-
30
-
31
- def main(argv: list[str] | None = None) -> int:
32
- args = _build_parser().parse_args(argv)
33
-
34
- if args.version:
35
- print(f"shellsafe {__version__}")
36
- return exitcodes.OK
37
- if args.command == "version":
38
- _print_version_matrix()
39
- return exitcodes.OK
40
- if args.command == "audit":
41
- print("audit arrives in shellsafe 0.3", file=sys.stderr)
42
- return exitcodes.USAGE
43
- _build_parser().print_help()
44
- return exitcodes.OK
@@ -1,35 +0,0 @@
1
- """Platform policy tables and helpers. This module IS the security policy."""
2
-
3
- from __future__ import annotations
4
-
5
- import sys
6
-
7
- # Static template text containing any of these routes the command to shell mode.
8
- # Whitespace is not here: it splits words in argv mode.
9
- METACHARACTERS: frozenset[str] = frozenset("|&;()<>$`\"'*?!#\\\n\r\t")
10
-
11
- IS_WINDOWS = sys.platform.startswith("win")
12
-
13
-
14
- def route_for(static_text: str) -> str:
15
- """Return "argv" or "shell" for the given static template text.
16
-
17
- Raises UnsupportedPlatformError when shell features are requested on Windows;
18
- cmd.exe quoting cannot be made injection-safe, so the refusal is the feature.
19
- """
20
- has_meta = any(ch in METACHARACTERS for ch in static_text)
21
- if not has_meta:
22
- return "argv"
23
- if IS_WINDOWS:
24
- raise _windows_error()
25
- return "shell"
26
-
27
-
28
- def _windows_error() -> Exception:
29
- from .errors import UnsupportedPlatformError
30
-
31
- return UnsupportedPlatformError(
32
- "shell metacharacters in a shellsafe template require posix sh; "
33
- "windows cmd.exe quoting cannot be made injection-safe. "
34
- "restructure without pipes/redirections, or run under wsl."
35
- )
@@ -1 +0,0 @@
1
- """Finding reporters: terminal and json. Arrives in shellsafe 0.3."""
File without changes
File without changes
File without changes
File without changes