shellsafe 0.1.1__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 (63) hide show
  1. {shellsafe-0.1.1 → shellsafe-0.3.0}/.github/workflows/ci.yml +0 -1
  2. shellsafe-0.3.0/CHANGELOG.md +72 -0
  3. {shellsafe-0.1.1 → shellsafe-0.3.0}/PKG-INFO +26 -2
  4. {shellsafe-0.1.1 → shellsafe-0.3.0}/README.md +25 -1
  5. {shellsafe-0.1.1 → shellsafe-0.3.0}/pyproject.toml +5 -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.1.1 → shellsafe-0.3.0}/src/shellsafe/execute.py +29 -27
  11. shellsafe-0.3.0/src/shellsafe/platforms.py +25 -0
  12. {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/raw.py +0 -1
  13. {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/render.py +9 -22
  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.3.0/tests/integration/test_exec_posix.py +94 -0
  17. shellsafe-0.3.0/tests/property/test_invariants.py +76 -0
  18. shellsafe-0.3.0/tests/unit/test_audit.py +56 -0
  19. shellsafe-0.3.0/tests/unit/test_payload_corpus.py +72 -0
  20. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/unit/test_render.py +0 -2
  21. shellsafe-0.1.1/CHANGELOG.md +0 -22
  22. shellsafe-0.1.1/src/shellsafe/_version.py +0 -1
  23. shellsafe-0.1.1/src/shellsafe/audit/__init__.py +0 -10
  24. shellsafe-0.1.1/src/shellsafe/audit/rules.py +0 -0
  25. shellsafe-0.1.1/src/shellsafe/cli.py +0 -44
  26. shellsafe-0.1.1/src/shellsafe/platforms.py +0 -35
  27. shellsafe-0.1.1/src/shellsafe/reporters.py +0 -1
  28. shellsafe-0.1.1/tests/integration/test_exec_posix.py +0 -44
  29. shellsafe-0.1.1/tests/property/test_invariants.py +0 -35
  30. shellsafe-0.1.1/tests/unit/test_payload_corpus.py +0 -43
  31. {shellsafe-0.1.1 → shellsafe-0.3.0}/.github/workflows/release.yml +0 -0
  32. {shellsafe-0.1.1 → shellsafe-0.3.0}/.gitignore +0 -0
  33. {shellsafe-0.1.1 → shellsafe-0.3.0}/CONTRIBUTING.md +0 -0
  34. {shellsafe-0.1.1 → shellsafe-0.3.0}/LICENSE +0 -0
  35. {shellsafe-0.1.1 → shellsafe-0.3.0}/examples/demo.py +0 -0
  36. {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/__init__.py +0 -0
  37. {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/__main__.py +0 -0
  38. {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/errors.py +0 -0
  39. {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/exitcodes.py +0 -0
  40. {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/py.typed +0 -0
  41. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/golden/.gitkeep +0 -0
  42. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/integration/.gitkeep +0 -0
  43. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/README.md +0 -0
  44. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_01_semicolon.txt +0 -0
  45. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_02_substitution.txt +0 -0
  46. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_03_backticks.txt +0 -0
  47. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_04_background_chain.txt +0 -0
  48. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_05_or_chain.txt +0 -0
  49. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
  50. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
  51. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
  52. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
  53. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
  54. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
  55. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_12_newline.txt +0 -0
  56. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
  57. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_14_whitespace.txt +0 -0
  58. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
  59. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/property/.gitkeep +0 -0
  60. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/unit/test_cli.py +0 -0
  61. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/unit/test_errors.py +0 -0
  62. {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/unit/test_public_surface.py +0 -0
  63. {shellsafe-0.1.1 → 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/
@@ -0,0 +1,72 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. Format follows
4
+ Keep a Changelog; versioning follows SemVer.
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
+
33
+ ## [0.2.0] - 2026-08-27
34
+
35
+ ### Added
36
+
37
+ - Shell-mode execution: `shx()` runs pipes, redirections, and globs via
38
+ `/bin/sh -c` on Linux and macOS; interpolated values are `shlex`-quoted
39
+ automatically
40
+ - Shell-mode payload corpus: 15 injection cases verified as single tokens via
41
+ `shlex.split` round-trip
42
+ - Shell-mode property tests: arbitrary values stay single shell tokens under
43
+ pipe templates
44
+ - Cyclomatic complexity lint (`C901`, max-complexity 10) enabled in CI
45
+
46
+ ### Changed
47
+
48
+ - `run()` now rejects templates that contain shell metacharacters with guidance
49
+ to use `shx()` instead
50
+ - CLI version matrix shows shell-mode availability
51
+
52
+ ### Removed
53
+
54
+ - Dead `METACHARACTERS` constant from render.py (platforms.py owns it)
55
+
56
+ ## [0.1.1] - 2026-08-24
57
+
58
+ ### Changed
59
+
60
+ - Project description rewritten
61
+ - README restructured for PyPI: package page now leads with the why, usage and
62
+ limits; contributing details stay in the repository only
63
+
64
+ ## [0.1.0] - 2026-08-24
65
+
66
+ ### Added
67
+
68
+ - Argv-mode rendering and execution via template strings
69
+ - RAW trust marker with argv splicing
70
+ - capture() helper with utf-8 stdout/stderr
71
+ - plan() helper for inspecting commands before execution
72
+ - Injection payload corpus as a release gate; all cases render inert
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shellsafe
3
- Version: 0.1.1
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,9 +108,33 @@ 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
- - On Windows, commands with pipes do not work. Plain commands work fully.
134
+ - Shell features (pipes, redirections) work on Linux and macOS only. Windows
135
+ supports plain commands only.
136
+ - `run()` refuses templates that contain shell metacharacters. Use `shx()` for
137
+ those.
114
138
  - Byte values are rejected. Decode them first.
115
139
  - We keep your command safe to build and run. Testing what your command does is
116
140
  still your job.
@@ -86,9 +86,33 @@ 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
- - On Windows, commands with pipes do not work. Plain commands work fully.
112
+ - Shell features (pipes, redirections) work on Linux and macOS only. Windows
113
+ supports plain commands only.
114
+ - `run()` refuses templates that contain shell metacharacters. Use `shx()` for
115
+ those.
92
116
  - Byte values are rejected. Decode them first.
93
117
  - We keep your command safe to build and run. Testing what your command does is
94
118
  still your job.
@@ -45,11 +45,10 @@ target-version = "py314"
45
45
  line-length = 100
46
46
 
47
47
  [tool.ruff.lint]
48
- select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
48
+ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF", "C901"]
49
49
 
50
- [tool.mypy]
51
- strict = true
52
- python_version = "3.14"
50
+ [tool.ruff.lint.mccabe]
51
+ max-complexity = 10
53
52
 
54
53
  [tool.pytest.ini_options]
55
54
  testpaths = ["tests"]
@@ -59,12 +58,13 @@ addopts = "-q --tb=short"
59
58
  # RAW() is a deliberate loud trust marker; the uppercase name is the API.
60
59
  "src/shellsafe/raw.py" = ["N802"]
61
60
  "src/shellsafe/__init__.py" = ["N999"]
61
+ # Fixture files contain planted violations and undefined names on purpose.
62
+ "tests/fixtures/**" = ["F821", "F841"]
62
63
 
63
64
  [dependency-groups]
64
65
  dev = [
65
66
  "pytest>=8",
66
67
  "ruff>=0.5",
67
- "mypy>=1.10",
68
68
  "hypothesis>=6",
69
69
  "build>=1.2",
70
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,20 +1,14 @@
1
1
  """Subprocess wrappers over rendered ExecutionPlans."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  import subprocess
4
+ import sys
6
5
  from dataclasses import dataclass
7
- from typing import Any, cast
6
+ from typing import Any
8
7
 
9
8
  from .errors import ArgvOnlyError, ShellSafeError
10
9
  from .raw import Raw # noqa: F401 (re-exported through the package root)
11
10
  from .render import ExecutionPlan
12
11
 
13
- _SHELL_MODE_PENDS = (
14
- "shell-mode execution arrives in shellsafe 0.2; "
15
- "this release covers argv-mode commands"
16
- )
17
-
18
12
  _ALLOWED_KWARGS = frozenset(
19
13
  {
20
14
  "check",
@@ -33,11 +27,11 @@ _ALLOWED_KWARGS = frozenset(
33
27
  )
34
28
 
35
29
 
36
- def _validate_kwargs(kwargs: dict[str, object]) -> None:
30
+ def _validate_kwargs(kwargs: dict[str, object]):
37
31
  if "shell" in kwargs:
38
32
  raise ShellSafeError(
39
- "shellsafe never passes shell=True; use shx() on posix for pipes and "
40
- "redirections"
33
+ "shellsafe never passes shell=True; use shx() for pipes and "
34
+ "redirections on posix"
41
35
  )
42
36
  unknown = set(kwargs) - _ALLOWED_KWARGS
43
37
  if unknown:
@@ -54,6 +48,10 @@ def plan(template: object) -> ExecutionPlan:
54
48
  return render_plan(template)
55
49
 
56
50
 
51
+ def _pass_through(kwargs: dict[str, object]) -> dict[str, Any]:
52
+ return {k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS}
53
+
54
+
57
55
  def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
58
56
  """Render the template and execute it.
59
57
 
@@ -64,15 +62,17 @@ def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[st
64
62
  _validate_kwargs(kwargs)
65
63
  rendered = plan(template)
66
64
  if rendered.mode == "shell":
67
- raise ShellSafeError(_SHELL_MODE_PENDS)
65
+ if sys.platform.startswith("win"):
66
+ raise ShellSafeError(
67
+ "shell mode is posix-only; restructure the command without "
68
+ "pipes or redirections, or run under wsl"
69
+ )
70
+ raise ShellSafeError(
71
+ "this command contains shell metacharacters; use shx() instead of "
72
+ "run() to execute pipes and redirections"
73
+ )
68
74
  assert rendered.argv is not None
69
- # passthrough boundary: values are caller-owned subprocess kwargs
70
- typed_kwargs = cast(
71
- "dict[str, Any]",
72
- {k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS},
73
- )
74
- result = subprocess.run(rendered.argv, **typed_kwargs)
75
- return cast("subprocess.CompletedProcess[str]", result)
75
+ return subprocess.run(rendered.argv, **_pass_through(kwargs))
76
76
 
77
77
 
78
78
  @dataclass(frozen=True, slots=True)
@@ -91,8 +91,6 @@ def capture(template: object, /, **kwargs: object) -> CaptureResult:
91
91
  kwargs["text"] = True
92
92
  kwargs["encoding"] = "utf-8"
93
93
  completed = run(template, **kwargs)
94
- assert isinstance(completed.stdout, str)
95
- assert isinstance(completed.stderr, str)
96
94
  return CaptureResult(
97
95
  stdout=completed.stdout,
98
96
  stderr=completed.stderr,
@@ -101,12 +99,12 @@ def capture(template: object, /, **kwargs: object) -> CaptureResult:
101
99
  )
102
100
 
103
101
 
104
- def shx(template: object, /, **kwargs: object) -> object:
105
- """Shell-route alias for templates that need pipes or redirections.
102
+ def shx(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
103
+ """Execute a shell-routed template (pipes, redirections, globs).
106
104
 
107
- Raises ArgvOnlyError when the template needs no shell at all, so a missing
108
- pipe is never silently ignored. Full shell execution ships in shellsafe 0.2;
109
- rendering and inspection work today via shellsafe.plan().
105
+ Interpolated values are POSIX shell-quoted automatically. The rendered line
106
+ runs under /bin/sh -c. Raises ArgvOnlyError when the template has no shell
107
+ metacharacters (use run() instead).
110
108
  """
111
109
  _validate_kwargs(kwargs)
112
110
  rendered = plan(template)
@@ -114,4 +112,8 @@ def shx(template: object, /, **kwargs: object) -> object:
114
112
  raise ArgvOnlyError(
115
113
  "template contains no shell metacharacters; use run() instead"
116
114
  )
117
- raise ShellSafeError(_SHELL_MODE_PENDS)
115
+ assert rendered.shell_line is not None
116
+ return subprocess.run(
117
+ ["/bin/sh", "-c", rendered.shell_line],
118
+ **_pass_through(kwargs),
119
+ )
@@ -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