skill-sentry-cli 2.0.0__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.
Files changed (96) hide show
  1. skill_sentry_cli/__init__.py +5 -0
  2. skill_sentry_cli/__main__.py +4 -0
  3. skill_sentry_cli/cli.py +918 -0
  4. skill_sentry_cli/core/__init__.py +1 -0
  5. skill_sentry_cli/core/anchors/__init__.py +5 -0
  6. skill_sentry_cli/core/anchors/credential_flow.py +440 -0
  7. skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
  8. skill_sentry_cli/core/cache/__init__.py +35 -0
  9. skill_sentry_cli/core/cache/hash_utils.py +206 -0
  10. skill_sentry_cli/core/cache/sqlite_store.py +925 -0
  11. skill_sentry_cli/core/config.py +299 -0
  12. skill_sentry_cli/core/contracts.py +551 -0
  13. skill_sentry_cli/core/decisions/__init__.py +44 -0
  14. skill_sentry_cli/core/decisions/base.py +174 -0
  15. skill_sentry_cli/core/decisions/calibration.py +325 -0
  16. skill_sentry_cli/core/decisions/laya.py +439 -0
  17. skill_sentry_cli/core/decisions/mapping.py +218 -0
  18. skill_sentry_cli/core/decisions/mock.py +118 -0
  19. skill_sentry_cli/core/decisions/questions.py +118 -0
  20. skill_sentry_cli/core/decisions/report.py +65 -0
  21. skill_sentry_cli/core/decisions/selector.py +151 -0
  22. skill_sentry_cli/core/engine.py +1412 -0
  23. skill_sentry_cli/core/evasion/__init__.py +27 -0
  24. skill_sentry_cli/core/evasion/detector.py +210 -0
  25. skill_sentry_cli/core/exit_codes.py +13 -0
  26. skill_sentry_cli/core/models/__init__.py +20 -0
  27. skill_sentry_cli/core/models/_json.py +147 -0
  28. skill_sentry_cli/core/models/base.py +50 -0
  29. skill_sentry_cli/core/models/gemma_cloud.py +240 -0
  30. skill_sentry_cli/core/models/gemma_local.py +273 -0
  31. skill_sentry_cli/core/models/image_utils.py +112 -0
  32. skill_sentry_cli/core/models/mock.py +152 -0
  33. skill_sentry_cli/core/models/selector.py +123 -0
  34. skill_sentry_cli/core/parser/__init__.py +29 -0
  35. skill_sentry_cli/core/parser/skill_parser.py +520 -0
  36. skill_sentry_cli/core/parser/typosquat.py +167 -0
  37. skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
  38. skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
  39. skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
  40. skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
  41. skill_sentry_cli/core/reports.py +114 -0
  42. skill_sentry_cli/core/sandbox/__init__.py +74 -0
  43. skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
  44. skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
  45. skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
  46. skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
  47. skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
  48. skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
  49. skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
  50. skill_sentry_cli/core/sandbox/fake_http.py +194 -0
  51. skill_sentry_cli/core/sandbox/instructions.py +170 -0
  52. skill_sentry_cli/core/sandbox/masquerade.py +265 -0
  53. skill_sentry_cli/core/sandbox/monitor.py +278 -0
  54. skill_sentry_cli/core/sandbox/runner.py +776 -0
  55. skill_sentry_cli/core/sandbox/sidecar.py +327 -0
  56. skill_sentry_cli/core/semantic/__init__.py +9 -0
  57. skill_sentry_cli/core/semantic/analyzer.py +443 -0
  58. skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
  59. skill_sentry_cli/core/semantic/ast_python.py +156 -0
  60. skill_sentry_cli/core/semantic/candidates.py +78 -0
  61. skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
  62. skill_sentry_cli/core/semantic/prompts.py +61 -0
  63. skill_sentry_cli/core/verdict/__init__.py +24 -0
  64. skill_sentry_cli/core/verdict/fusion.py +392 -0
  65. skill_sentry_cli/core/verdict/markdown_report.py +211 -0
  66. skill_sentry_cli/core/verdict/sarif_report.py +139 -0
  67. skill_sentry_cli/core/verdict/status.py +35 -0
  68. skill_sentry_cli/core/verdict/stix_report.py +132 -0
  69. skill_sentry_cli/core/version.py +38 -0
  70. skill_sentry_cli/core/vision/__init__.py +8 -0
  71. skill_sentry_cli/core/vision/image_extractor.py +91 -0
  72. skill_sentry_cli/core/vision/vision_scanner.py +274 -0
  73. skill_sentry_cli/data/__init__.py +1 -0
  74. skill_sentry_cli/data/known_skills_v1.json +276 -0
  75. skill_sentry_cli/data/sandbox_image.json +4 -0
  76. skill_sentry_cli/rules/credential_rules.toml +94 -0
  77. skill_sentry_cli/server/__init__.py +5 -0
  78. skill_sentry_cli/server/app.py +129 -0
  79. skill_sentry_cli/server/backend.py +63 -0
  80. skill_sentry_cli/server/demo_assets.py +62 -0
  81. skill_sentry_cli/server/demo_backend.py +475 -0
  82. skill_sentry_cli/server/demo_data.py +814 -0
  83. skill_sentry_cli/server/demo_mode.py +40 -0
  84. skill_sentry_cli/server/errors.py +48 -0
  85. skill_sentry_cli/server/live_backend.py +576 -0
  86. skill_sentry_cli/server/routes.py +191 -0
  87. skill_sentry_cli/skill/SKILL.md +67 -0
  88. skill_sentry_cli/skill/scripts/audit.sh +48 -0
  89. skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
  90. skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
  91. skill_sentry_cli/ui/dist/index.html +14 -0
  92. skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
  93. skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
  94. skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
  95. skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
  96. skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,776 @@
