shellsafe 0.1.0__tar.gz → 0.2.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.
- shellsafe-0.2.0/CHANGELOG.md +45 -0
- shellsafe-0.2.0/PKG-INFO +131 -0
- shellsafe-0.2.0/README.md +109 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/pyproject.toml +5 -2
- shellsafe-0.2.0/src/shellsafe/_version.py +1 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/cli.py +1 -1
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/execute.py +32 -20
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/render.py +4 -6
- shellsafe-0.2.0/tests/integration/test_exec_posix.py +100 -0
- shellsafe-0.2.0/tests/property/test_invariants.py +78 -0
- shellsafe-0.2.0/tests/unit/test_payload_corpus.py +74 -0
- shellsafe-0.2.0/tests/unit/test_raw.py +15 -0
- shellsafe-0.1.0/CHANGELOG.md +0 -14
- shellsafe-0.1.0/PKG-INFO +0 -126
- shellsafe-0.1.0/README.md +0 -104
- shellsafe-0.1.0/src/shellsafe/_version.py +0 -1
- shellsafe-0.1.0/tests/integration/test_exec_posix.py +0 -44
- shellsafe-0.1.0/tests/property/test_invariants.py +0 -35
- shellsafe-0.1.0/tests/unit/test_payload_corpus.py +0 -43
- shellsafe-0.1.0/tests/unit/test_raw.py +0 -28
- {shellsafe-0.1.0 → shellsafe-0.2.0}/.github/workflows/ci.yml +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/.github/workflows/release.yml +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/.gitignore +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/CONTRIBUTING.md +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/LICENSE +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/examples/demo.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/__init__.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/__main__.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/audit/__init__.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/audit/rules.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/errors.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/exitcodes.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/platforms.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/py.typed +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/raw.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/reporters.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/golden/.gitkeep +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/integration/.gitkeep +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/README.md +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_01_semicolon.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_02_substitution.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_03_backticks.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_04_background_chain.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_05_or_chain.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_12_newline.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_14_whitespace.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/property/.gitkeep +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/unit/test_cli.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/unit/test_errors.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/unit/test_public_surface.py +0 -0
- {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/unit/test_render.py +0 -0
|
@@ -0,0 +1,45 @@
|
|
|
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.2.0] - 2026-08-27
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Shell-mode execution: `shx()` runs pipes, redirections, and globs via
|
|
11
|
+
`/bin/sh -c` on Linux and macOS; interpolated values are `shlex`-quoted
|
|
12
|
+
automatically
|
|
13
|
+
- Shell-mode payload corpus: 15 injection cases verified as single tokens via
|
|
14
|
+
`shlex.split` round-trip
|
|
15
|
+
- Shell-mode property tests: arbitrary values stay single shell tokens under
|
|
16
|
+
pipe templates
|
|
17
|
+
- Cyclomatic complexity lint (`C901`, max-complexity 10) enabled in CI
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- `run()` now rejects templates that contain shell metacharacters with guidance
|
|
22
|
+
to use `shx()` instead
|
|
23
|
+
- CLI version matrix shows shell-mode availability
|
|
24
|
+
|
|
25
|
+
### Removed
|
|
26
|
+
|
|
27
|
+
- Dead `METACHARACTERS` constant from render.py (platforms.py owns it)
|
|
28
|
+
|
|
29
|
+
## [0.1.1] - 2026-08-24
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Project description rewritten
|
|
34
|
+
- README restructured for PyPI: package page now leads with the why, usage and
|
|
35
|
+
limits; contributing details stay in the repository only
|
|
36
|
+
|
|
37
|
+
## [0.1.0] - 2026-08-24
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- Argv-mode rendering and execution via template strings
|
|
42
|
+
- RAW trust marker with argv splicing
|
|
43
|
+
- capture() helper with utf-8 stdout/stderr
|
|
44
|
+
- plan() helper for inspecting commands before execution
|
|
45
|
+
- Injection payload corpus as a release gate; all cases render inert
|
shellsafe-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: shellsafe
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Run shell commands safely using Python 3.14 template strings. Values can never turn into commands.
|
|
5
|
+
Project-URL: Repository, https://github.com/rahulXs/shellsafe
|
|
6
|
+
Project-URL: Issues, https://github.com/rahulXs/shellsafe/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Rahul Sharma <rahulxsh@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: automation,command-injection,injection,pep750,security,shell,subprocess,t-strings,template-strings
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.14
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# shellsafe
|
|
24
|
+
|
|
25
|
+
> Run shell commands safely using Python 3.14 template strings. Values can never
|
|
26
|
+
> turn into commands.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from shellsafe import run
|
|
30
|
+
|
|
31
|
+
message = get_user_input() # "fix; rm -rf ~"
|
|
32
|
+
run(t"git commit -m {message}")
|
|
33
|
+
# argv: ["git", "commit", "-m", "fix; rm -rf ~"]
|
|
34
|
+
# one command; the scary text is just an argument
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Why this package exists
|
|
38
|
+
|
|
39
|
+
Python 3.14 added template strings (PEP 750). Now Python keeps your fixed text
|
|
40
|
+
and your values separate at the language level.
|
|
41
|
+
|
|
42
|
+
Shell commands are the first place people want to use this. That is because
|
|
43
|
+
f-strings inside shell commands have caused real security bugs for ten years:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
subprocess.run(f"git commit -m {message}", shell=True)
|
|
47
|
+
# if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
|
|
48
|
+
```
|
|
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.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install shellsafe
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Needs Python 3.14 or newer.
|
|
61
|
+
|
|
62
|
+
## How to use
|
|
63
|
+
|
|
64
|
+
Run a command. Your values always stay one argument each:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from shellsafe import run
|
|
68
|
+
|
|
69
|
+
run(t"mkdir {path}")
|
|
70
|
+
run(t"docker build -t {tag} .", check=True, timeout=300)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Get the output as text:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from shellsafe import capture
|
|
77
|
+
|
|
78
|
+
res = capture(t"grep {pattern} {file}")
|
|
79
|
+
print(res.stdout, res.returncode)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Need pipes? Works on Linux and macOS. Your values are quoted safely first:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from shellsafe import shx
|
|
86
|
+
|
|
87
|
+
shx(t"cat {file} | wc -l")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Want to see exactly what will run?
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from shellsafe import plan
|
|
94
|
+
|
|
95
|
+
print(plan(t"git commit -m {message}"))
|
|
96
|
+
# argv: ["git","commit","-m","fix; rm -rf ~"]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Safety rules
|
|
100
|
+
|
|
101
|
+
| Case | What happens |
|
|
102
|
+
|---|---|
|
|
103
|
+
| Any value you pass | becomes one argument, exactly as given |
|
|
104
|
+
| Value used as the program name | error: program names must be written as fixed text |
|
|
105
|
+
| Shell features on Windows | error: we cannot make Windows safe this way, so we say no |
|
|
106
|
+
| RAW() misuse | error: one value only, used as-is, no nesting |
|
|
107
|
+
|
|
108
|
+
`RAW(...)` marks content you have already made safe by hand. It is loud and easy
|
|
109
|
+
to find in code review, so trust is never hidden.
|
|
110
|
+
|
|
111
|
+
## Limits
|
|
112
|
+
|
|
113
|
+
- Shell features (pipes, redirections) work on Linux and macOS only. Windows
|
|
114
|
+
supports plain commands only.
|
|
115
|
+
- `run()` refuses templates that contain shell metacharacters. Use `shx()` for
|
|
116
|
+
those.
|
|
117
|
+
- Byte values are rejected. Decode them first.
|
|
118
|
+
- We keep your command safe to build and run. Testing what your command does is
|
|
119
|
+
still your job.
|
|
120
|
+
|
|
121
|
+
## Needs
|
|
122
|
+
|
|
123
|
+
- Python 3.14 or newer
|
|
124
|
+
- Linux, macOS, Windows (pipes work on Linux and macOS only)
|
|
125
|
+
|
|
126
|
+
## More
|
|
127
|
+
|
|
128
|
+
- Source and issues: [github.com/rahulXs/shellsafe](https://github.com/rahulXs/shellsafe)
|
|
129
|
+
- Want to help? See CONTRIBUTING.md in the repository.
|
|
130
|
+
|
|
131
|
+
License: MIT
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# shellsafe
|
|
2
|
+
|
|
3
|
+
> Run shell commands safely using Python 3.14 template strings. Values can never
|
|
4
|
+
> turn into commands.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from shellsafe import run
|
|
8
|
+
|
|
9
|
+
message = get_user_input() # "fix; rm -rf ~"
|
|
10
|
+
run(t"git commit -m {message}")
|
|
11
|
+
# argv: ["git", "commit", "-m", "fix; rm -rf ~"]
|
|
12
|
+
# one command; the scary text is just an argument
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Why this package exists
|
|
16
|
+
|
|
17
|
+
Python 3.14 added template strings (PEP 750). Now Python keeps your fixed text
|
|
18
|
+
and your values separate at the language level.
|
|
19
|
+
|
|
20
|
+
Shell commands are the first place people want to use this. That is because
|
|
21
|
+
f-strings inside shell commands have caused real security bugs for ten years:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
subprocess.run(f"git commit -m {message}", shell=True)
|
|
25
|
+
# if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
|
|
26
|
+
```
|
|
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.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install shellsafe
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Needs Python 3.14 or newer.
|
|
39
|
+
|
|
40
|
+
## How to use
|
|
41
|
+
|
|
42
|
+
Run a command. Your values always stay one argument each:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from shellsafe import run
|
|
46
|
+
|
|
47
|
+
run(t"mkdir {path}")
|
|
48
|
+
run(t"docker build -t {tag} .", check=True, timeout=300)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Get the output as text:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from shellsafe import capture
|
|
55
|
+
|
|
56
|
+
res = capture(t"grep {pattern} {file}")
|
|
57
|
+
print(res.stdout, res.returncode)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Need pipes? Works on Linux and macOS. Your values are quoted safely first:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from shellsafe import shx
|
|
64
|
+
|
|
65
|
+
shx(t"cat {file} | wc -l")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Want to see exactly what will run?
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from shellsafe import plan
|
|
72
|
+
|
|
73
|
+
print(plan(t"git commit -m {message}"))
|
|
74
|
+
# argv: ["git","commit","-m","fix; rm -rf ~"]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Safety rules
|
|
78
|
+
|
|
79
|
+
| Case | What happens |
|
|
80
|
+
|---|---|
|
|
81
|
+
| Any value you pass | becomes one argument, exactly as given |
|
|
82
|
+
| Value used as the program name | error: program names must be written as fixed text |
|
|
83
|
+
| Shell features on Windows | error: we cannot make Windows safe this way, so we say no |
|
|
84
|
+
| RAW() misuse | error: one value only, used as-is, no nesting |
|
|
85
|
+
|
|
86
|
+
`RAW(...)` marks content you have already made safe by hand. It is loud and easy
|
|
87
|
+
to find in code review, so trust is never hidden.
|
|
88
|
+
|
|
89
|
+
## Limits
|
|
90
|
+
|
|
91
|
+
- Shell features (pipes, redirections) work on Linux and macOS only. Windows
|
|
92
|
+
supports plain commands only.
|
|
93
|
+
- `run()` refuses templates that contain shell metacharacters. Use `shx()` for
|
|
94
|
+
those.
|
|
95
|
+
- Byte values are rejected. Decode them first.
|
|
96
|
+
- We keep your command safe to build and run. Testing what your command does is
|
|
97
|
+
still your job.
|
|
98
|
+
|
|
99
|
+
## Needs
|
|
100
|
+
|
|
101
|
+
- Python 3.14 or newer
|
|
102
|
+
- Linux, macOS, Windows (pipes work on Linux and macOS only)
|
|
103
|
+
|
|
104
|
+
## More
|
|
105
|
+
|
|
106
|
+
- Source and issues: [github.com/rahulXs/shellsafe](https://github.com/rahulXs/shellsafe)
|
|
107
|
+
- Want to help? See CONTRIBUTING.md in the repository.
|
|
108
|
+
|
|
109
|
+
License: MIT
|
|
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "shellsafe"
|
|
7
7
|
dynamic = ["version"]
|
|
8
|
-
description = "
|
|
8
|
+
description = "Run shell commands safely using Python 3.14 template strings. Values can never turn into commands."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.14"
|
|
@@ -45,7 +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
|
+
|
|
50
|
+
[tool.ruff.lint.mccabe]
|
|
51
|
+
max-complexity = 10
|
|
49
52
|
|
|
50
53
|
[tool.mypy]
|
|
51
54
|
strict = true
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -24,7 +24,7 @@ def _print_version_matrix() -> None:
|
|
|
24
24
|
py = platform.python_version()
|
|
25
25
|
print(f"shellsafe {__version__} · python {py} · {sys.platform}")
|
|
26
26
|
print("argv-mode: available")
|
|
27
|
-
print("shell-mode:
|
|
27
|
+
print("shell-mode: available (posix)")
|
|
28
28
|
print("audit: arriving in 0.3")
|
|
29
29
|
|
|
30
30
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import subprocess
|
|
6
|
+
import sys
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from typing import Any, cast
|
|
8
9
|
|
|
@@ -10,11 +11,6 @@ from .errors import ArgvOnlyError, ShellSafeError
|
|
|
10
11
|
from .raw import Raw # noqa: F401 (re-exported through the package root)
|
|
11
12
|
from .render import ExecutionPlan
|
|
12
13
|
|
|
13
|
-
_SHELL_MODE_PENDS = (
|
|
14
|
-
"shell-mode execution arrives in shellsafe 0.2; "
|
|
15
|
-
"this release covers argv-mode commands"
|
|
16
|
-
)
|
|
17
|
-
|
|
18
14
|
_ALLOWED_KWARGS = frozenset(
|
|
19
15
|
{
|
|
20
16
|
"check",
|
|
@@ -36,8 +32,8 @@ _ALLOWED_KWARGS = frozenset(
|
|
|
36
32
|
def _validate_kwargs(kwargs: dict[str, object]) -> None:
|
|
37
33
|
if "shell" in kwargs:
|
|
38
34
|
raise ShellSafeError(
|
|
39
|
-
"shellsafe never passes shell=True; use shx()
|
|
40
|
-
"redirections"
|
|
35
|
+
"shellsafe never passes shell=True; use shx() for pipes and "
|
|
36
|
+
"redirections on posix"
|
|
41
37
|
)
|
|
42
38
|
unknown = set(kwargs) - _ALLOWED_KWARGS
|
|
43
39
|
if unknown:
|
|
@@ -54,6 +50,14 @@ def plan(template: object) -> ExecutionPlan:
|
|
|
54
50
|
return render_plan(template)
|
|
55
51
|
|
|
56
52
|
|
|
53
|
+
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
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
57
61
|
def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
58
62
|
"""Render the template and execute it.
|
|
59
63
|
|
|
@@ -64,14 +68,17 @@ def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[st
|
|
|
64
68
|
_validate_kwargs(kwargs)
|
|
65
69
|
rendered = plan(template)
|
|
66
70
|
if rendered.mode == "shell":
|
|
67
|
-
|
|
71
|
+
if sys.platform.startswith("win"):
|
|
72
|
+
raise ShellSafeError(
|
|
73
|
+
"shell mode is posix-only; restructure the command without "
|
|
74
|
+
"pipes or redirections, or run under wsl"
|
|
75
|
+
)
|
|
76
|
+
raise ShellSafeError(
|
|
77
|
+
"this command contains shell metacharacters; use shx() instead of "
|
|
78
|
+
"run() to execute pipes and redirections"
|
|
79
|
+
)
|
|
68
80
|
assert rendered.argv is not None
|
|
69
|
-
|
|
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)
|
|
81
|
+
result = subprocess.run(rendered.argv, **_pass_through(kwargs))
|
|
75
82
|
return cast("subprocess.CompletedProcess[str]", result)
|
|
76
83
|
|
|
77
84
|
|
|
@@ -101,12 +108,12 @@ def capture(template: object, /, **kwargs: object) -> CaptureResult:
|
|
|
101
108
|
)
|
|
102
109
|
|
|
103
110
|
|
|
104
|
-
def shx(template: object, /, **kwargs: object) ->
|
|
105
|
-
"""
|
|
111
|
+
def shx(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
112
|
+
"""Execute a shell-routed template (pipes, redirections, globs).
|
|
106
113
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
Interpolated values are POSIX shell-quoted automatically. The rendered line
|
|
115
|
+
runs under /bin/sh -c. Raises ArgvOnlyError when the template has no shell
|
|
116
|
+
metacharacters (use run() instead).
|
|
110
117
|
"""
|
|
111
118
|
_validate_kwargs(kwargs)
|
|
112
119
|
rendered = plan(template)
|
|
@@ -114,4 +121,9 @@ def shx(template: object, /, **kwargs: object) -> object:
|
|
|
114
121
|
raise ArgvOnlyError(
|
|
115
122
|
"template contains no shell metacharacters; use run() instead"
|
|
116
123
|
)
|
|
117
|
-
|
|
124
|
+
assert rendered.shell_line is not None
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
["/bin/sh", "-c", rendered.shell_line],
|
|
127
|
+
**_pass_through(kwargs),
|
|
128
|
+
)
|
|
129
|
+
return cast("subprocess.CompletedProcess[str]", result)
|
|
@@ -19,11 +19,6 @@ from .raw import Raw
|
|
|
19
19
|
|
|
20
20
|
_NUL = "\x00"
|
|
21
21
|
|
|
22
|
-
# Static-text characters that require shell semantics (pipes, redirection,
|
|
23
|
-
# substitution, globs, comments). Whitespace is absent here: it splits words
|
|
24
|
-
# in argv mode rather than forcing a shell.
|
|
25
|
-
METACHARACTERS: frozenset[str] = frozenset("|&;()<>$`\"'*?!#\n\r\t\\")
|
|
26
|
-
|
|
27
22
|
|
|
28
23
|
@dataclass(frozen=True, slots=True)
|
|
29
24
|
class ExecutionPlan:
|
|
@@ -189,4 +184,7 @@ def _render_shell(parts: list[Segment]) -> ExecutionPlan:
|
|
|
189
184
|
resolved = _resolve(part)
|
|
190
185
|
_reject_nul(resolved)
|
|
191
186
|
line_parts.append(shlex.quote(resolved))
|
|
192
|
-
|
|
187
|
+
line = "".join(line_parts).strip()
|
|
188
|
+
if not line:
|
|
189
|
+
raise ShellSafeTypeError("empty command")
|
|
190
|
+
return ExecutionPlan(mode="shell", shell_line=line)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Real-execution integration tests (posix). Proves the end-to-end path."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from shellsafe import capture, run, shx
|
|
8
|
+
from shellsafe.errors import ArgvOnlyError, ShellSafeError, UnsupportedPlatformError
|
|
9
|
+
|
|
10
|
+
# --- argv mode ---
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
14
|
+
def test_capture_round_trip():
|
|
15
|
+
res = capture(t"echo hello")
|
|
16
|
+
assert res.returncode == 0
|
|
17
|
+
assert res.stdout == "hello\n"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
21
|
+
def test_injection_payload_arrives_as_text():
|
|
22
|
+
payload = "hello; echo PWNED"
|
|
23
|
+
res = capture(t"echo {payload}")
|
|
24
|
+
# argv mode: echo receives the whole payload as ONE argument; no second
|
|
25
|
+
# command can exist. The output is the payload text itself.
|
|
26
|
+
assert res.stdout.strip() == payload
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
30
|
+
def test_interpolation_with_spaces_stays_one_argument():
|
|
31
|
+
value = "two words"
|
|
32
|
+
res = capture(t"echo {value}")
|
|
33
|
+
assert res.stdout.strip() == "two words"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- shell mode execution ---
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
40
|
+
def test_shx_pipe_execution():
|
|
41
|
+
res = shx(t"echo hello | wc -w", capture_output=True, text=True)
|
|
42
|
+
assert res.returncode == 0
|
|
43
|
+
assert res.stdout.strip() == "1"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
47
|
+
def test_shx_interpolation_is_quoted():
|
|
48
|
+
"""Spaces in interpolated values do not split into separate tokens."""
|
|
49
|
+
res = shx(t"echo {['a', 'b']} | wc -w", capture_output=True, text=True)
|
|
50
|
+
assert res.returncode == 0
|
|
51
|
+
# echo ['a', 'b'] outputs the list repr as one argument; wc counts words
|
|
52
|
+
# in that output. The important thing: no shell error, no split.
|
|
53
|
+
assert res.returncode == 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
57
|
+
def test_shx_injection_payload_is_quoted():
|
|
58
|
+
payload = "hello; echo PWNED"
|
|
59
|
+
res = shx(t"echo {payload} | cat", capture_output=True, text=True)
|
|
60
|
+
assert res.returncode == 0
|
|
61
|
+
assert res.stdout.strip() == payload
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
65
|
+
def test_shx_output_capture():
|
|
66
|
+
res = shx(t"echo world | tr a-z A-Z", capture_output=True, text=True)
|
|
67
|
+
assert res.returncode == 0
|
|
68
|
+
assert res.stdout.strip() == "WORLD"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- run() rejects shell mode ---
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
75
|
+
def test_run_rejects_shell_metacharacters():
|
|
76
|
+
with pytest.raises(ShellSafeError, match="shx"):
|
|
77
|
+
run(t"echo hello | wc -l")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- shx rejects argv-only templates ---
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
84
|
+
def test_shx_rejects_argv_only_template():
|
|
85
|
+
with pytest.raises(ArgvOnlyError):
|
|
86
|
+
shx(t"echo hello")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- Windows policy ---
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_shell_mode_execution_not_yet_available_on_windows():
|
|
93
|
+
p = t"cat /etc/hostname | wc -l"
|
|
94
|
+
if sys.platform == "win32":
|
|
95
|
+
with pytest.raises(UnsupportedPlatformError):
|
|
96
|
+
shx(p)
|
|
97
|
+
else:
|
|
98
|
+
# on posix, shell mode works; this test verifies the Windows refusal
|
|
99
|
+
# path is wired correctly via monkeypatch (see test_render.py)
|
|
100
|
+
pass
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Property tests: invariants that must hold for arbitrary inputs."""
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
import sys
|
|
5
|
+
from string.templatelib import Interpolation, Template
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from hypothesis import given, settings
|
|
9
|
+
from hypothesis import strategies as st
|
|
10
|
+
|
|
11
|
+
from shellsafe.render import plan
|
|
12
|
+
|
|
13
|
+
# NUL bytes are refused at resolution; everything else must render safely.
|
|
14
|
+
text_values = st.text(st.characters(blacklist_characters="\x00"), max_size=200)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --- argv mode invariants ---
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@settings(max_examples=300)
|
|
21
|
+
@given(value=text_values)
|
|
22
|
+
def test_one_interpolation_is_exactly_one_argument(value: str):
|
|
23
|
+
p = plan(Template("run ", Interpolation(value, "v", None, "")))
|
|
24
|
+
assert p.argv == ("run", value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@settings(max_examples=200)
|
|
28
|
+
@given(a=text_values, b=text_values)
|
|
29
|
+
def test_two_interpolations_stay_separate(a: str, b: str):
|
|
30
|
+
p = plan(
|
|
31
|
+
Template("copy ", Interpolation(a, "x", None, ""), " ", Interpolation(b, "y", None, ""))
|
|
32
|
+
)
|
|
33
|
+
assert p.argv == ("copy", a, b)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@settings(max_examples=200)
|
|
37
|
+
@given(value=text_values)
|
|
38
|
+
def test_repr_round_trip_is_byte_stable(value: str):
|
|
39
|
+
p1 = plan(Template("run ", Interpolation(value, "v", None, "")))
|
|
40
|
+
p2 = plan(Template("run ", Interpolation(value, "v", None, "")))
|
|
41
|
+
assert repr(p1) == repr(p2)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --- shell mode invariants ---
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="shell mode is posix-only")
|
|
48
|
+
@settings(max_examples=300)
|
|
49
|
+
@given(value=text_values)
|
|
50
|
+
def test_shell_interpolation_stays_single_token(value: str):
|
|
51
|
+
"""INV-shell: under a pipe template, shlex.split yields value as one token."""
|
|
52
|
+
p = plan(Template("cat ", Interpolation(value, "v", None, ""), " | wc -l"))
|
|
53
|
+
assert p.mode == "shell"
|
|
54
|
+
assert p.shell_line is not None
|
|
55
|
+
tokens = shlex.split(p.shell_line)
|
|
56
|
+
# "cat", VALUE, "|", "wc", "-l" = 5 tokens
|
|
57
|
+
assert len(tokens) == 5
|
|
58
|
+
assert tokens[1] == value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="shell mode is posix-only")
|
|
62
|
+
@settings(max_examples=200)
|
|
63
|
+
@given(a=text_values, b=text_values)
|
|
64
|
+
def test_shell_two_interpolations_stay_separate(a: str, b: str):
|
|
65
|
+
"""Two shell-mode interpolations must each occupy one token."""
|
|
66
|
+
p = plan(
|
|
67
|
+
Template(
|
|
68
|
+
"grep ", Interpolation(a, "x", None, ""), " ", Interpolation(b, "y", None, ""),
|
|
69
|
+
" | sort",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
assert p.mode == "shell"
|
|
73
|
+
assert p.shell_line is not None
|
|
74
|
+
tokens = shlex.split(p.shell_line)
|
|
75
|
+
# "grep", A, B, "|", "sort" = 5 tokens
|
|
76
|
+
assert len(tokens) == 5
|
|
77
|
+
assert tokens[1] == a
|
|
78
|
+
assert tokens[2] == b
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Payload corpus gate: every injection case renders inert.
|
|
2
|
+
|
|
3
|
+
Argv mode: each .txt file in cases/ holds exactly one interpolated value. The
|
|
4
|
+
command is argv mode "echo <value>"; the invariant under test: the value occupies
|
|
5
|
+
exactly one argv element equal to the original payload. If any payload can escape
|
|
6
|
+
into a second command, this suite fails and the release is blocked.
|
|
7
|
+
|
|
8
|
+
Shell mode: the same payloads are tested under a pipe template. The invariant:
|
|
9
|
+
shlex.split(rendered_line) yields the interpolated value as exactly one token
|
|
10
|
+
equal to the original payload.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import shlex
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from string.templatelib import Interpolation, Template
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from shellsafe.render import plan
|
|
21
|
+
|
|
22
|
+
CASES_DIR = Path(__file__).parent.parent / "payloads" / "cases"
|
|
23
|
+
CASE_FILES = sorted(CASES_DIR.glob("case_*.txt"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def payloads() -> list[tuple[str, str]]:
|
|
27
|
+
out = []
|
|
28
|
+
for path in CASE_FILES:
|
|
29
|
+
raw = path.read_text()
|
|
30
|
+
# strip one trailing newline added by file storage; keep all else verbatim
|
|
31
|
+
value = raw[:-1] if raw.endswith("\n") else raw
|
|
32
|
+
out.append((path.stem, value))
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- argv mode ---
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize(
|
|
40
|
+
("name", "payload"),
|
|
41
|
+
payloads(),
|
|
42
|
+
ids=[n for n, _ in payloads()],
|
|
43
|
+
)
|
|
44
|
+
def test_payload_stays_single_argument(name: str, payload: str):
|
|
45
|
+
p = plan(Template("echo ", Interpolation(payload, "value", None, "")))
|
|
46
|
+
assert p.mode == "argv"
|
|
47
|
+
assert p.argv == ("echo", payload)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- shell mode ---
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize(
|
|
54
|
+
("name", "payload"),
|
|
55
|
+
payloads(),
|
|
56
|
+
ids=[n for n, _ in payloads()],
|
|
57
|
+
)
|
|
58
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="shell mode is posix-only")
|
|
59
|
+
def test_shell_payload_stays_single_token(name: str, payload: str):
|
|
60
|
+
"""Pipe template: value must be shell-quoted, never splitting into tokens."""
|
|
61
|
+
p = plan(
|
|
62
|
+
Template("cat ", Interpolation(payload, "value", None, ""), " | wc -l")
|
|
63
|
+
)
|
|
64
|
+
assert p.mode == "shell"
|
|
65
|
+
assert p.shell_line is not None
|
|
66
|
+
tokens = shlex.split(p.shell_line)
|
|
67
|
+
# static tokens: "cat", "|", "wc", "-l" (4); value must be exactly one
|
|
68
|
+
assert len(tokens) == 5
|
|
69
|
+
assert tokens[1] == payload
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_corpus_is_present():
|
|
73
|
+
# guard against silent corpus deletion
|
|
74
|
+
assert len(CASE_FILES) >= 10
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""RAW marker invariants: nesting refused, verbatim display, single argument."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from shellsafe import RAW
|
|
6
|
+
from shellsafe.errors import RawUsageError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_raw_repr_shows_trust_boundary():
|
|
10
|
+
assert "<RAW" in repr(RAW("pre-quoted"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_nested_raw_refused():
|
|
14
|
+
with pytest.raises(RawUsageError):
|
|
15
|
+
RAW(RAW(["x"]))
|
shellsafe-0.1.0/CHANGELOG.md
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
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.1.0] - 2026-08-24
|
|
7
|
-
|
|
8
|
-
### Added
|
|
9
|
-
|
|
10
|
-
- Argv-mode rendering and execution via template strings
|
|
11
|
-
- RAW trust marker with argv splicing
|
|
12
|
-
- capture() helper with utf-8 stdout/stderr
|
|
13
|
-
- plan() helper for inspecting commands before execution
|
|
14
|
-
- Injection payload corpus as a release gate; all cases render inert
|
shellsafe-0.1.0/PKG-INFO
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: shellsafe
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Safe shell commands via Python 3.14 template strings. Injection-proof by construction.
|
|
5
|
-
Project-URL: Repository, https://github.com/rahulXs/shellsafe
|
|
6
|
-
Project-URL: Issues, https://github.com/rahulXs/shellsafe/issues
|
|
7
|
-
Project-URL: Changelog, https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md
|
|
8
|
-
Author-email: Rahul Sharma <rahulxsh@gmail.com>
|
|
9
|
-
License-Expression: MIT
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
Keywords: automation,command-injection,injection,pep750,security,shell,subprocess,t-strings,template-strings
|
|
12
|
-
Classifier: Development Status :: 3 - Alpha
|
|
13
|
-
Classifier: Intended Audience :: Developers
|
|
14
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
-
Classifier: Programming Language :: Python :: 3
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
-
Classifier: Topic :: Security
|
|
18
|
-
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
-
Classifier: Typing :: Typed
|
|
20
|
-
Requires-Python: >=3.14
|
|
21
|
-
Description-Content-Type: text/markdown
|
|
22
|
-
|
|
23
|
-
# shellsafe
|
|
24
|
-
|
|
25
|
-
> Safe shell commands via Python 3.14 template strings. Injection-proof by
|
|
26
|
-
> construction.
|
|
27
|
-
|
|
28
|
-
```python
|
|
29
|
-
from shellsafe import run
|
|
30
|
-
|
|
31
|
-
message = get_user_input() # "fix; rm -rf ~"
|
|
32
|
-
run(t"git commit -m {message}")
|
|
33
|
-
# argv: ["git", "commit", "-m", "fix; rm -rf ~"]
|
|
34
|
-
# one command; the scary text is just an argument
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## Why
|
|
38
|
-
|
|
39
|
-
The dominant pattern in scripts and automation is still this:
|
|
40
|
-
|
|
41
|
-
```python
|
|
42
|
-
subprocess.run(f"git commit -m {message}", shell=True) # injection waiting to happen
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Python 3.14 template strings (`t"..."`) separate static text from interpolated
|
|
46
|
-
values. shellsafe turns that structure into argv lists where interpolated values
|
|
47
|
-
are always data, never commands. When you genuinely need pipes, shell mode quotes
|
|
48
|
-
every value with POSIX rules first.
|
|
49
|
-
|
|
50
|
-
## Install
|
|
51
|
-
|
|
52
|
-
```bash
|
|
53
|
-
pip install shellsafe
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
Requires Python 3.14+ (template strings).
|
|
57
|
-
|
|
58
|
-
## Usage
|
|
59
|
-
|
|
60
|
-
Run a command. Interpolated values are always single arguments:
|
|
61
|
-
|
|
62
|
-
```python
|
|
63
|
-
from shellsafe import run
|
|
64
|
-
|
|
65
|
-
run(t"mkdir {path}")
|
|
66
|
-
run(t"docker build -t {tag} .", check=True, timeout=300)
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
Capture output as text:
|
|
70
|
-
|
|
71
|
-
```python
|
|
72
|
-
from shellsafe import capture
|
|
73
|
-
|
|
74
|
-
res = capture(t"grep {pattern} {file}")
|
|
75
|
-
print(res.stdout, res.returncode)
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Pipes and redirections on POSIX (values are quoted with `shlex.quote` first):
|
|
79
|
-
|
|
80
|
-
```python
|
|
81
|
-
from shellsafe import shx
|
|
82
|
-
|
|
83
|
-
shx(t"cat {file} | wc -l")
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Inspect exactly what will execute:
|
|
87
|
-
|
|
88
|
-
```python
|
|
89
|
-
from shellsafe import plan # lower-level: render without running
|
|
90
|
-
|
|
91
|
-
print(plan(t"git commit -m {message}"))
|
|
92
|
-
# argv: ["git","commit","-m","fix; rm -rf ~"]
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
## What it refuses
|
|
96
|
-
|
|
97
|
-
| Case | Behavior |
|
|
98
|
-
|---|---|
|
|
99
|
-
| Interpolation as the executable | error: the command comes from static text only |
|
|
100
|
-
| Shell route on Windows | error: cmd.exe quoting cannot be made injection-safe; use argv mode |
|
|
101
|
-
| RAW misuse | error: one argument, verbatim, nesting refused |
|
|
102
|
-
|
|
103
|
-
`RAW("...")` / `RAW(["a", "b"])` is the single explicit trust boundary for
|
|
104
|
-
pre-quoted content. Every use site is greppable.
|
|
105
|
-
|
|
106
|
-
## Limits
|
|
107
|
-
|
|
108
|
-
- Windows: interpolated shell routes are refused rather than approximated;
|
|
109
|
-
argv-mode commands work fully.
|
|
110
|
-
- Bytes interpolations are rejected: decode explicitly first.
|
|
111
|
-
- Runtime behavior after import is your test suite's job, same trust model as
|
|
112
|
-
calling subprocess yourself.
|
|
113
|
-
|
|
114
|
-
## Contributing
|
|
115
|
-
|
|
116
|
-
Issues and PRs welcome. Security reports go privately to the maintainer, never
|
|
117
|
-
through public issues.
|
|
118
|
-
|
|
119
|
-
## Requirements
|
|
120
|
-
|
|
121
|
-
- CPython >= 3.14 (uses template strings from PEP 750)
|
|
122
|
-
- Linux, macOS, Windows (Windows supports argv mode; POSIX-only shell mode)
|
|
123
|
-
|
|
124
|
-
## License
|
|
125
|
-
|
|
126
|
-
MIT
|
shellsafe-0.1.0/README.md
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
# shellsafe
|
|
2
|
-
|
|
3
|
-
> Safe shell commands via Python 3.14 template strings. Injection-proof by
|
|
4
|
-
> construction.
|
|
5
|
-
|
|
6
|
-
```python
|
|
7
|
-
from shellsafe import run
|
|
8
|
-
|
|
9
|
-
message = get_user_input() # "fix; rm -rf ~"
|
|
10
|
-
run(t"git commit -m {message}")
|
|
11
|
-
# argv: ["git", "commit", "-m", "fix; rm -rf ~"]
|
|
12
|
-
# one command; the scary text is just an argument
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## Why
|
|
16
|
-
|
|
17
|
-
The dominant pattern in scripts and automation is still this:
|
|
18
|
-
|
|
19
|
-
```python
|
|
20
|
-
subprocess.run(f"git commit -m {message}", shell=True) # injection waiting to happen
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
Python 3.14 template strings (`t"..."`) separate static text from interpolated
|
|
24
|
-
values. shellsafe turns that structure into argv lists where interpolated values
|
|
25
|
-
are always data, never commands. When you genuinely need pipes, shell mode quotes
|
|
26
|
-
every value with POSIX rules first.
|
|
27
|
-
|
|
28
|
-
## Install
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
pip install shellsafe
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
Requires Python 3.14+ (template strings).
|
|
35
|
-
|
|
36
|
-
## Usage
|
|
37
|
-
|
|
38
|
-
Run a command. Interpolated values are always single arguments:
|
|
39
|
-
|
|
40
|
-
```python
|
|
41
|
-
from shellsafe import run
|
|
42
|
-
|
|
43
|
-
run(t"mkdir {path}")
|
|
44
|
-
run(t"docker build -t {tag} .", check=True, timeout=300)
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Capture output as text:
|
|
48
|
-
|
|
49
|
-
```python
|
|
50
|
-
from shellsafe import capture
|
|
51
|
-
|
|
52
|
-
res = capture(t"grep {pattern} {file}")
|
|
53
|
-
print(res.stdout, res.returncode)
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
Pipes and redirections on POSIX (values are quoted with `shlex.quote` first):
|
|
57
|
-
|
|
58
|
-
```python
|
|
59
|
-
from shellsafe import shx
|
|
60
|
-
|
|
61
|
-
shx(t"cat {file} | wc -l")
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Inspect exactly what will execute:
|
|
65
|
-
|
|
66
|
-
```python
|
|
67
|
-
from shellsafe import plan # lower-level: render without running
|
|
68
|
-
|
|
69
|
-
print(plan(t"git commit -m {message}"))
|
|
70
|
-
# argv: ["git","commit","-m","fix; rm -rf ~"]
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
## What it refuses
|
|
74
|
-
|
|
75
|
-
| Case | Behavior |
|
|
76
|
-
|---|---|
|
|
77
|
-
| Interpolation as the executable | error: the command comes from static text only |
|
|
78
|
-
| Shell route on Windows | error: cmd.exe quoting cannot be made injection-safe; use argv mode |
|
|
79
|
-
| RAW misuse | error: one argument, verbatim, nesting refused |
|
|
80
|
-
|
|
81
|
-
`RAW("...")` / `RAW(["a", "b"])` is the single explicit trust boundary for
|
|
82
|
-
pre-quoted content. Every use site is greppable.
|
|
83
|
-
|
|
84
|
-
## Limits
|
|
85
|
-
|
|
86
|
-
- Windows: interpolated shell routes are refused rather than approximated;
|
|
87
|
-
argv-mode commands work fully.
|
|
88
|
-
- Bytes interpolations are rejected: decode explicitly first.
|
|
89
|
-
- Runtime behavior after import is your test suite's job, same trust model as
|
|
90
|
-
calling subprocess yourself.
|
|
91
|
-
|
|
92
|
-
## Contributing
|
|
93
|
-
|
|
94
|
-
Issues and PRs welcome. Security reports go privately to the maintainer, never
|
|
95
|
-
through public issues.
|
|
96
|
-
|
|
97
|
-
## Requirements
|
|
98
|
-
|
|
99
|
-
- CPython >= 3.14 (uses template strings from PEP 750)
|
|
100
|
-
- Linux, macOS, Windows (Windows supports argv mode; POSIX-only shell mode)
|
|
101
|
-
|
|
102
|
-
## License
|
|
103
|
-
|
|
104
|
-
MIT
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
"""Real-execution integration tests (posix). Proves the end-to-end path."""
|
|
2
|
-
|
|
3
|
-
import sys
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from shellsafe import capture, shx
|
|
8
|
-
from shellsafe.errors import ShellSafeError, UnsupportedPlatformError
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
12
|
-
def test_capture_round_trip():
|
|
13
|
-
res = capture(t"echo hello")
|
|
14
|
-
assert res.returncode == 0
|
|
15
|
-
assert res.stdout == "hello\n"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
19
|
-
def test_injection_payload_arrives_as_text():
|
|
20
|
-
payload = "hello; echo PWNED"
|
|
21
|
-
res = capture(t"echo {payload}")
|
|
22
|
-
# argv mode: echo receives the whole payload as ONE argument; no second
|
|
23
|
-
# command can exist. The output is the payload text itself.
|
|
24
|
-
assert res.stdout.strip() == payload
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
28
|
-
def test_interpolation_with_spaces_stays_one_argument():
|
|
29
|
-
value = "two words"
|
|
30
|
-
res = capture(t"echo {value}")
|
|
31
|
-
assert res.stdout.strip() == "two words"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_shell_mode_execution_not_yet_available():
|
|
35
|
-
# posix: rendering works, execution gate is explicit until v0.2
|
|
36
|
-
# windows: the route itself is refused before any process spawns
|
|
37
|
-
p = t"cat /etc/hostname | wc -l"
|
|
38
|
-
|
|
39
|
-
if sys.platform == "win32":
|
|
40
|
-
with pytest.raises(UnsupportedPlatformError):
|
|
41
|
-
shx(p)
|
|
42
|
-
else:
|
|
43
|
-
with pytest.raises(ShellSafeError, match=r"0\.2"):
|
|
44
|
-
shx(p)
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
"""Property tests: invariants that must hold for arbitrary inputs."""
|
|
2
|
-
|
|
3
|
-
from string.templatelib import Interpolation, Template
|
|
4
|
-
|
|
5
|
-
from hypothesis import given, settings
|
|
6
|
-
from hypothesis import strategies as st
|
|
7
|
-
|
|
8
|
-
from shellsafe.render import plan
|
|
9
|
-
|
|
10
|
-
# NUL bytes are refused at resolution; everything else must render safely.
|
|
11
|
-
text_values = st.text(st.characters(blacklist_characters="\x00"), max_size=200)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@settings(max_examples=300)
|
|
15
|
-
@given(value=text_values)
|
|
16
|
-
def test_one_interpolation_is_exactly_one_argument(value: str):
|
|
17
|
-
p = plan(Template("run ", Interpolation(value, "v", None, "")))
|
|
18
|
-
assert p.argv == ("run", value)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@settings(max_examples=200)
|
|
22
|
-
@given(a=text_values, b=text_values)
|
|
23
|
-
def test_two_interpolations_stay_separate(a: str, b: str):
|
|
24
|
-
p = plan(
|
|
25
|
-
Template("copy ", Interpolation(a, "x", None, ""), " ", Interpolation(b, "y", None, ""))
|
|
26
|
-
)
|
|
27
|
-
assert p.argv == ("copy", a, b)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@settings(max_examples=200)
|
|
31
|
-
@given(value=text_values)
|
|
32
|
-
def test_repr_round_trip_is_byte_stable(value: str):
|
|
33
|
-
p1 = plan(Template("run ", Interpolation(value, "v", None, "")))
|
|
34
|
-
p2 = plan(Template("run ", Interpolation(value, "v", None, "")))
|
|
35
|
-
assert repr(p1) == repr(p2)
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
"""Payload corpus gate: every injection case renders inert.
|
|
2
|
-
|
|
3
|
-
Each .txt file in cases/ holds exactly one interpolated value. The command is
|
|
4
|
-
argv mode "echo <value>"; the invariant under test: the value occupies exactly
|
|
5
|
-
one argv element equal to the original payload. If any payload can escape into a
|
|
6
|
-
second command, this suite fails and the release is blocked.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from string.templatelib import Interpolation, Template
|
|
11
|
-
|
|
12
|
-
import pytest
|
|
13
|
-
|
|
14
|
-
from shellsafe.render import plan
|
|
15
|
-
|
|
16
|
-
CASES_DIR = Path(__file__).parent.parent / "payloads" / "cases"
|
|
17
|
-
CASE_FILES = sorted(CASES_DIR.glob("case_*.txt"))
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def payloads() -> list[tuple[str, str]]:
|
|
21
|
-
out = []
|
|
22
|
-
for path in CASE_FILES:
|
|
23
|
-
raw = path.read_text()
|
|
24
|
-
# strip one trailing newline added by file storage; keep all else verbatim
|
|
25
|
-
value = raw[:-1] if raw.endswith("\n") else raw
|
|
26
|
-
out.append((path.stem, value))
|
|
27
|
-
return out
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@pytest.mark.parametrize(
|
|
31
|
-
("name", "payload"),
|
|
32
|
-
payloads(),
|
|
33
|
-
ids=[n for n, _ in payloads()],
|
|
34
|
-
)
|
|
35
|
-
def test_payload_stays_single_argument(name: str, payload: str):
|
|
36
|
-
p = plan(Template("echo ", Interpolation(payload, "value", None, "")))
|
|
37
|
-
assert p.mode == "argv"
|
|
38
|
-
assert p.argv == ("echo", payload)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_corpus_is_present():
|
|
42
|
-
# guard against silent corpus deletion
|
|
43
|
-
assert len(CASE_FILES) >= 10
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
"""RAW marker invariants: nesting refused, verbatim display, single argument."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from shellsafe import RAW
|
|
6
|
-
from shellsafe.errors import RawUsageError
|
|
7
|
-
from shellsafe.raw import Raw
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_raw_wraps_list_verbatim():
|
|
11
|
-
r = RAW(["git", "log", "--oneline"])
|
|
12
|
-
assert isinstance(r, Raw)
|
|
13
|
-
assert r.value == ["git", "log", "--oneline"]
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def test_raw_repr_shows_trust_boundary():
|
|
17
|
-
assert "<RAW" in repr(RAW("pre-quoted"))
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_nested_raw_refused():
|
|
21
|
-
with pytest.raises(RawUsageError):
|
|
22
|
-
RAW(RAW(["x"]))
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def test_raw_is_greppable():
|
|
26
|
-
# house rule: trust boundaries are greppable; this documents the pattern
|
|
27
|
-
source = 'RAW("trusted")'
|
|
28
|
-
assert "RAW(" in source
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|