shellsafe 0.3.3__tar.gz → 0.3.5__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 (58) hide show
  1. {shellsafe-0.3.3 → shellsafe-0.3.5}/CHANGELOG.md +26 -0
  2. {shellsafe-0.3.3 → shellsafe-0.3.5}/PKG-INFO +19 -4
  3. {shellsafe-0.3.3 → shellsafe-0.3.5}/README.md +17 -3
  4. {shellsafe-0.3.3 → shellsafe-0.3.5}/pyproject.toml +1 -0
  5. {shellsafe-0.3.3 → shellsafe-0.3.5}/src/shellsafe/__init__.py +3 -2
  6. shellsafe-0.3.5/src/shellsafe/_version.py +1 -0
  7. {shellsafe-0.3.3 → shellsafe-0.3.5}/src/shellsafe/audit/scanner.py +56 -49
  8. {shellsafe-0.3.3 → shellsafe-0.3.5}/src/shellsafe/execute.py +27 -10
  9. {shellsafe-0.3.3 → shellsafe-0.3.5}/src/shellsafe/render.py +14 -4
  10. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/unit/test_public_surface.py +2 -1
  11. shellsafe-0.3.3/src/shellsafe/_version.py +0 -1
  12. {shellsafe-0.3.3 → shellsafe-0.3.5}/.github/workflows/ci.yml +0 -0
  13. {shellsafe-0.3.3 → shellsafe-0.3.5}/.github/workflows/release.yml +0 -0
  14. {shellsafe-0.3.3 → shellsafe-0.3.5}/.gitignore +0 -0
  15. {shellsafe-0.3.3 → shellsafe-0.3.5}/CONTRIBUTING.md +0 -0
  16. {shellsafe-0.3.3 → shellsafe-0.3.5}/LICENSE +0 -0
  17. {shellsafe-0.3.3 → shellsafe-0.3.5}/examples/demo.py +0 -0
  18. {shellsafe-0.3.3 → shellsafe-0.3.5}/src/shellsafe/__main__.py +0 -0
  19. {shellsafe-0.3.3 → shellsafe-0.3.5}/src/shellsafe/audit/__init__.py +0 -0
  20. {shellsafe-0.3.3 → shellsafe-0.3.5}/src/shellsafe/cli.py +0 -0
  21. {shellsafe-0.3.3 → shellsafe-0.3.5}/src/shellsafe/errors.py +0 -0
  22. {shellsafe-0.3.3 → shellsafe-0.3.5}/src/shellsafe/exitcodes.py +0 -0
  23. {shellsafe-0.3.3 → shellsafe-0.3.5}/src/shellsafe/platforms.py +0 -0
  24. {shellsafe-0.3.3 → shellsafe-0.3.5}/src/shellsafe/py.typed +0 -0
  25. {shellsafe-0.3.3 → shellsafe-0.3.5}/src/shellsafe/raw.py +0 -0
  26. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/fixtures/audit/au001_sample.py +0 -0
  27. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/fixtures/audit/au002_sample.py +0 -0
  28. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/fixtures/audit/au003_sample.py +0 -0
  29. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/fixtures/audit/au004_sample.py +0 -0
  30. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/fixtures/audit/suppress_sample.py +0 -0
  31. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/golden/.gitkeep +0 -0
  32. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/integration/.gitkeep +0 -0
  33. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/integration/test_cli_audit.py +0 -0
  34. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/integration/test_exec_posix.py +0 -0
  35. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/README.md +0 -0
  36. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_01_semicolon.txt +0 -0
  37. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_02_substitution.txt +0 -0
  38. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_03_backticks.txt +0 -0
  39. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_04_background_chain.txt +0 -0
  40. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_05_or_chain.txt +0 -0
  41. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
  42. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
  43. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
  44. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
  45. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
  46. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
  47. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_12_newline.txt +0 -0
  48. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
  49. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_14_whitespace.txt +0 -0
  50. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
  51. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/property/.gitkeep +0 -0
  52. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/property/test_invariants.py +0 -0
  53. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/unit/test_audit.py +0 -0
  54. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/unit/test_cli.py +0 -0
  55. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/unit/test_errors.py +0 -0
  56. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/unit/test_payload_corpus.py +0 -0
  57. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/unit/test_raw.py +0 -0
  58. {shellsafe-0.3.3 → shellsafe-0.3.5}/tests/unit/test_render.py +0 -0