1
+ """Docker sandbox lifecycle: limits, masquerade, canary seeding, teardown.
2
+
3
+ This module is deliberately usable without a Docker daemon in unit tests: pass
4
+ a fake client to :class:`SandboxRunner`. The real client is created lazily, so
5
+ importing the server does not require Docker.
6
+
7
+ Security invariants:
8
+ * host mounts are the skill directory (read-only) and ``/dev/null`` for
9
+ ``/.dockerenv``; fake cgroup/mountinfo views are served by the shim rather
10
+ than by Docker 29-incompatible bind mounts inside ``/proc``;
11
+ * the network is an ``internal`` bridge (no NAT);
12
+ * the container is read-only, non-root, cap-drop ALL, no-new-privileges;
13
+ * teardown runs in ``finally`` even on timeout or exceptions.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import contextlib
20
+ import ipaddress
21
+ import threading
22
+ import time
23
+ from collections.abc import Callable
24
+ from contextlib import suppress
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import Any
29
+ from uuid import UUID
30
+
31
+ from skill_sentry_cli.core.contracts import (
32
+ Finding,
33
+ FindingEvidence,
34
+ NetLog,
35
+ SandboxTrace,
36
+ Severity,
37
+ Stage,
38
+ StageResult,
39
+ utc_now,
40
+ )
41
+ from skill_sentry_cli.core.poisoned_well.canary_gen import (
42
+ CANARY_ROOT,
43
+ CanarySet,
44
+ generate_canary_set,
45
+ )
46
+ from skill_sentry_cli.core.sandbox.canary_fs import build_canary_tar
47
+ from skill_sentry_cli.core.sandbox.context_honeypot import runtime_environment
48
+ from skill_sentry_cli.core.sandbox.masquerade import (
49
+ FAKE_CGROUP_CONTENT,
50
+ FAKE_MOUNTINFO_CONTENT,
51
+ MasqueradeCheck,
52
+ MasqueradeConfig,
53
+ MasqueradeError,
54
+ require_masquerade,
55
+ run_script_text,
56
+ verify_container,
57
+ )
58
+ from skill_sentry_cli.core.sandbox.monitor import (
59
+ MonitorEvent,
60
+ normalise_paths,
61
+ parse_monitor_log,
62
+ synthetic_event,
63
+ )
64
+ from skill_sentry_cli.core.sandbox.sidecar import SinkholeObservation, SinkholeSidecar
65
+
66
+ LABEL_KEY = "skillsentry"
67
+ LABEL_VALUE = "true"
68
+ CANARY_HOME = CANARY_ROOT
69
+ MAX_CAPTURED_OUTPUT_BYTES = 4 * 1024 * 1024
70
+ _SETUP_WRITER = (
71
+ "import base64, os, sys; "
72
+ "open(sys.argv[1], 'wb').write(base64.b64decode(sys.argv[2])); "
73
+ "os.chmod(sys.argv[1], int(sys.argv[3], 8))"
74
+ )
75
+ # The network name/subnet and sidecar IP are process-wide fixed resources.
76
+ # Lock across runner instances too; the per-instance lock alone would let two
77
+ # API engines (for example different model backends) collide.
78
+ _RUN_LOCK = threading.Lock()
79
+
80
+
81
+ class SandboxUnavailable(RuntimeError):
82
+ """Docker is not installed, or its daemon is unreachable."""
83
+
84
+
85
+ class SandboxError(RuntimeError):
86
+ """A sandbox operation failed; the caller must not synthesise a SAFE verdict."""
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class SandboxRequest:
91
+ scan_id: UUID
92
+ skill_dir: Path
93
+ declared_intent: str = ""
94
+ command: tuple[str, ...] | None = None
95
+ canary_marker: str | None = None
96
+ secondary_canary: str | None = None
97
+ timeout_s: float | None = None
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class SandboxConfig:
102
+ image: str = "skillsentry-sandbox:local"
103
+ user: str = "65534:65534"
104
+ network_name: str = "skillsentry-sinkhole"
105
+ home: str = CANARY_HOME
106
+ skill_mount: str = "/skill"
107
+ memory_mb: int = 512
108
+ cpus: float = 1.0
109
+ pids_limit: int = 128
110
+ timeout_s: float = 60.0
111
+ poll_interval_s: float = 0.1
112
+ stop_timeout_s: float = 1.0
113
+ # None means "no explicit resolver": the live engine supplies the
114
+ # per-scan sinkhole sidecar's internal address. Never default to loopback;
115
+ # a container's loopback is not a sinkhole and would hide egress.
116
+ dns_server: str | None = None
117
+ sidecar_subnet: str = "172.30.77.0/24"
118
+ sidecar_ip: str = "172.30.77.2"
119
+ sidecar_image: str = "python:3.11-slim"
120
+ verify_masquerade: bool = True
121
+ cleanup_stale: bool = True
122
+ cleanup_network: bool = True
123
+ labels: dict[str, str] = field(default_factory=dict)
124
+ masquerade: MasqueradeConfig = field(default_factory=MasqueradeConfig)
125
+
126
+ def __post_init__(self) -> None:
127
+ if not self.image.strip():
128
+ raise ValueError("sandbox image must not be empty")
129
+ if not (128 <= self.memory_mb <= 4096):
130
+ raise ValueError("memory_mb must be between 128 and 4096")
131
+ if self.cpus <= 0:
132
+ raise ValueError("cpus must be positive")
133
+ if self.pids_limit < 16:
134
+ raise ValueError("pids_limit must be at least 16")
135
+ if self.timeout_s <= 0:
136
+ raise ValueError("timeout_s must be positive")
137
+ if self.dns_server:
138
+ try:
139
+ if ipaddress.ip_address(self.dns_server).is_loopback:
140
+ raise ValueError("sandbox DNS server must not be loopback")
141
+ except ValueError:
142
+ # Hostnames are not valid Docker --dns targets in this design.
143
+ raise
144
+ try:
145
+ network = ipaddress.ip_network(self.sidecar_subnet, strict=False)
146
+ if ipaddress.ip_address(self.sidecar_ip) not in network:
147
+ raise ValueError("sidecar_ip must belong to sidecar_subnet")
148
+ except ValueError:
149
+ raise
150
+
151
+ @property
152
+ def all_labels(self) -> dict[str, str]:
153
+ labels = {LABEL_KEY: LABEL_VALUE}
154
+ labels.update(self.labels)
155
+ return labels
156
+
157
+
158
+ @dataclass
159
+ class SandboxResult:
160
+ scan_id: UUID
161
+ container_id: str
162
+ canaries: CanarySet
163
+ exit_code: int | None
164
+ timed_out: bool
165
+ duration_ms: int
166
+ stdout: str
167
+ stderr: str
168
+ started_at: datetime = field(default_factory=utc_now)
169
+ events: list[MonitorEvent] = field(default_factory=list)
170
+ net_logs: list[NetLog] = field(default_factory=list)
171
+ sinkhole_observations: list[SinkholeObservation] = field(default_factory=list)
172
+ resource_usage: dict[str, Any] | None = None
173
+ masquerade: MasqueradeCheck | None = None
174
+ cleanup_ok: bool = True
175
+ error: str | None = None
176
+ timeout_s: float | None = None
177
+
178
+ @property
179
+ def canary_reads(self) -> list[MonitorEvent]:
180
+ return [event for event in self.events if event.event_type == "canary_read"]
181
+
182
+ @property
183
+ def probes(self) -> list[MonitorEvent]:
184
+ return [event for event in self.events if event.event_type == "probe_detected"]
185
+
186
+ def add_net_log(self, log: NetLog) -> None:
187
+ self.net_logs.append(log)
188
+
189
+ def to_trace(self) -> SandboxTrace:
190
+ return SandboxTrace(
191
+ events=[event.to_contract() for event in self.events],
192
+ net_logs=self.net_logs,
193
+ resource_usage=self.resource_usage,
194
+ )
195
+
196
+ def sandbox_signal(self) -> float:
197
+ if self.probes:
198
+ return 100.0
199
+ if self.canary_reads:
200
+ return 75.0
201
+ if self.timed_out:
202
+ return 40.0
203
+ if self.exit_code not in (0, None):
204
+ return 20.0
205
+ return 0.0
206
+
207
+ def findings(self) -> list[Finding]:
208
+ findings: list[Finding] = []
209
+ seen_paths: set[str] = set()
210
+ for event in self.canary_reads:
211
+ path = event.path_or_channel or "unknown canary"
212
+ if path in seen_paths:
213
+ continue
214
+ seen_paths.add(path)
215
+ findings.append(
216
+ Finding(
217
+ stage=Stage.SANDBOX,
218
+ severity=Severity.HIGH,
219
+ message=f"Sandboxed skill read a canary credential: {path}.",
220
+ finding_type="canary-read",
221
+ evidence=FindingEvidence(
222
+ path=path,
223
+ process=event.process,
224
+ marker=event.marker,
225
+ ),
226
+ )
227
+ )
228
+ if self.timed_out:
229
+ findings.append(
230
+ Finding(
231
+ stage=Stage.SANDBOX,
232
+ severity=Severity.MEDIUM,
233
+ message=(
234
+ f"Sandbox execution exceeded its {self.timeout_s:g} s limit and was "
235
+ f"killed after {self.duration_ms} ms (including container startup)."
236
+ if self.timeout_s is not None
237
+ else f"Sandbox execution exceeded its limit and was killed after "
238
+ f"{self.duration_ms} ms."
239
+ ),
240
+ finding_type="sandbox-timeout",
241
+ )
242
+ )
243
+ if self.exit_code not in (0, None) and not self.timed_out:
244
+ findings.append(
245
+ Finding(
246
+ stage=Stage.SANDBOX,
247
+ severity=Severity.LOW,
248
+ message=f"Skill exited with status {self.exit_code} inside the sandbox.",
249
+ finding_type="sandbox-exit",
250
+ )
251
+ )
252
+ if not self.cleanup_ok:
253
+ findings.append(
254
+ Finding(
255
+ stage=Stage.SANDBOX,
256
+ severity=Severity.NOTE,
257
+ message="Sandbox teardown reported an error; inspect the engine log.",
258
+ finding_type="sandbox-cleanup",
259
+ )
260
+ )
261
+ return findings
262
+
263
+ def to_stage_result(self) -> StageResult:
264
+ trace = self.to_trace()
265
+ return StageResult(
266
+ stage=Stage.SANDBOX,
267
+ findings=self.findings(),
268
+ signals={"sandbox": self.sandbox_signal()},
269
+ telemetry={
270
+ "sandboxTrace": trace.model_dump(by_alias=True, mode="json"),
271
+ "startedAt": self.started_at.isoformat(),
272
+ "exitCode": self.exit_code,
273
+ "timedOut": self.timed_out,
274
+ "cleanupOk": self.cleanup_ok,
275
+ "masquerade": self.masquerade.to_dict() if self.masquerade else None,
276
+ },
277
+ degraded=self.timed_out or not self.cleanup_ok,
278
+ )
279
+
280
+
281
+ class SandboxRunner:
282
+ """Synchronous Docker lifecycle wrapper.
283
+
284
+ The orchestrator should call :meth:`run` in a worker thread. A fake client
285
+ only needs ``containers``, ``networks``, ``api``, and (optionally) ``ping``.
286
+ """
287
+
288
+ def __init__(
289
+ self,
290
+ client: Any | None = None,
291
+ config: SandboxConfig | None = None,
292
+ *,
293
+ sidecar_factory: Callable[[Any], SinkholeSidecar] | None = None,
294
+ ) -> None:
295
+ self.config = config or SandboxConfig()
296
+ self._client = client
297
+ self._cleaned = False
298
+ self._sidecar_factory = sidecar_factory
299
+ self._active_dns_server: str | None = self.config.dns_server
300
+ # Every scan currently shares one internal network and the sidecar's
301
+ # fixed address. A second sidecar on that network would either fail to
302
+ # bind or hijack the first scan's DNS, so all runners serialise scans.
303
+ self._run_lock = _RUN_LOCK
304
+
305
+ @property
306
+ def client(self) -> Any:
307
+ if self._client is None:
308
+ try:
309
+ import docker
310
+ except ImportError as exc: # pragma: no cover - depends on environment
311
+ raise SandboxUnavailable("the docker Python SDK is not installed") from exc
312
+ try:
313
+ self._client = docker.from_env()
314
+ except Exception as exc: # pragma: no cover - daemon-specific
315
+ raise SandboxUnavailable(f"Docker is unavailable: {exc}") from exc
316
+ return self._client
317
+
318
+ def available(self) -> bool:
319
+ try:
320
+ ping = getattr(self.client, "ping", None)
321
+ if ping is None:
322
+ return True
323
+ return bool(ping())
324
+ except Exception:
325
+ return False
326
+
327
+ def close(self) -> None:
328
+ """Release the Docker client. Safe to call more than once."""
329
+
330
+ client, self._client = self._client, None
331
+ if client is not None:
332
+ with contextlib.suppress(Exception):
333
+ client.close()
334
+
335
+ def cleanup_stale(self) -> int:
336
+ """Remove labelled containers left by a crashed previous run."""
337
+
338
+ removed = 0
339
+ client = self.client
340
+ with suppress(Exception):
341
+ for container in client.containers.list(
342
+ all=True, filters={"label": f"{LABEL_KEY}={LABEL_VALUE}"}
343
+ ):
344
+ status = getattr(container, "status", "")
345
+ if status not in {"created", "exited", "dead"}:
346
+ continue
347
+ with suppress(Exception):
348
+ container.remove(force=True, v=True)
349
+ removed += 1
350
+ with suppress(Exception):
351
+ for network in client.networks.list(filters={"label": f"{LABEL_KEY}={LABEL_VALUE}"}):
352
+ with suppress(Exception):
353
+ network.remove()
354
+ self._cleaned = True
355
+ return removed
356
+
357
+ def run(self, request: SandboxRequest) -> SandboxResult:
358
+ """Run one scan at a time on this runner.
359
+
360
+ The internal network name, subnet, and sidecar IP are intentionally
361
+ fixed. Serialising here prevents concurrent API scans from colliding
362
+ on those resources while keeping each scan's teardown isolated.
363
+ """
364
+
365
+ with self._run_lock:
366
+ return self._run_locked(request)
367
+
368
+ def _run_locked(self, request: SandboxRequest) -> SandboxResult:
369
+ skill_dir = Path(request.skill_dir).resolve()
370
+ if not skill_dir.is_dir():
371
+ raise SandboxError(f"skill directory does not exist: {skill_dir}")
372
+ timeout_s = request.timeout_s if request.timeout_s is not None else self.config.timeout_s
373
+ if timeout_s <= 0:
374
+ raise SandboxError("sandbox timeout must be positive")
375
+
376
+ client = self.client
377
+ if self.config.cleanup_stale and not self._cleaned:
378
+ self.cleanup_stale()
379
+
380
+ canaries = generate_canary_set(
381
+ request.scan_id,
382
+ request.declared_intent,
383
+ marker=request.canary_marker,
384
+ secondary_marker=request.secondary_canary,
385
+ )
386
+ network = None
387
+ network_created = False
388
+ container = None
389
+ sidecar: SinkholeSidecar | None = None
390
+ result: SandboxResult | None = None
391
+ masquerade_config = self.config.masquerade
392
+ started_at = utc_now()
393
+ started = time.monotonic()
394
+
395
+ try:
396
+ network, network_created = self._ensure_network(client)
397
+ if self._sidecar_factory is not None:
398
+ sidecar = self._sidecar_factory(client)
399
+ sidecar.start(network, request.scan_id, image=self.config.sidecar_image)
400
+ self._active_dns_server = sidecar.ip
401
+ container = self._create_container(
402
+ client, request, skill_dir, canaries, network, masquerade_config
403
+ )
404
+ container.start()
405
+ self._seed_canaries(container, canaries)
406
+ self._seed_setup(container)
407
+ if self.config.verify_masquerade:
408
+ check = verify_container(container, config=masquerade_config, user=self.config.user)
409
+ require_masquerade(check)
410
+ else:
411
+ check = None
412
+ self._clear_monitor_log(container)
413
+ exec_result, stdout, stderr = self._execute(container, request, canaries, timeout_s)
414
+ duration_ms = int((time.monotonic() - started) * 1000)
415
+ events = normalise_paths(
416
+ parse_monitor_log(
417
+ self._read_text(container, "/tmp/skillsentry/events.jsonl")
418
+ ).events,
419
+ canaries,
420
+ )
421
+ seed_events = [
422
+ synthetic_event(
423
+ "canary_seeded",
424
+ path_or_channel=file.display_path,
425
+ marker=file.marker,
426
+ detail={"asset": file.asset},
427
+ ts=started_at,
428
+ )
429
+ for file in canaries.files
430
+ ]
431
+ result = SandboxResult(
432
+ scan_id=request.scan_id,
433
+ container_id=str(getattr(container, "id", "")),
434
+ canaries=canaries,
435
+ exit_code=exec_result["exit_code"],
436
+ timed_out=exec_result["timed_out"],
437
+ duration_ms=duration_ms,
438
+ timeout_s=timeout_s,
439
+ stdout=_truncate(stdout),
440
+ stderr=_truncate(stderr),
441
+ started_at=started_at,
442
+ events=[*seed_events, *events],
443
+ resource_usage=self._resource_usage(container, duration_ms),
444
+ masquerade=check,
445
+ error=exec_result.get("error"),
446
+ )
447
+ return result
448
+ except MasqueradeError:
449
+ raise
450
+ except SandboxUnavailable:
451
+ raise
452
+ except Exception as exc:
453
+ raise SandboxError(f"sandbox execution failed: {exc}") from exc
454
+ finally:
455
+ sidecar_observations: list[SinkholeObservation] = []
456
+ if sidecar is not None:
457
+ try:
458
+ sidecar_observations = sidecar.stop()
459
+ except Exception as exc: # teardown must never mask the verdict
460
+ if result is not None and result.error is None:
461
+ result.error = f"sinkhole sidecar teardown failed: {exc}"
462
+ cleanup_ok, cleanup_errors = self._teardown(container, network, network_created)
463
+ if result is not None:
464
+ result.cleanup_ok = cleanup_ok
465
+ if cleanup_errors and result.error is None:
466
+ result.error = "; ".join(cleanup_errors)
467
+ result.sinkhole_observations = sidecar_observations
468
+ result.net_logs = [_net_log(observation) for observation in sidecar_observations]
469
+
470
+ # -- Docker pieces ------------------------------------------------------
471
+ def _ensure_network(self, client: Any) -> tuple[Any, bool]:
472
+ network = None
473
+ try:
474
+ network = client.networks.get(self.config.network_name)
475
+ except Exception as exc:
476
+ if not _is_not_found(exc):
477
+ raise
478
+ if network is not None:
479
+ attrs = getattr(network, "attrs", {}) or {}
480
+ if not attrs.get("Internal", False):
481
+ raise SandboxError(
482
+ f"existing network {self.config.network_name!r} is not internal; "
483
+ "refusing to run with NAT"
484
+ )
485
+ return network, False
486
+ kwargs: dict[str, Any] = {
487
+ "driver": "bridge",
488
+ "internal": True,
489
+ "attachable": False,
490
+ "enable_ipv6": False,
491
+ "labels": {**self.config.all_labels, f"{LABEL_KEY}.role": "sinkhole"},
492
+ }
493
+ if self._sidecar_factory is not None:
494
+ kwargs["ipam"] = {
495
+ "Driver": "default",
496
+ "Config": [{"Subnet": self.config.sidecar_subnet}],
497
+ }
498
+ network = client.networks.create(self.config.network_name, **kwargs)
499
+ return network, True
500
+
501
+ def _create_container(
502
+ self,
503
+ client: Any,
504
+ request: SandboxRequest,
505
+ skill_dir: Path,
506
+ canaries: CanarySet,
507
+ network: Any,
508
+ masquerade: MasqueradeConfig,
509
+ ) -> Any:
510
+ # Keep the Docker SDK an optional runtime dependency: static-only scans
511
+ # must still import the engine on hosts without the Docker package.
512
+ from docker.types import Mount
513
+
514
+ labels = {
515
+ **self.config.all_labels,
516
+ f"{LABEL_KEY}.scan_id": str(request.scan_id),
517
+ f"{LABEL_KEY}.skill": skill_dir.name,
518
+ }
519
+ volumes = {
520
+ str(skill_dir): {"bind": self.config.skill_mount, "mode": "ro"},
521
+ **masquerade.volumes(),
522
+ }
523
+ # put_archive() cannot target a read-only rootfs. The canary home is
524
+ # an anonymous volume so it can be seeded and removed with the
525
+ # container (remove(v=True)); /tmp remains a bounded tmpfs and its
526
+ # setup files are written by _seed_setup().
527
+ mounts = [Mount(target=self.config.home, source=None, type="volume")]
528
+ tmpfs = {
529
+ "/tmp": "rw,nosuid,nodev,noexec,size=64m,mode=1777",
530
+ **masquerade.tmpfs(),
531
+ }
532
+ environment = {
533
+ "HOME": self.config.home,
534
+ "PYTHONUNBUFFERED": "1",
535
+ **masquerade.environment(),
536
+ }
537
+ kwargs: dict[str, Any] = {
538
+ "image": self.config.image,
539
+ "entrypoint": ["/bin/sh", "-c"],
540
+ "command": ["trap 'exit 0' TERM INT; while :; do sleep 3600; done"],
541
+ "detach": True,
542
+ "user": self.config.user,
543
+ "working_dir": self.config.skill_mount,
544
+ "hostname": "devbox",
545
+ "network": self.config.network_name,
546
+ "mem_limit": f"{self.config.memory_mb}m",
547
+ "memswap_limit": f"{self.config.memory_mb}m",
548
+ "nano_cpus": int(self.config.cpus * 1_000_000_000),
549
+ "pids_limit": self.config.pids_limit,
550
+ "read_only": True,
551
+ "security_opt": ["no-new-privileges"],
552
+ "cap_drop": ["ALL"],
553
+ "volumes": volumes,
554
+ "mounts": mounts,
555
+ "tmpfs": tmpfs,
556
+ "environment": environment,
557
+ "labels": labels,
558
+ "auto_remove": False,
559
+ "init": True,
560
+ "stdin_open": False,
561
+ "tty": False,
562
+ }
563
+ dns_server = self._active_dns_server or self.config.dns_server
564
+ if dns_server:
565
+ kwargs["dns"] = [dns_server]
566
+ return client.containers.create(**kwargs)
567
+
568
+ def _seed_canaries(self, container: Any, canaries: CanarySet) -> None:
569
+ container.put_archive(self.config.home, build_canary_tar(canaries))
570
+
571
+ def _seed_setup(self, container: Any) -> None:
572
+ created = container.exec_run(
573
+ ["/bin/sh", "-c", "mkdir -p /tmp/skillsentry && chmod 0700 /tmp/skillsentry"],
574
+ user=self.config.user,
575
+ )
576
+ if created.exit_code not in (0, None):
577
+ raise SandboxError("could not create the sandbox setup directory")
578
+ # The setup directory lives on the /tmp tmpfs. Docker refuses
579
+ # put_archive() against a read-only rootfs even when the target is a
580
+ # tmpfs, so write the four small files through an exec as the sandbox
581
+ # user. This also keeps their ownership writable by the skill.
582
+ for name, payload, mode in _setup_entries():
583
+ written = container.exec_run(
584
+ [
585
+ "python3",
586
+ "-c",
587
+ _SETUP_WRITER,
588
+ f"/tmp/skillsentry/{name}",
589
+ base64.b64encode(payload).decode("ascii"),
590
+ format(mode, "o"),
591
+ ],
592
+ user=self.config.user,
593
+ )
594
+ if written.exit_code not in (0, None):
595
+ raise SandboxError(f"could not write sandbox setup file: {name}")
596
+
597
+ def _clear_monitor_log(self, container: Any) -> None:
598
+ cleared = container.exec_run(
599
+ ["/bin/sh", "-c", ": > /tmp/skillsentry/events.jsonl"],
600
+ user=self.config.user,
601
+ )
602
+ if cleared.exit_code not in (0, None):
603
+ raise SandboxError("could not reset the monitor log after masquerade verification")
604
+
605
+ def _execute(
606
+ self,
607
+ container: Any,
608
+ request: SandboxRequest,
609
+ canaries: CanarySet,
610
+ timeout_s: float,
611
+ ) -> tuple[dict[str, Any], str, str]:
612
+ command = (
613
+ list(request.command)
614
+ if request.command
615
+ else [
616
+ "/bin/sh",
617
+ "/tmp/skillsentry/run_skill.sh",
618
+ ]
619
+ )
620
+ environment = runtime_environment(canaries)
621
+ environment.update(self.config.masquerade.environment())
622
+ environment["PYTHONUNBUFFERED"] = "1"
623
+ wrapped = [
624
+ "/bin/sh",
625
+ "-c",
626
+ 'exec "$@" > /tmp/skillsentry/stdout.log 2> /tmp/skillsentry/stderr.log',
627
+ "skillsentry",
628
+ *command,
629
+ ]
630
+ client = self.client
631
+ exec_id = client.api.exec_create(
632
+ container.id,
633
+ wrapped,
634
+ stdout=False,
635
+ stderr=False,
636
+ stdin=False,
637
+ tty=False,
638
+ privileged=False,
639
+ user=self.config.user,
640
+ environment=environment,
641
+ workdir=self.config.skill_mount,
642
+ )["Id"]
643
+ client.api.exec_start(exec_id, detach=True)
644
+
645
+ deadline = time.monotonic() + timeout_s
646
+ exit_code: int | None = None
647
+ timed_out = False
648
+ while True:
649
+ info = client.api.exec_inspect(exec_id)
650
+ if not info.get("Running", False):
651
+ exit_code = info.get("ExitCode")
652
+ break
653
+ if time.monotonic() >= deadline:
654
+ timed_out = True
655
+ with suppress(Exception):
656
+ container.stop(timeout=max(1, round(self.config.stop_timeout_s)))
657
+ with suppress(Exception):
658
+ container.kill()
659
+ exit_code = 124
660
+ break
661
+ time.sleep(max(0.01, self.config.poll_interval_s))
662
+
663
+ stdout = self._read_text(container, "/tmp/skillsentry/stdout.log")
664
+ stderr = self._read_text(container, "/tmp/skillsentry/stderr.log")
665
+ return (
666
+ {
667
+ "exit_code": exit_code,
668
+ "timed_out": timed_out,
669
+ "error": f"sandbox timed out after {timeout_s:g}s" if timed_out else None,
670
+ },
671
+ stdout,
672
+ stderr,
673
+ )
674
+
675
+ def _read_text(self, container: Any, path: str) -> str:
676
+ # Never ask the container for an unbounded file. A hostile skill can
677
+ # write forever to stdout.log; ``head`` closes the pipe early and the
678
+ # host-side slice protects against a fake/compromised container.
679
+ with suppress(Exception):
680
+ result = container.exec_run(
681
+ ["head", "-c", str(MAX_CAPTURED_OUTPUT_BYTES), path],
682
+ user=self.config.user,
683
+ )
684
+ if result.exit_code in (0, None) and result.output:
685
+ if isinstance(result.output, bytes):
686
+ text = result.output.decode("utf-8", errors="replace")
687
+ else:
688
+ text = str(result.output)
689
+ return text[:MAX_CAPTURED_OUTPUT_BYTES]
690
+ return ""
691
+
692
+ def _resource_usage(self, container: Any, duration_ms: int) -> dict[str, Any] | None:
693
+ with suppress(Exception):
694
+ stats = container.stats(stream=False)
695
+ if not isinstance(stats, dict):
696
+ return None
697
+ memory = stats.get("memory_stats", {}) or {}
698
+ used = int(memory.get("usage", 0) or 0)
699
+ peak = int(memory.get("max_usage", used) or used)
700
+ cpu = stats.get("cpu_stats", {}) or {}
701
+ previous = stats.get("precpu_stats", {}) or {}
702
+ cpu_total = int((cpu.get("cpu_usage") or {}).get("total_usage", 0) or 0)
703
+ previous_total = int((previous.get("cpu_usage") or {}).get("total_usage", 0) or 0)
704
+ system_total = int(cpu.get("system_cpu_usage", 0) or 0)
705
+ previous_system = int(previous.get("system_cpu_usage", 0) or 0)
706
+ online = int(cpu.get("online_cpus", 1) or 1)
707
+ cpu_delta = cpu_total - previous_total
708
+ system_delta = system_total - previous_system
709
+ cpu_percent = (
710
+ round((cpu_delta / system_delta) * online * 100.0, 1)
711
+ if cpu_delta > 0 and system_delta > 0
712
+ else 0.0
713
+ )
714
+ return {
715
+ "durationMs": duration_ms,
716
+ "peakMemoryMb": round(peak / (1024 * 1024), 1),
717
+ "cpuPercent": cpu_percent,
718
+ }
719
+ return None
720
+
721
+ def _teardown(
722
+ self,
723
+ container: Any | None,
724
+ network: Any | None,
725
+ network_created: bool,
726
+ ) -> tuple[bool, list[str]]:
727
+ ok = True
728
+ errors: list[str] = []
729
+ if container is not None:
730
+ with suppress(Exception):
731
+ container.stop(timeout=max(1, round(self.config.stop_timeout_s)))
732
+ try:
733
+ container.remove(force=True, v=True)
734
+ except Exception as exc:
735
+ ok = False
736
+ errors.append(f"container remove failed: {exc}")
737
+ if network is not None and network_created and self.config.cleanup_network:
738
+ try:
739
+ network.remove()
740
+ except Exception as exc:
741
+ ok = False
742
+ errors.append(f"network remove failed: {exc}")
743
+ return ok, errors
744
+
745
+
746
+ def _setup_entries() -> list[tuple[str, bytes, int]]:
747
+ return [
748
+ ("run_skill.sh", run_script_text().encode("utf-8"), 0o755),
749
+ ("cgroup", FAKE_CGROUP_CONTENT.encode("utf-8"), 0o644),
750
+ ("mountinfo", FAKE_MOUNTINFO_CONTENT.encode("utf-8"), 0o644),
751
+ ("events.jsonl", b"", 0o600),
752
+ ]
753
+
754
+
755
+ def _net_log(observation: SinkholeObservation) -> NetLog:
756
+ return NetLog(
757
+ ts=observation.ts,
758
+ protocol=observation.protocol, # type: ignore[arg-type]
759
+ query_or_url=observation.query_or_url,
760
+ body_b64=observation.body_b64,
761
+ responder_action=observation.responder_action,
762
+ )
763
+
764
+
765
+ def _truncate(value: str, limit: int = 100_000) -> str:
766
+ if len(value) <= limit:
767
+ return value
768
+ return f"{value[:limit]}\n... [truncated {len(value) - limit} chars]"
769
+
770
+
771
+ def _is_not_found(exc: Exception) -> bool:
772
+ return getattr(exc, "status_code", None) == 404 or exc.__class__.__name__ in {
773
+ "NotFound",
774
+ "NetworkNotFound",
775
+ "ImageNotFound",
776
+ }