skillsentinel-cli 0.3.1__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """SkillSentinel — post-install guard for AI-agent skills and prompt configs."""
2
+
3
+ __version__ = "0.3.1"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
skillsentinel/cli.py ADDED
@@ -0,0 +1,364 @@
1
+ """SkillSentinel CLI: scan / watch / rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import sys
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+
13
+ from . import __version__
14
+ from .detonate import DetonationRefusedError, detonate_tree
15
+ from .rules import load_rules, SEVERITY_ORDER
16
+ from .scanner import InputError, scan_path, to_sarif
17
+ from .verifier import VERIFIERS_BY_NAME
18
+ from .watch import (write_snapshot, load_snapshot, diff_snapshots, snapshot_dir,
19
+ rescan_changes)
20
+
21
+ SEV_EXIT: dict[str, int] = {"low": 0, "medium": 1, "high": 2, "critical": 3}
22
+
23
+ # `scan --help` must state the contract a CI pipeline relies on.
24
+ EXIT_CODES_EPILOG = """\
25
+ exit codes:
26
+ 0 clean — no confirmed findings
27
+ 1 at least one confirmed finding at/above the --severity threshold
28
+ 2 input error: missing or unreadable path, invalid or empty --rules, a .skillsentinelignore
29
+ pattern that ignores the whole skill, a scan that saw zero scannable files,
30
+ --timeout expiry, --max-files limit reached
31
+ 3 internal error — a bug, please report it"""
32
+
33
+ SEV_COLOR = {
34
+ "critical": "\033[91m", "high": "\033[31m",
35
+ "medium": "\033[33m", "low": "\033[32m", "end": "\033[0m",
36
+ }
37
+
38
+
39
+ def _colorized(s: str, sev: str, enabled: bool) -> str:
40
+ if not enabled:
41
+ return s
42
+ return f"{SEV_COLOR[sev]}{s}{SEV_COLOR['end']}"
43
+
44
+
45
+ def _park_stdout_on_devnull() -> None:
46
+ """After a stdout write failed, point fd 1 at /dev/null so the interpreter's
47
+ shutdown flush of the leftover buffer cannot re-raise — without this a
48
+ broken pipe turns into an "Exception ignored" dump and rc=120. Call only
49
+ after the real error line is already on stderr."""
50
+ if sys.stdout is None:
51
+ return # no stdout buffer was ever created — nothing to re-point or flush
52
+ try:
53
+ null = os.open(os.devnull, os.O_WRONLY)
54
+ except OSError: # nothing to hide the failure behind: best effort only
55
+ return
56
+ try:
57
+ os.dup2(null, sys.stdout.fileno())
58
+ except (OSError, ValueError):
59
+ pass # already detached/closed — nothing buffered can re-raise then
60
+ finally:
61
+ os.close(null)
62
+
63
+
64
+ def _emit_stdout(emit: Callable[[], None], what: str) -> bool:
65
+ """Run a stdout-writing report block AND flush it here, so a stdout that
66
+ cannot be written (closed pipe, full device, fd 1 closed at exec so
67
+ sys.stdout is None) fails now as one clean error naming the output — not
68
+ at interpreter shutdown. Returns False after printing that line and
69
+ muting fd 1 (see _park_stdout_on_devnull)."""
70
+ if sys.stdout is None:
71
+ # fd 1 was closed before the interpreter started: print() discards
72
+ # silently, so treat the missing stream exactly like an unwritable
73
+ # one — an output error, not an internal one.
74
+ print(f"skillsentinel: error: cannot write {what}: stdout is closed "
75
+ f"(fd 1 was closed at startup)", file=sys.stderr)
76
+ return False
77
+ try:
78
+ emit()
79
+ sys.stdout.flush()
80
+ except (BrokenPipeError, OSError) as e:
81
+ print(f"skillsentinel: error: cannot write {what}: {e}",
82
+ file=sys.stderr)
83
+ _park_stdout_on_devnull()
84
+ return False
85
+ return True
86
+
87
+
88
+ def _print_findings(result, use_color: bool, suppressed: int = 0,
89
+ show_inconclusive: bool = False) -> None:
90
+ c = result.counts
91
+ total = sum(c.values())
92
+ dropped = len(result.demoted)
93
+ inconclusive = [f for f in result.demoted if f.verdict == "inconclusive"]
94
+ print(f"SkillSentinel v{__version__} — scanned {result.path} "
95
+ f"({result.files_scanned} files, {result.files_skipped} skipped, "
96
+ f"profile: {result.profile})")
97
+ if result.undecodable_files:
98
+ # D3: undecodable files are reported loudly, never silently dropped.
99
+ names = ", ".join(result.undecodable_files[:3])
100
+ more = f" (+{len(result.undecodable_files) - 3} more)" \
101
+ if len(result.undecodable_files) > 3 else ""
102
+ print(f"warning: {len(result.undecodable_files)} file(s) could not be "
103
+ f"decoded as text: {names}{more}", file=sys.stderr)
104
+ if not total:
105
+ note = f", {suppressed} suppressed by inline markers" if suppressed else ""
106
+ if dropped:
107
+ note += f"; {dropped} candidate(s) demoted — see --json for reasons"
108
+ print(f"No exploitable findings. Supply chain looks clean. ✓{note}")
109
+ else:
110
+ print(f"Confirmed findings: {total} "
111
+ f"(critical: {c['critical']}, high: {c['high']}, "
112
+ f"medium: {c['medium']}, low: {c['low']})"
113
+ + (f" — {suppressed} suppressed by inline markers" if suppressed else "")
114
+ + (f"; {dropped} candidate(s) demoted" if dropped else ""))
115
+ cur = None
116
+ for f in result.findings:
117
+ if f.severity != cur:
118
+ cur = f.severity
119
+ print(_colorized(f"\n== {cur.upper()} ==", cur, use_color))
120
+ print(_colorized(f"[{f.rule_id}] {f.file}:{f.line}", cur, use_color)
121
+ + (f" (x{f.count})" if f.count > 1 else "")
122
+ + f" {f.title}")
123
+ print(f" {f.snippet[:160]}")
124
+ print(f" verdict: {f.verdict} — {f.reason}")
125
+ if show_inconclusive or inconclusive:
126
+ print(f"\n{len(inconclusive)} inconclusive candidate(s):")
127
+ for f in inconclusive:
128
+ print(f" [{f.rule_id}] {f.file}:{f.line} — {f.reason}")
129
+
130
+
131
+ def load_ignore_file(root: Path) -> list[str]:
132
+ p = root if root.is_file() else root / ".skillsentinelignore"
133
+ if not p.exists() or not p.is_file():
134
+ return []
135
+ out = []
136
+ for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
137
+ line = line.strip()
138
+ if line and not line.startswith("#"):
139
+ out.append(line)
140
+ return out
141
+
142
+
143
+ def cmd_scan(args: argparse.Namespace) -> int:
144
+ root = Path(args.path).expanduser()
145
+ # `.skillsentinelignore` is input from the scanned tree, so it is trusted
146
+ # only under --honor-ignores — exactly like inline suppression markers.
147
+ ignore_patterns = load_ignore_file(root) if args.honor_ignores else []
148
+ result = scan_path(args.path, args.rules,
149
+ honor_ignores=args.honor_ignores,
150
+ exclude=list(args.exclude),
151
+ ignore_patterns=ignore_patterns,
152
+ profile=args.profile,
153
+ timeout=args.timeout,
154
+ max_files=args.max_files)
155
+ reports = None
156
+ if args.detonate:
157
+ try:
158
+ reports = detonate_tree(Path(args.path), allow_net=args.allow_net,
159
+ timeout=args.detonate_timeout)
160
+ except DetonationRefusedError as e:
161
+ print(f"detonation refused: {e}", file=sys.stderr)
162
+ return 2
163
+ def _text_report() -> None:
164
+ _print_findings(result, use_color=sys.stdout is not None and sys.stdout.isatty(),
165
+ suppressed=result.suppressed,
166
+ show_inconclusive=args.show_inconclusive)
167
+ if reports is not None:
168
+ print(f"\n-- Detonation ({len(reports)} scripts) --")
169
+ for r in reports:
170
+ if not r.executed:
171
+ print(f"[skip] {r.file}: {'; '.join(r.notes)}")
172
+ continue
173
+ net = ", ".join(r.network_attempts) or "none observed"
174
+ print(f"[ran ] {r.file} exit={r.exit_code} egress: {net}")
175
+ for n in r.notes:
176
+ if "timed out" in n.lower():
177
+ print(f"[warn] {r.file}: {n}")
178
+ if any("network NOT isolated" in n for n in r.notes):
179
+ print("[warn] network NOT isolated (--allow-net): the "
180
+ "sandbox shares the host network — real egress "
181
+ "is possible")
182
+
183
+ if args.json:
184
+ payload = result.to_dict()
185
+ if reports is not None:
186
+ payload["detonations"] = [r.to_dict() for r in reports]
187
+ if not _emit_stdout(lambda: print(json.dumps(payload, indent=2)),
188
+ "the JSON report to stdout"):
189
+ return 2
190
+ elif not _emit_stdout(_text_report, "the report to stdout"):
191
+ return 2
192
+ if args.sarif:
193
+ sarif = to_sarif(result)
194
+ try:
195
+ Path(args.sarif).write_text(json.dumps(sarif, indent=2), encoding="utf-8")
196
+ except OSError as e: # the report target is part of the input contract too
197
+ print(f"skillsentinel: error: cannot write the SARIF report to "
198
+ f"{args.sarif}: {e}", file=sys.stderr)
199
+ return 2
200
+ if not args.json and not _emit_stdout(
201
+ lambda: print(f"\nSARIF written to {args.sarif}"),
202
+ "the SARIF confirmation to stdout"):
203
+ return 2
204
+ if result.limit_reached is not None:
205
+ n, m = result.limit_reached
206
+ print(f"error: limit reached: {n} of {m} files scanned "
207
+ f"(raise --max-files to see the rest)", file=sys.stderr)
208
+ return 2
209
+ threshold = SEVERITY_ORDER[args.severity]
210
+ return 1 if result.max_severity_rank() >= threshold else 0
211
+
212
+
213
+ def cmd_watch(args: argparse.Namespace) -> int:
214
+ root = Path(args.path).expanduser().resolve()
215
+ if args.watch_cmd == "init":
216
+ snap = write_snapshot(root, Path(args.snapshot))
217
+ print(f"Snapshot saved: {args.snapshot} "
218
+ f"({len(snap['entries'])} files under {snap['root']})")
219
+ return 0
220
+ if args.watch_cmd == "diff":
221
+ snap_path = Path(args.snapshot)
222
+ if not snap_path.exists():
223
+ print(f"error: snapshot not found: {snap_path} — run `watch init` first",
224
+ file=sys.stderr)
225
+ return 2
226
+ try:
227
+ old = load_snapshot(snap_path)
228
+ except (OSError, ValueError) as e:
229
+ raise InputError(f"cannot read snapshot {snap_path}: {e}") from None
230
+ new = snapshot_dir(root)
231
+ d = diff_snapshots(old, new)
232
+ if d["clean"]:
233
+ print(f"Clean: no drift across {len(new['entries'])} files.")
234
+ return 0
235
+ print("DRIFT DETECTED:")
236
+ for k in ("added", "changed", "removed"):
237
+ for p in d[k]:
238
+ print(f" {k:>7}: {p}")
239
+ if getattr(args, "rescan", False):
240
+ findings = rescan_changes(root, d)
241
+ if findings:
242
+ print(f"\nRescan of changed/added files — "
243
+ f"{len(findings)} confirmed finding(s):")
244
+ for f in findings:
245
+ print(f" [{f.rule_id}] {f.file}:{f.line} {f.title}")
246
+ print(f" {f.snippet[:160]}")
247
+ print(f" verdict: {f.verdict} — {f.reason}")
248
+ else:
249
+ print("\nRescan of changed/added files: no confirmed findings.")
250
+ return 1
251
+ print(json.dumps(snapshot_dir(root), indent=2))
252
+ return 0
253
+
254
+
255
+ def cmd_rules(args: argparse.Namespace) -> int:
256
+ try:
257
+ rules = load_rules(args.rules)
258
+ except (OSError, ValueError) as e:
259
+ raise InputError(
260
+ f"cannot load rules from {args.rules or 'the built-in catalog'}: {e}") from None
261
+ if args.json:
262
+ print(json.dumps({
263
+ "count": len(rules),
264
+ "rules": [{
265
+ "id": r.id, "title": r.title, "severity": r.severity,
266
+ "category": r.category, "owasp": r.owasp,
267
+ "description": r.description,
268
+ } for r in rules]}, indent=2))
269
+ else:
270
+ print(f"{len(rules)} rules:")
271
+ for r in sorted(rules, key=lambda x: (x.category, x.id)):
272
+ print(f" {r.id} {r.severity:<8} {r.category:<18} {r.title}")
273
+ return 0
274
+
275
+
276
+ def build_parser() -> argparse.ArgumentParser:
277
+ p = argparse.ArgumentParser(
278
+ prog="skillsentinel",
279
+ description="Post-install security scanner for AI-agent skills and prompt configs.")
280
+ p.add_argument("--version", action="version", version=f"skillsentinel {__version__}")
281
+ sub = p.add_subparsers(dest="cmd", required=True)
282
+
283
+ s = sub.add_parser("scan", help="scan a skill file or directory",
284
+ epilog=EXIT_CODES_EPILOG,
285
+ formatter_class=argparse.RawDescriptionHelpFormatter)
286
+ s.add_argument("path", help="file or directory to scan")
287
+ s.add_argument("--json", action="store_true", help="machine-readable JSON output")
288
+ s.add_argument("--sarif", metavar="OUT.sarif", help="also write SARIF 2.1.0 report")
289
+ s.add_argument("--severity", choices=list(SEVERITY_ORDER), default="low",
290
+ help="exit 1 only for findings at/above this severity (default: low)")
291
+ s.add_argument("--rules", help="path to alternative rules.json")
292
+ s.add_argument("--profile", choices=list(VERIFIERS_BY_NAME), default="balanced",
293
+ help="verification strictness: strict (every candidate confirmed, "
294
+ "demoted is empty) / balanced (default) / loose / paranoid")
295
+ s.add_argument("--honor-ignores", action="store_true",
296
+ help="honor inline `skillsentinel:ignore` markers and "
297
+ "`.skillsentinelignore` patterns (OFF by default: a scanned "
298
+ "file must not be able to silence its own detection)")
299
+ s.add_argument("--exclude", action="append", default=[], metavar="GLOB",
300
+ help="skip files matching this glob (repeatable)")
301
+ s.add_argument("--timeout", type=float, default=None, metavar="SEC",
302
+ help="abort the scan after SEC seconds and exit 2 (0 disables); "
303
+ "protects CI against pathological trees")
304
+ s.add_argument("--max-files", type=int, default=None, metavar="N",
305
+ help="scan at most N files, then exit 2 with a limit report; "
306
+ "bounds huge trees without silently ignoring the rest")
307
+ s.add_argument("--detonate", action="store_true",
308
+ help="run discovered scripts in a disposable bwrap sandbox "
309
+ "with no network access (--unshare-net; requires "
310
+ "bubblewrap, skipped with a notice otherwise)")
311
+ s.add_argument("--allow-net", action="store_true",
312
+ help="with --detonate: lift network isolation — the sandbox "
313
+ "CAN open real connections (each detonation prints a "
314
+ "'network NOT isolated' warning)")
315
+ s.add_argument("--detonate-timeout", type=int, default=10, metavar="SEC",
316
+ help="with --detonate: kill each sandboxed script after SEC "
317
+ "seconds and record a timeout warning (0 disables; "
318
+ "default: 10)")
319
+ s.add_argument("--show-inconclusive", action="store_true",
320
+ help="also list candidates the verifier graded inconclusive "
321
+ "(unknown context) with their file:line and reason")
322
+ s.set_defaults(func=cmd_scan)
323
+
324
+ w = sub.add_parser("watch", help="snapshot installed skills and detect drift")
325
+ wsub = w.add_subparsers(dest="watch_cmd")
326
+ for name, helptext in (("init", "record a hash snapshot"),
327
+ ("diff", "compare current tree against the snapshot"),
328
+ ("show", "print the current snapshot as JSON")):
329
+ wp = wsub.add_parser(name, help=helptext)
330
+ wp.add_argument("path", help="directory of installed skills")
331
+ wp.add_argument("--snapshot", default=".skillsentinel-snapshot.json",
332
+ help="snapshot file path (default: .skillsentinel-snapshot.json)")
333
+ wsub.choices["diff"].add_argument(
334
+ "--rescan", action="store_true",
335
+ help="also re-scan changed/added files and print their confirmed findings")
336
+ w.set_defaults(func=cmd_watch, watch_cmd="show")
337
+
338
+ r = sub.add_parser("rules", help="list detection rules")
339
+ r.add_argument("--json", action="store_true", help="JSON output")
340
+ r.add_argument("--rules", help="path to alternative rules.json")
341
+ r.set_defaults(func=cmd_rules)
342
+ return p
343
+
344
+
345
+ def main(argv: list[str] | None = None) -> int:
346
+ args = build_parser().parse_args(argv)
347
+ try:
348
+ return args.func(args)
349
+ except InputError as e:
350
+ print(f"skillsentinel: error: {e}", file=sys.stderr)
351
+ return 2
352
+ except OSError as e:
353
+ # IO trouble reaching the input (vanished path, unreadable file): the
354
+ # scan target's problem, not the scanner's — still exit 2, one line.
355
+ print(f"skillsentinel: error: {e}", file=sys.stderr)
356
+ return 2
357
+ except Exception as e: # noqa: BLE001 — the CLI boundary turns bugs into exit 3
358
+ print(f"skillsentinel: internal error: {type(e).__name__}: {e}",
359
+ file=sys.stderr)
360
+ return 3
361
+
362
+
363
+ if __name__ == "__main__":
364
+ raise SystemExit(main())
@@ -0,0 +1,236 @@
1
+ """`--detonate`: run skill scripts in a disposable bwrap sandbox, record egress attempts.
2
+
3
+ Network isolation by default: the sandbox is built with --unshare-net, so no process
4
+ inside can open a connection — attempted tool egress (curl/wget/nc/ssh/...) is still
5
+ observed via PATH shims that log the destination. The sandbox also gets its own PID
6
+ namespace (--unshare-pid): killing the bwrap supervisor at a detonation timeout takes
7
+ the whole sandbox tree down with it, so a payload cannot survive as a host orphan.
8
+ The capability preflight runs in every mode: if the host bwrap cannot honor the unshare
9
+ options the mode relies on (--unshare-net and --unshare-pid; only --unshare-pid under
10
+ `allow_net=True`), detonation is refused outright (never a live-egress or
11
+ leaky-process-tree fallback); the caller reports it on stderr and exits 2.
12
+ `allow_net=True` lifts the network isolation explicitly and stamps the report with a
13
+ 'network NOT isolated' warning. If bwrap is unavailable, detonation is skipped and
14
+ reported as such.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import shutil
20
+ import subprocess
21
+ import tempfile
22
+ from dataclasses import dataclass, field, asdict
23
+ from pathlib import Path
24
+
25
+ NETLOG_HELPER = r"""#!/usr/bin/env python3
26
+ import sys, os
27
+ log = os.environ.get("SANDBOX_NETLOG", "/netlog.txt")
28
+ with open(log, "a") as f:
29
+ f.write(sys.argv[0].rsplit("/", 1)[-1] + " " + " ".join(sys.argv[1:]) + "\n")
30
+ sys.exit(7)
31
+ """
32
+
33
+ SHIM_TOOLS = ("curl", "wget", "nc", "ncat", "ping", "telnet", "ssh", "scp", "ftp", "dig")
34
+
35
+
36
+ class DetonationRefusedError(RuntimeError):
37
+ """bwrap cannot guarantee sandbox isolation — detonation must not run."""
38
+
39
+
40
+ @dataclass
41
+ class DetonationReport:
42
+ file: str
43
+ executed: bool
44
+ sandbox: str
45
+ exit_code: int | None = None
46
+ stdout_tail: str = ""
47
+ stderr_tail: str = ""
48
+ network_attempts: list[str] = field(default_factory=list)
49
+ notes: list[str] = field(default_factory=list)
50
+
51
+ def to_dict(self) -> dict:
52
+ return asdict(self)
53
+
54
+
55
+ def _tails(out: str, err: str, n: int = 500) -> tuple[str, str]:
56
+ return out[-n:], err[-n:]
57
+
58
+
59
+ def find_scripts(root: Path) -> list[Path]:
60
+ scripts = []
61
+ for p in sorted(root.rglob("*")):
62
+ if not p.is_file() or p.is_symlink():
63
+ continue
64
+ if p.suffix in (".sh", ".py", ".bash") or p.name == "install":
65
+ scripts.append(p)
66
+ return scripts
67
+
68
+
69
+ def _base_bind_args() -> list[str]:
70
+ """Bind the minimal read-only host dirs needed to run python/sh."""
71
+ args = []
72
+ for d in ("/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc/alternatives"):
73
+ if Path(d).exists():
74
+ args += ["--ro-bind", d, d]
75
+ return args
76
+
77
+
78
+ def _bwrap_capability_probe(bwrap: str, flags: list[str]) -> tuple[bool, str]:
79
+ """Non-destructive preflight: can this bwrap build the sandbox we need?
80
+
81
+ Runs /usr/bin/true once in a throwaway sandbox with `flags` — the unshare
82
+ options the caller's mode relies on: --unshare-net (no egress from the
83
+ sandbox) and --unshare-pid (the payload dies with the bwrap supervisor, so
84
+ a timeout cannot leave it running as a host orphan) — plus the same base
85
+ binds the real detonation uses. Old bwrap builds reject the options
86
+ outright ("unrecognized option", exit 1); any probe failure counts as
87
+ "cannot isolate", so the caller refuses instead of running untrusted code
88
+ with live egress or a leaking process tree. Returns (ok, detail), where
89
+ detail carries the probe's stderr (or launch failure) so the refusal
90
+ message can name the flag that was missing.
91
+ """
92
+ try:
93
+ proc = subprocess.run(
94
+ [bwrap, *flags, *_base_bind_args(), "/usr/bin/true"],
95
+ capture_output=True, text=True, timeout=10,
96
+ )
97
+ except (OSError, subprocess.TimeoutExpired) as e:
98
+ return False, f"probe did not complete: {e}"
99
+ return proc.returncode == 0, (proc.stderr or "").strip()
100
+
101
+
102
+ def detonate_file(path: Path, timeout: int | None = 10,
103
+ allow_net: bool = False) -> DetonationReport:
104
+ bwrap = shutil.which("bwrap")
105
+ if bwrap is None:
106
+ return DetonationReport(
107
+ file=str(path), executed=False, sandbox="none",
108
+ notes=["bwrap not available — detonation skipped "
109
+ "(install bubblewrap to enable)"],
110
+ )
111
+
112
+ # preflight in every mode: a detonation timeout must never leave payload
113
+ # processes on the host, so --unshare-pid is probed (and required) even
114
+ # when network isolation is lifted; --unshare-net is probed alongside it
115
+ # only in the mode that actually uses it
116
+ probe_flags = (["--unshare-pid"] if allow_net
117
+ else ["--unshare-net", "--unshare-pid"])
118
+ capable, detail = _bwrap_capability_probe(bwrap, probe_flags)
119
+ if not capable:
120
+ detail_note = f" Probe said: {detail}" if detail else ""
121
+ if allow_net:
122
+ raise DetonationRefusedError(
123
+ f"{bwrap} does not support --unshare-pid — detonation "
124
+ "refused: without its own PID namespace a detonation timeout "
125
+ "can leave payload processes running on the host, and "
126
+ "--allow-net lifts only the network guarantee, not this one. "
127
+ f"Upgrade bubblewrap to a build with --unshare-pid support."
128
+ f"{detail_note}")
129
+ raise DetonationRefusedError(
130
+ f"{bwrap} does not support --unshare-net/--unshare-pid — "
131
+ "detonation refused: without them the sandbox would run untrusted "
132
+ "code with real egress and a detonation timeout could leave "
133
+ "payload processes running on the host. Upgrade bubblewrap to a "
134
+ "build with --unshare-net and --unshare-pid support, or pass "
135
+ f"--allow-net to explicitly accept a networked sandbox."
136
+ f"{detail_note}")
137
+
138
+ with tempfile.TemporaryDirectory(prefix="ssdet_") as tmp:
139
+ tmpdir = Path(tmp)
140
+ netlog = tmpdir / "netlog.txt"
141
+ netlog.touch()
142
+ work = tmpdir / "work"
143
+ work.mkdir()
144
+ home = tmpdir / "home"
145
+ home.mkdir()
146
+ script_copy = work / path.name
147
+ script_copy.write_bytes(path.read_bytes())
148
+ if script_copy.suffix in (".sh", ".bash") or script_copy.name == "install":
149
+ script_copy.chmod(0o755)
150
+
151
+ shims = tmpdir / "shims"
152
+ shims.mkdir()
153
+ for tool in SHIM_TOOLS:
154
+ f = shims / tool
155
+ f.write_text(NETLOG_HELPER)
156
+ f.chmod(0o755)
157
+
158
+ cmd = [
159
+ bwrap,
160
+ *_base_bind_args(),
161
+ "--proc", "/proc",
162
+ "--dev", "/dev",
163
+ "--tmpfs", "/tmp",
164
+ "--ro-bind", str(work), "/work",
165
+ "--ro-bind", str(shims), "/shims",
166
+ "--bind", str(home), "/home/sandbox",
167
+ "--bind", str(netlog), "/netlog.txt",
168
+ "--chdir", "/work",
169
+ ]
170
+ if not allow_net:
171
+ cmd.append("--unshare-net")
172
+ # own PID namespace: when the bwrap supervisor is killed at a timeout,
173
+ # the kernel tears down the whole sandbox tree — without it the payload
174
+ # would outlive the monitor as an orphan host process
175
+ cmd += ["--unshare-pid", "--die-with-parent", "--new-session"]
176
+ if script_copy.suffix == ".py":
177
+ cmd += ["python3", f"/work/{path.name}"]
178
+ else:
179
+ cmd += ["/bin/sh", f"/work/{path.name}"]
180
+
181
+ env = {
182
+ "PATH": "/shims:/usr/bin:/bin",
183
+ "HOME": "/home/sandbox",
184
+ "SANDBOX_NETLOG": "/netlog.txt",
185
+ }
186
+ timeout_note = None
187
+ run_kwargs: dict = {"capture_output": True, "text": True, "env": env}
188
+ if timeout is not None and timeout > 0:
189
+ run_kwargs["timeout"] = timeout
190
+ try:
191
+ proc = subprocess.run(cmd, **run_kwargs)
192
+ rc, out, err = proc.returncode, proc.stdout, proc.stderr
193
+ except subprocess.TimeoutExpired as e:
194
+ # the sandbox was killed at the deadline — recorded as a warning,
195
+ # never propagated: one runaway script must not end the run
196
+ rc = None
197
+ out = (e.stdout or "")
198
+ out = out.decode() if isinstance(out, bytes) else out
199
+ err = "detonation timed out"
200
+ timeout_note = (f"detonation timed out after {timeout} s — the "
201
+ "script was killed; scanning continues")
202
+ except OSError as e:
203
+ return DetonationReport(
204
+ file=str(path), executed=False, sandbox="bwrap",
205
+ notes=[f"failed to launch bwrap: {e}"],
206
+ )
207
+
208
+ try:
209
+ attempts = [l for l in netlog.read_text().splitlines() if l.strip()]
210
+ except OSError:
211
+ attempts = []
212
+
213
+ o, e2 = _tails(out, err)
214
+ if allow_net:
215
+ net_note = ("network NOT isolated (--allow-net): the sandbox shares "
216
+ "the host network — real egress is possible")
217
+ else:
218
+ net_note = ("network isolated (--unshare-net): no connection can be "
219
+ "opened from inside the sandbox")
220
+ notes = [net_note,
221
+ "attempted tool egress is logged via PATH shims "
222
+ "(curl/wget/nc/ssh/...)"]
223
+ if timeout_note is not None:
224
+ notes.append(timeout_note)
225
+ return DetonationReport(
226
+ file=str(path), executed=True, sandbox="bwrap",
227
+ exit_code=rc, stdout_tail=o, stderr_tail=e2,
228
+ network_attempts=attempts,
229
+ notes=notes,
230
+ )
231
+
232
+
233
+ def detonate_tree(root: Path, allow_net: bool = False,
234
+ timeout: int | None = 10) -> list[DetonationReport]:
235
+ return [detonate_file(p, timeout=timeout, allow_net=allow_net)
236
+ for p in find_scripts(root)]