@@ -3,6 +3,32 @@
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.5] - 2026-09-11
7
+
8
+ ### Fixed
9
+
10
+ - `capture()` no longer renders template twice
11
+ - `_resolve` always returns `str` (defense-in-depth)
12
+ - Suppression comment check extended to 5 lines back (was 1)
13
+ - AU010 now flags file-level suppressions without reasons
14
+ - Audit scanner follows symlinks
15
+ - Walrus operator (`:=`) tracked by AU003
16
+
17
+ ### Changed
18
+
19
+ - Shell line length guard (128KB max)
20
+ - Suppression `reason:` is now case-insensitive
21
+ - `_apply_suppression` uses `dataclasses.replace` (internal cleanup)
22
+ - PEP 787 status updated to reflect ongoing deferral
23
+
24
+ ## [0.3.4] - 2026-09-03
25
+
26
+ ### Fixed
27
+
28
+ - Export `plan()` from public surface (`__init__.py`); was defined in
29
+ `execute.py` but not listed in `__all__`, causing `ImportError` on
30
+ `from shellsafe import plan`
31
+
6
32
  ## [0.3.3] - 2026-09-01
7
33
 
8
34
  ### Added
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shellsafe
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Run shell commands safely using Python 3.14 template strings. Values can never turn into commands.
5
5
  Project-URL: Homepage, https://github.com/rahulXs/shellsafe
6
6
  Project-URL: Repository, https://github.com/rahulXs/shellsafe
7
7
  Project-URL: Issues, https://github.com/rahulXs/shellsafe/issues
8
8
  Project-URL: Changelog, https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md
9
+ Project-URL: Blog, https://rahulxs.github.io/shellsafe/
9
10
  Author-email: Rahul Sharma <rahulxsh@gmail.com>
10
11
  License-Expression: MIT
11
12
  License-File: LICENSE
@@ -48,9 +49,9 @@ subprocess.run(f"git commit -m {message}", shell=True)
48
49
  # if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
49
50
  ```
50
51
 
51
- Python planned to solve this officially (PEP 787), but that PEP was deferred to
52
- at least Python 3.15. So today there is no standard way to run shell commands
53
- safely with templates. This package fills that gap.
52
+ Python planned to solve this officially (PEP 787), but that PEP is still deferred.
53
+ It is not in the Python 3.15 release candidates. So today there is no standard
54
+ way to run shell commands safely with templates. This package fills that gap.
54
55
 
55
56
  ## Install
56
57
 
@@ -131,6 +132,19 @@ Filter by severity:
131
132
  shellsafe audit src/ --severity warning
132
133
  ```
133
134
 
135
+ Suppress known-safe findings inline:
136
+
137
+ ```python
138
+ # shellsafe: ignore AU001 reason: tested, value is constant
139
+ os.system(f"echo {safe_value}")
140
+ ```
141
+
142
+ Suppress from the command line:
143
+
144
+ ```bash
145
+ shellsafe audit src/ --ignore AU004
146
+ ```
147
+
134
148
  ## Limits
135
149
 
136
150
  - Shell features (pipes, redirections) work on Linux and macOS only. Windows
@@ -149,6 +163,7 @@ shellsafe audit src/ --severity warning
149
163
  ## More
150
164
 
