shellsafe 0.2.0__tar.gz → 0.3.1__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 (60) hide show
  1. {shellsafe-0.2.0 → shellsafe-0.3.1}/.github/workflows/ci.yml +0 -1
  2. {shellsafe-0.2.0 → shellsafe-0.3.1}/CHANGELOG.md +37 -0
  3. {shellsafe-0.2.0 → shellsafe-0.3.1}/PKG-INFO +27 -5
  4. {shellsafe-0.2.0 → shellsafe-0.3.1}/README.md +26 -4
  5. {shellsafe-0.2.0 → shellsafe-0.3.1}/pyproject.toml +2 -5
  6. shellsafe-0.3.1/src/shellsafe/_version.py +1 -0
  7. shellsafe-0.3.1/src/shellsafe/audit/__init__.py +6 -0
  8. shellsafe-0.3.1/src/shellsafe/audit/scanner.py +290 -0
  9. shellsafe-0.3.1/src/shellsafe/cli.py +70 -0
  10. {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/execute.py +5 -15
  11. shellsafe-0.3.1/src/shellsafe/platforms.py +25 -0
  12. {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/raw.py +0 -1
  13. {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/render.py +5 -16
  14. shellsafe-0.3.1/tests/fixtures/audit/au001_sample.py +31 -0
  15. shellsafe-0.3.1/tests/fixtures/audit/au002_sample.py +41 -0
  16. shellsafe-0.3.1/tests/integration/test_cli_audit.py +136 -0
  17. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/integration/test_exec_posix.py +0 -6
  18. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/property/test_invariants.py +0 -2
  19. shellsafe-0.3.1/tests/unit/test_audit.py +104 -0
  20. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/unit/test_payload_corpus.py +0 -2
  21. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/unit/test_render.py +0 -2
  22. shellsafe-0.2.0/src/shellsafe/_version.py +0 -1
  23. shellsafe-0.2.0/src/shellsafe/audit/__init__.py +0 -10
  24. shellsafe-0.2.0/src/shellsafe/audit/rules.py +0 -0
  25. shellsafe-0.2.0/src/shellsafe/cli.py +0 -44
  26. shellsafe-0.2.0/src/shellsafe/platforms.py +0 -35
  27. shellsafe-0.2.0/src/shellsafe/reporters.py +0 -1
  28. {shellsafe-0.2.0 → shellsafe-0.3.1}/.github/workflows/release.yml +0 -0
  29. {shellsafe-0.2.0 → shellsafe-0.3.1}/.gitignore +0 -0
  30. {shellsafe-0.2.0 → shellsafe-0.3.1}/CONTRIBUTING.md +0 -0
  31. {shellsafe-0.2.0 → shellsafe-0.3.1}/LICENSE +0 -0
  32. {shellsafe-0.2.0 → shellsafe-0.3.1}/examples/demo.py +0 -0
  33. {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/__init__.py +0 -0
  34. {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/__main__.py +0 -0
  35. {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/errors.py +0 -0
  36. {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/exitcodes.py +0 -0
  37. {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/py.typed +0 -0
  38. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/golden/.gitkeep +0 -0
  39. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/integration/.gitkeep +0 -0
  40. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/README.md +0 -0
  41. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_01_semicolon.txt +0 -0
  42. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_02_substitution.txt +0 -0
  43. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_03_backticks.txt +0 -0
  44. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_04_background_chain.txt +0 -0
  45. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_05_or_chain.txt +0 -0
  46. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
  47. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
  48. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
  49. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
  50. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
  51. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
  52. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_12_newline.txt +0 -0
  53. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
  54. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_14_whitespace.txt +0 -0
  55. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
  56. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/property/.gitkeep +0 -0
  57. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/unit/test_cli.py +0 -0
  58. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/unit/test_errors.py +0 -0
  59. {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/unit/test_public_surface.py +0 -0
  60. {shellsafe-0.2.0 → shellsafe-0.3.1}/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,43 @@
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.1] - 2026-08-30
7
+
8
+ ### Added
9
+
10
+ - AU002 rule: detect `shell=True` with dynamic content (.format(), string
11
+ concatenation, %-formatting, variables) in shell executor calls
12
+ - AU002 fixture corpus: 6 positive cases + 5 safe cases
13
+ - Unit tests for AU002 detection, confidence, and severity
14
+ - Integration tests for CLI audit with AU002
15
+
16
+ ## [0.3.0] - 2026-08-29
17
+
18
+ ### Added
19
+
20
+ - Offline audit scanner: AST-based detection of f-strings passed to shell
21
+ executors (`os.system`, `subprocess.run`, `subprocess.call`, etc.)
22
+ - AU001 rule: flags f-string interpolation in shell executor arguments with
23
+ fix-hint suggesting template strings
24
+ - Import alias tracking: handles `import subprocess as sp`, `from os import
25
+ system`, etc.
26
+ - CLI `shellsafe audit` command with terminal table and JSON output
27
+ - `--json` flag for machine-readable findings
28
+ - `--severity` flag to filter by minimum severity level
29
+ - Audit fixture corpus: 6 positive cases + 4 safe cases
30
+ - Unit tests for scanner, integration tests for CLI audit
31
+
32
+ ### Changed
33
+
34
+ - Collapse `audit/rules.py` and `reporters.py` into single `audit/scanner.py`
35
+ - CLI refactored with dedicated `_run_audit` handler
36
+
37
+ ### Removed
38
+
39
+ - mypy (ruff catches real bugs)
40
+ - `cast()` calls on values that already have the right type
41
+
42
+
6
43
  ## [0.2.0] - 2026-08-27
7
44
 
8
45
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shellsafe
3
- Version: 0.2.0
3
+ Version: 0.3.1
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
@@ -47,9 +47,9 @@ subprocess.run(f"git commit -m {message}", shell=True)
47
47
  # if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
48
48
  ```
49
49
 
50
- Python planned to solve this officially (PEP 787), but that plan was postponed.
51
- So today there is no standard way to run shell commands safely with templates.
52
- This package fills that gap.
50
+ Python planned to solve this officially (PEP 787), but that PEP was deferred to
51
+ at least Python 3.15. So today there is no standard way to run shell commands
52
+ safely with templates. This package fills that gap.
53
53
 
54
54
  ## Install
55
55
 
@@ -61,7 +61,8 @@ Needs Python 3.14 or newer.
61
61
 
62
62
  ## How to use
63
63
 
64
- Run a command. Your values always stay one argument each:
64
+ Run a command. Your values always stay one argument each. `run()` never invokes
65
+ a shell, so injection is impossible on any platform:
65
66
 
66
67
  ```python
67
68
  from shellsafe import run
@@ -108,6 +109,27 @@ print(plan(t"git commit -m {message}"))
108
109
  `RAW(...)` marks content you have already made safe by hand. It is loud and easy
109
110
  to find in code review, so trust is never hidden.
110
111
 
112
+ ## Find old dangerous patterns
113
+
114
+ Already have code using f-strings in shell commands? The scanner finds them:
115
+
116
+ ```bash
117
+ shellsafe audit src/
118
+ ```
119
+
120
+ It detects f-strings passed to `os.system`, `subprocess.run(shell=True)`, and
121
+ other shell executors. Get machine-readable output:
122
+
123
+ ```bash
124
+ shellsafe audit src/ --json
125
+ ```
126
+
127
+ Filter by severity:
128
+
129
+ ```bash
130
+ shellsafe audit src/ --severity warning
131
+ ```
132
+
111
133
  ## Limits
112
134
 
113
135
  - Shell features (pipes, redirections) work on Linux and macOS only. Windows
@@ -25,9 +25,9 @@ subprocess.run(f"git commit -m {message}", shell=True)
25
25
  # if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
26
26
  ```
27
27
 
28
- Python planned to solve this officially (PEP 787), but that plan was postponed.
29
- So today there is no standard way to run shell commands safely with templates.
30
- This package fills that gap.
28
+ Python planned to solve this officially (PEP 787), but that PEP was deferred to
29
+ at least Python 3.15. So today there is no standard way to run shell commands
30
+ safely with templates. This package fills that gap.
31
31
 
32
32
  ## Install
33
33
 
@@ -39,7 +39,8 @@ Needs Python 3.14 or newer.
39
39
 
40
40
  ## How to use
41
41
 
42
- Run a command. Your values always stay one argument each:
42
+ Run a command. Your values always stay one argument each. `run()` never invokes
43
+ a shell, so injection is impossible on any platform:
43
44
 
44
45
  ```python
45
46
  from shellsafe import run
@@ -86,6 +87,27 @@ print(plan(t"git commit -m {message}"))
86
87
  `RAW(...)` marks content you have already made safe by hand. It is loud and easy
87
88
  to find in code review, so trust is never hidden.
88
89
 
90
+ ## Find old dangerous patterns
91
+
92
+ Already have code using f-strings in shell commands? The scanner finds them:
93
+
94
+ ```bash
95
+ shellsafe audit src/
96
+ ```
97
+
98
+ It detects f-strings passed to `os.system`, `subprocess.run(shell=True)`, and
99
+ other shell executors. Get machine-readable output:
100
+
101
+ ```bash
102
+ shellsafe audit src/ --json
103
+ ```
104
+
105
+ Filter by severity:
106
+
107
+ ```bash
108
+ shellsafe audit src/ --severity warning
109
+ ```
110
+
89
111
  ## Limits
90
112
 
91
113
  - 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.1"
@@ -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,290 @@
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
+ def _has_shell_true(node: ast.Call) -> bool:
102
+ for kw in node.keywords:
103
+ if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
104
+ return True
105
+ return False
106
+
107
+
108
+ def _is_dynamic_string(node: ast.expr) -> bool:
109
+ if isinstance(node, ast.JoinedStr):
110
+ return any(isinstance(v, ast.FormattedValue) for v in node.values)
111
+ if (
112
+ isinstance(node, ast.Call)
113
+ and isinstance(node.func, ast.Attribute)
114
+ and node.func.attr == "format"
115
+ ):
116
+ return True
117
+ if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
118
+ return True
119
+ return isinstance(node, (ast.Name, ast.Attribute))
120
+
121
+
122
+ def au002(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
123
+ """shell=True with dynamic content in shell-executing call."""
124
+ callee = _resolve_callee(node.func, aliases)
125
+ if callee is None:
126
+ return None
127
+ if not _has_shell_true(node):
128
+ return None
129
+ for arg in node.args:
130
+ if _is_dynamic_string(arg):
131
+ kind = "f-string" if isinstance(arg, ast.JoinedStr) else "dynamic content"
132
+ if isinstance(arg, ast.Call) and isinstance(arg.func, ast.Attribute):
133
+ kind = ".format() call"
134
+ elif isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Add):
135
+ kind = "string concatenation"
136
+ elif isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Mod):
137
+ kind = "%-format string"
138
+ elif isinstance(arg, (ast.Name, ast.Attribute)):
139
+ kind = "variable"
140
+ return Finding(
141
+ rule_id="AU002",
142
+ title="shell=True with dynamic content",
143
+ severity="warning",
144
+ confidence=0.90,
145
+ path=path,
146
+ lineno=node.lineno,
147
+ col=node.col_offset,
148
+ message=f"{callee} uses shell=True with {kind}",
149
+ evidence={
150
+ "callee": callee,
151
+ "kind": kind,
152
+ },
153
+ fix_hint='use shellsafe.run(t"...") or pass an argv list without shell=True',
154
+ )
155
+ return None
156
+
157
+
158
+ RULES: list[tuple[str, Callable[..., Finding | None]]] = [
159
+ ("AU001", au001),
160
+ ("AU002", au002),
161
+ ]
162
+
163
+
164
+ def _discover(paths: list[str]) -> list[Path]:
165
+ files: list[Path] = []
166
+ for raw in paths:
167
+ p = Path(raw)
168
+ if p.is_file() and p.suffix == ".py":
169
+ files.append(p)
170
+ elif p.is_dir():
171
+ files.extend(
172
+ f
173
+ for f in sorted(p.rglob("*.py"))
174
+ if not any(part.startswith(".") or part == "__pycache__" for part in f.parts)
175
+ )
176
+ return files
177
+
178
+
179
+ def _import_aliases(tree: ast.Module) -> dict[str, str]:
180
+ """Map local names to real module.function for executor imports."""
181
+ aliases: dict[str, str] = {}
182
+ for node in ast.walk(tree):
183
+ if isinstance(node, ast.Import):
184
+ for alias in node.names:
185
+ local = alias.asname or alias.name
186
+ aliases[local] = alias.name
187
+ elif isinstance(node, ast.ImportFrom):
188
+ module = node.module or ""
189
+ for alias in node.names:
190
+ local = alias.asname or alias.name
191
+ aliases[local] = f"{module}.{alias.name}"
192
+ return aliases
193
+
194
+
195
+ def scan(paths: list[str]) -> list[Finding]:
196
+ """Scan Python files for audit findings."""
197
+ findings: list[Finding] = []
198
+ for path in _discover(paths):
199
+ try:
200
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
201
+ except (SyntaxError, UnicodeDecodeError):
202
+ continue
203
+ aliases = _import_aliases(tree)
204
+ rel = str(path)
205
+ for node in ast.walk(tree):
206
+ if isinstance(node, ast.Call):
207
+ for _, rule in RULES:
208
+ result = rule(node, aliases, rel)
209
+ if result is not None:
210
+ findings.append(result)
211
+ findings.sort(key=lambda f: (f.severity != "error", f.path, f.lineno))
212
+ return findings
213
+
214
+
215
+ _COLORS = {"error": "\033[31m", "warning": "\033[33m", "info": "\033[36m", "reset": "\033[0m"}
216
+
217
+
218
+ def _color(text: str, severity: str) -> str:
219
+ c = _COLORS.get(severity, "")
220
+ return f"{c}{text}{_COLORS['reset']}" if c else text
221
+
222
+
223
+ def report_terminal(findings: list[Finding]) -> str:
224
+ """Render findings as a terminal table."""
225
+ if not findings:
226
+ return "no findings"
227
+ use_color = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
228
+ lines = [
229
+ f"{'SEV':<9} {'RULE':<7} {'FILE':<40} {'LINE':>5} MESSAGE",
230
+ "-" * 90,
231
+ ]
232
+ for f in findings:
233
+ sev = _color(f.severity.upper(), f.severity) if use_color else f.severity.upper()
234
+ path = f.path if len(f.path) <= 40 else "..." + f.path[-37:]
235
+ lines.append(f"{sev:<18} {f.rule_id:<7} {path:<40} {f.lineno:>5} {f.message}")
236
+ return "\n".join(lines)
237
+
238
+
239
+ def report_json(report: dict[str, object]) -> str:
240
+ """Render the full report as JSON."""
241
+ return json.dumps(report, indent=2)
242
+
243
+
244
+ def scan_report(paths: list[str]) -> dict[str, object]:
245
+ """Scan and return a report dict matching schema v1."""
246
+ start = time.monotonic()
247
+ findings = scan(paths)
248
+ duration_ms = round((time.monotonic() - start) * 1000, 1)
249
+
250
+ errors = sum(1 for f in findings if f.severity == "error" and not f.ignored)
251
+ warnings = sum(1 for f in findings if f.severity == "warning" and not f.ignored)
252
+ info = sum(1 for f in findings if f.severity == "info" and not f.ignored)
253
+ ignored = sum(1 for f in findings if f.ignored)
254
+
255
+ from .._version import __version__
256
+
257
+ return {
258
+ "schema_version": 1,
259
+ "tool": {"name": "shellsafe", "version": __version__},
260
+ "run": {
261
+ "duration_ms": duration_ms,
262
+ "host_python": f"{sys.version_info.major}.{sys.version_info.minor}"
263
+ f".{sys.version_info.micro}",
264
+ "paths": paths,
265
+ },
266
+ "summary": {
267
+ "errors": errors,
268
+ "warnings": warnings,
269
+ "info": info,
270
+ "ignored": ignored,
271
+ "verdict": "fail" if errors > 0 else "pass",
272
+ },
273
+ "findings": [
274
+ {
275
+ "rule_id": f.rule_id,
276
+ "title": f.title,
277
+ "severity": f.severity,
278
+ "confidence": f.confidence,
279
+ "path": f.path,
280
+ "lineno": f.lineno,
281
+ "col": f.col,
282
+ "message": f.message,
283
+ "evidence": f.evidence,
284
+ "fix_hint": f.fix_hint,
285
+ "ignored": f.ignored,
286
+ "ignore_reason": f.ignore_reason,
287
+ }
288
+ for f in findings
289
+ ],
290
+ }
@@ -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