151
165
  - Source and issues: [github.com/rahulXs/shellsafe](https://github.com/rahulXs/shellsafe)
166
+ - Blog: [rahulxs.github.io/shellsafe](https://rahulxs.github.io/shellsafe/)
152
167
  - Want to help? See CONTRIBUTING.md in the repository.
153
168
 
154
169
  License: MIT
@@ -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 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.
28
+ Python planned to solve this officially (PEP 787), but that PEP is still deferred.
29
+ It is not in the Python 3.15 release candidates. So today there is no standard
30
+ way to run shell commands safely with templates. This package fills that gap.
31
31
 
32
32
  ## Install
33
33
 
@@ -108,6 +108,19 @@ Filter by severity:
108
108
  shellsafe audit src/ --severity warning
109
109
  ```
110
110
 
111
+ Suppress known-safe findings inline:
112
+
113
+ ```python
114
+ # shellsafe: ignore AU001 reason: tested, value is constant
115
+ os.system(f"echo {safe_value}")
116
+ ```
117
+
118
+ Suppress from the command line:
119
+
120
+ ```bash
121
+ shellsafe audit src/ --ignore AU004
122
+ ```
123
+
111
124
  ## Limits
112
125
 
113
126
  - Shell features (pipes, redirections) work on Linux and macOS only. Windows
@@ -126,6 +139,7 @@ shellsafe audit src/ --severity warning
126
139
  ## More
127
140
 
128
141
  - Source and issues: [github.com/rahulXs/shellsafe](https://github.com/rahulXs/shellsafe)
142
+ - Blog: [rahulxs.github.io/shellsafe](https://rahulxs.github.io/shellsafe/)
129
143
  - Want to help? See CONTRIBUTING.md in the repository.
130
144
 
131
145
  License: MIT
@@ -34,6 +34,7 @@ Homepage = "https://github.com/rahulXs/shellsafe"
34
34
  Repository = "https://github.com/rahulXs/shellsafe"
35
35
  Issues = "https://github.com/rahulXs/shellsafe/issues"
36
36
  Changelog = "https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md"
37
+ Blog = "https://rahulxs.github.io/shellsafe/"
37
38
 
38
39
  [tool.hatch.version]
39
40
  path = "src/shellsafe/_version.py"
@@ -1,11 +1,11 @@
1
1
  """shellsafe: safe shell commands via Python 3.14 template strings.
2
2
 
3
3
  Public surface (stable names, additive only through 1.0):
4
- run, capture, shx, RAW, CaptureResult.
4
+ run, capture, shx, plan, RAW, CaptureResult.
5
5
  """
6
6
 
7
7
  from ._version import __version__
8
- from .execute import CaptureResult, capture, run, shx
8
+ from .execute import CaptureResult, capture, plan, run, shx
9
9
  from .raw import RAW
10
10
 
11
11
  __all__ = [
@@ -13,6 +13,7 @@ __all__ = [
13
13
  "CaptureResult",
14
14
  "__version__",
15
15
  "capture",
16
+ "plan",
16
17
  "run",
17
18
  "shx",
18
19
  ]
@@ -0,0 +1 @@
1
+ __version__ = "0.3.5"
@@ -7,7 +7,7 @@ import ast
7
7
  import json
8
8
  import sys
9
9
  import time
10
- from dataclasses import dataclass, field
10
+ from dataclasses import dataclass, field, replace
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
@@ -158,13 +158,15 @@ def _track_dynamic_vars(tree: ast.Module):
158
158
  """Find variables assigned dynamic string expressions."""
159
159
  dynamic = set()
160
160
  for node in ast.walk(tree):
161
- if not isinstance(node, ast.Assign):
162
- continue
163
- if not _is_dynamic_string(node.value):
164
- continue
165
- for target in node.targets:
166
- if isinstance(target, ast.Name):
167
- dynamic.add(target.id)
161
+ if isinstance(node, ast.Assign):
162
+ if not _is_dynamic_string(node.value):
163
+ continue
164
+ for target in node.targets:
165
+ if isinstance(target, ast.Name):
166
+ dynamic.add(target.id)
167
+ elif isinstance(node, ast.NamedExpr):
168
+ if _is_dynamic_string(node.value) and isinstance(node.target, ast.Name):
169
+ dynamic.add(node.target.id)
168
170
  return dynamic
169
171
 
170
172
 
@@ -300,11 +302,17 @@ def _discover(paths: list[str]):
300
302
  if p.is_file() and p.suffix == ".py":
301
303
  files.append(p)
302
304
  elif p.is_dir():
303
- files.extend(
304
- f
305
- for f in sorted(p.rglob("*.py"))
306
- if not any(part.startswith(".") or part == "__pycache__" for part in f.parts)
307
- )
305
+ import os
306
+
307
+ for dirpath, dirnames, filenames in os.walk(p, followlinks=True):
308
+ dirnames[:] = [
309
+ d for d in dirnames
310
+ if not d.startswith(".") and d != "__pycache__"
311
+ ]
312
+ for name in filenames:
313
+ if name.endswith(".py"):
314
+ files.append(Path(dirpath) / name)
315
+ files.sort()
308
316
  return files
309
317
 
310
318
 
@@ -341,10 +349,11 @@ def _parse_suppression_comment(comment: str) -> tuple[list[str], str | None]:
341
349
  return [], None
342
350
  rest = directive[len("ignore "):]
343
351
  reason = None
344
- reason_match = rest.split(" reason:", 1)
345
- if len(reason_match) == 2:
346
- rest = reason_match[0]
347
- reason = reason_match[1].strip() or None
352
+ reason_lower = rest.lower()
353
+ reason_idx = reason_lower.find(" reason:")
354
+ if reason_idx != -1:
355
+ reason = rest[reason_idx + 8:].strip() or None
356
+ rest = rest[:reason_idx]
348
357
  rule_ids = [r.strip().upper() for r in rest.split(",") if r.strip()]
349
358
  return rule_ids, reason
350
359
 
@@ -386,11 +395,14 @@ def _find_suppressed_line(
386
395
  ) -> tuple[set[str], str | None] | None:
387
396
  """Find which rules suppress findings at target_line.
388
397
 
389
- Checks previous non-blank, non-comment line, then target line itself.
398
+ Checks up to 5 previous non-blank lines, then the target line itself.
390
399
  """
391
- for candidate in (target_line - 1, target_line):
400
+ for offset in range(1, 6):
401
+ candidate = target_line - offset
392
402
  if candidate in line_suppressions:
393
403
  return line_suppressions[candidate]
404
+ if target_line in line_suppressions:
405
+ return line_suppressions[target_line]
394
406
  return None
395
407
 
396
408
 
@@ -402,24 +414,13 @@ def _apply_suppression(
402
414
  ) -> Finding:
403
415
  """Apply suppression rules to a finding. Returns new Finding with ignored flag."""
404
416
  rule_id = result.rule_id
417
+
418
+ if ignore and rule_id in ignore:
419
+ return replace(result, ignored=True, ignore_reason="CLI --ignore")
420
+
405
421
  suppressed = False
406
422
  ignore_reason = None
407
423
 
408
- if ignore and rule_id in ignore:
409
- return Finding(
410
- rule_id=result.rule_id,
411
- title=result.title,
412
- severity=result.severity,
413
- confidence=result.confidence,
414
- path=result.path,
415
- lineno=result.lineno,
416
- col=result.col,
417
- message=result.message,
418
- evidence=result.evidence,
419
- fix_hint=result.fix_hint,
420
- ignored=True,
421
- ignore_reason="CLI --ignore",
422
- )
423
424
  if rule_id in file_rules or "*" in file_rules:
424
425
  suppressed = True
425
426
  ignore_reason = "file-level suppression"
@@ -433,24 +434,12 @@ def _apply_suppression(
433
434
 
434
435
  if not suppressed:
435
436
  return result
436
- return Finding(
437
- rule_id=result.rule_id,
438
- title=result.title,
439
- severity=result.severity,
440
- confidence=result.confidence,
441
- path=result.path,
442
- lineno=result.lineno,
443
- col=result.col,
444
- message=result.message,
445
- evidence=result.evidence,
446
- fix_hint=result.fix_hint,
447
- ignored=True,
448
- ignore_reason=ignore_reason,
449
- )
437
+ return replace(result, ignored=True, ignore_reason=ignore_reason)
450
438
 
451
439
 
452
440
  def _emit_au010(
453
441
  line_suppressions: dict[int, tuple[set[str], str | None]],
442
+ file_rules: set[str],
454
443
  rel: str,
455
444
  ) -> list[Finding]:
456
445
  """Emit AU010 warnings for suppression comments without reasons."""
@@ -477,6 +466,24 @@ def _emit_au010(
477
466
  evidence={"rules": sorted(suppressed_rules)},
478
467
  )
479
468
  )
469
+ if file_rules and "*" not in file_rules:
470
+ for rule in sorted(file_rules):
471
+ results.append(
472
+ Finding(
473
+ rule_id="AU010",
474
+ title="suppression comment without reason",
475
+ severity="info",
476
+ confidence=1.0,
477
+ path=rel,
478
+ lineno=1,
479
+ col=0,
480
+ message=(
481
+ f"File-level suppression for {rule} "
482
+ f"has no reason. Add: reason: <why>"
483
+ ),
484
+ evidence={"rules": [rule], "file_level": True},
485
+ )
486
+ )
480
487
  return results
481
488
 
482
489
 
@@ -506,7 +513,7 @@ def scan(paths: list[str], ignore: set[str] | None = None) -> list[Finding]:
506
513
  findings.append(
507
514
  _apply_suppression(result, ignore, file_rules, line_suppressions)
508
515
  )
509
- findings.extend(_emit_au010(line_suppressions, rel))
516
+ findings.extend(_emit_au010(line_suppressions, file_rules, rel))
510
517
 
511
518
  findings.sort(key=lambda f: (f.severity != "error", f.path, f.lineno))
512
519
  return findings
@@ -51,15 +51,14 @@ def _pass_through(kwargs: dict[str, object]):
51
51
  return {k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS}
52
52
 
53
53
 
54
- def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
55
- """Render the template and execute it.
54
+ def _render(
55
+ template: object, kwargs: dict[str, object]
56
+ ) -> tuple[ExecutionPlan, subprocess.CompletedProcess[str]]:
57
+ """Render template and execute, returning both plan and result."""
58
+ from .render import plan as render_plan
56
59
 
57
- Interpolated values always arrive as single argv elements. Keyword arguments
58
- pass through to subprocess.run with one exception: shell is rejected by
59
- design.
60
- """
61
60
  _validate_kwargs(kwargs)
62
- rendered = plan(template)
61
+ rendered = render_plan(template)
63
62
  if rendered.mode == "shell":
64
63
  if sys.platform.startswith("win"):
65
64
  raise ShellSafeError(
@@ -71,7 +70,19 @@ def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[st
71
70
  "run() to execute pipes and redirections"
72
71
  )
73
72
  assert rendered.argv is not None
74
- return subprocess.run(rendered.argv, **_pass_through(kwargs))
73
+ completed = subprocess.run(rendered.argv, **_pass_through(kwargs))
74
+ return rendered, completed
75
+
76
+
77
+ def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
78
+ """Render the template and execute it.
79
+
80
+ Interpolated values always arrive as single argv elements. Keyword arguments
81
+ pass through to subprocess.run with one exception: shell is rejected by
82
+ design.
83
+ """
84
+ _, completed = _render(template, kwargs)
85
+ return completed
75
86
 
76
87
 
77
88
  @dataclass(frozen=True, slots=True)
@@ -83,18 +94,24 @@ class CaptureResult:
83
94
  returncode: int
84
95
  plan: ExecutionPlan
85
96
 
97
+ def __repr__(self) -> str:
98
+ return (
99
+ f"CaptureResult(returncode={self.returncode}, "
100
+ f"stdout={self.stdout!r}, stderr={self.stderr!r})"
101
+ )
102
+
86
103
 
87
104
  def capture(template: object, /, **kwargs: object) -> CaptureResult:
88
105
  """Run with captured utf-8 stdout/stderr and return a CaptureResult."""
89
106
  kwargs["capture_output"] = True
90
107
  kwargs["text"] = True
91
108
  kwargs["encoding"] = "utf-8"
92
- completed = run(template, **kwargs)
109
+ rendered, completed = _render(template, kwargs)
93
110
  return CaptureResult(
94
111
  stdout=completed.stdout,
95
112
  stderr=completed.stderr,
96
113
  returncode=completed.returncode,
97
- plan=plan(template),
114
+ plan=rendered,
98
115
  )
99
116
 
100
117
 
@@ -83,10 +83,15 @@ def _resolve(interpolation: Interpolation[str]) -> str:
83
83
  )
84
84
 
85
85
  if format_spec:
86
- return format(converted, format_spec)
87
- if conversion is None:
88
- return str(converted)
89
- return converted
86
+ result = format(converted, format_spec)
87
+ elif conversion is None:
88
+ result = str(converted)
89
+ else:
90
+ result = converted
91
+
92
+ if not isinstance(result, str):
93
+ result = str(result)
94
+ return result
90
95
 
91
96
 
92
97
  def _reject_nul(resolved: str):
@@ -176,4 +181,9 @@ def _render_shell(parts: list[Segment]) -> ExecutionPlan:
176
181
  line = "".join(line_parts).strip()
177
182
  if not line:
178
183
  raise ShellSafeTypeError("empty command")
184
+ if len(line) > 131072:
185
+ raise ShellSafeTypeError(
186
+ f"shell line exceeds 128KB ({len(line)} bytes); "
187
+ "use argv mode or shorten the command"
188
+ )
179
189
  return ExecutionPlan(mode="shell", shell_line=line)
@@ -4,7 +4,7 @@ import shellsafe
4
4
 
5
5
 
6
6
  def test_public_names_exist():
7
- for name in ("run", "capture", "shx", "RAW", "CaptureResult", "__version__"):
7
+ for name in ("run", "capture", "shx", "plan", "RAW", "CaptureResult", "__version__"):
8
8
  assert hasattr(shellsafe, name), name
9
9
 
10
10
 
@@ -13,6 +13,7 @@ def test_all_is_exact():
13
13
  "run",
14
14
  "capture",
15
15
  "shx",
16
+ "plan",
16
17
  "RAW",
17
18
  "CaptureResult",
18
19
  "__version__",
@@ -1 +0,0 @@
1
- __version__ = "0.3.3"
File without changes
File without changes
File without changes
File without changes