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,170 @@
1
+ """Sandbox execution plan (INT-4).
2
+
3
+ The plan says what the sandbox is asked to check and why. Baseline checks are
4
+ always present. Checks derived from deterministic anchors only add to them, and
5
+ nothing can remove a baseline check. Only the declared entrypoint may run, and a
6
+ plan that fails validation blocks execution, so the stage degrades instead of
7
+ running with a partial or unsafe plan.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from enum import Enum
13
+ from typing import Literal
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from skill_sentry_cli.core.contracts import StageResult
18
+ from skill_sentry_cli.core.parser import SkillPackage
19
+
20
+ MAX_CHECK_TIMEOUT_S = 600
21
+
22
+
23
+ class InstructionCategory(str, Enum):
24
+ TRIGGER_EXECUTION = "TRIGGER_EXECUTION"
25
+ CREDENTIAL_CANARY_MONITOR = "CREDENTIAL_CANARY_MONITOR"
26
+ DNS_SINKHOLE_WATCH = "DNS_SINKHOLE_WATCH"
27
+ HTTP_EXFIL_MONITOR = "HTTP_EXFIL_MONITOR"
28
+ EVASION_PROBE_WATCH = "EVASION_PROBE_WATCH"
29
+
30
+
31
+ class InstructionSource(str, Enum):
32
+ BASELINE = "baseline"
33
+ DETERMINISTIC_ANCHOR = "deterministicAnchor"
34
+
35
+
36
+ BASELINE_CATEGORIES: tuple[InstructionCategory, ...] = (
37
+ InstructionCategory.TRIGGER_EXECUTION,
38
+ InstructionCategory.CREDENTIAL_CANARY_MONITOR,
39
+ InstructionCategory.DNS_SINKHOLE_WATCH,
40
+ InstructionCategory.HTTP_EXFIL_MONITOR,
41
+ InstructionCategory.EVASION_PROBE_WATCH,
42
+ )
43
+
44
+ # Anchor finding types that call for an extra watch, on top of the baseline.
45
+ _ANCHOR_WATCHES: dict[str, InstructionCategory] = {
46
+ "credential-path": InstructionCategory.CREDENTIAL_CANARY_MONITOR,
47
+ "secret-access": InstructionCategory.CREDENTIAL_CANARY_MONITOR,
48
+ "dns-lookup": InstructionCategory.DNS_SINKHOLE_WATCH,
49
+ "exfil-http-post": InstructionCategory.HTTP_EXFIL_MONITOR,
50
+ "raw-socket": InstructionCategory.HTTP_EXFIL_MONITOR,
51
+ "reverse-shell": InstructionCategory.HTTP_EXFIL_MONITOR,
52
+ "sandbox-artifact": InstructionCategory.EVASION_PROBE_WATCH,
53
+ }
54
+
55
+
56
+ class SandboxActionInstruction(BaseModel):
57
+ model_config = ConfigDict(extra="forbid")
58
+
59
+ instruction_id: str = Field(min_length=1, max_length=80)
60
+ category: InstructionCategory
61
+ reason: str = Field(min_length=1, max_length=500)
62
+ source: InstructionSource
63
+ check: str = Field(min_length=1, max_length=200)
64
+ timeout_s: int = Field(ge=1, le=MAX_CHECK_TIMEOUT_S)
65
+ required: bool
66
+
67
+
68
+ class SandboxExecutionPlan(BaseModel):
69
+ model_config = ConfigDict(extra="forbid")
70
+
71
+ instructions: list[SandboxActionInstruction] = Field(default_factory=list)
72
+ baseline_instructions: list[SandboxActionInstruction] = Field(default_factory=list)
73
+ declared_entrypoint: list[str] = Field(default_factory=list)
74
+ canary_context: str = ""
75
+ validation_status: Literal["valid", "invalid"] = "invalid"
76
+ problems: list[str] = Field(default_factory=list)
77
+
78
+
79
+ def _baseline(timeout_s: int) -> list[SandboxActionInstruction]:
80
+ return [
81
+ SandboxActionInstruction(
82
+ instruction_id=f"baseline-{category.value.lower()}",
83
+ category=category,
84
+ reason="always checked for every skill",
85
+ source=InstructionSource.BASELINE,
86
+ check=category.value,
87
+ timeout_s=timeout_s,
88
+ required=True,
89
+ )
90
+ for category in BASELINE_CATEGORIES
91
+ ]
92
+
93
+
94
+ def build_plan(
95
+ package: SkillPackage,
96
+ anchors: StageResult,
97
+ *,
98
+ timeout_s: int,
99
+ ) -> SandboxExecutionPlan:
100
+ """Build and validate the plan for one skill.
101
+
102
+ Anchor-derived watches are additive and optional. The plan is valid only when
103
+ every baseline category is present, the entrypoint is declared, and every
104
+ timeout is within bounds.
105
+ """
106
+
107
+ timeout = max(1, min(int(timeout_s), MAX_CHECK_TIMEOUT_S))
108
+ baseline = _baseline(timeout)
109
+ additions: list[SandboxActionInstruction] = []
110
+ seen: set[tuple[str, str]] = set()
111
+ for finding in anchors.findings:
112
+ category = _ANCHOR_WATCHES.get(finding.finding_type or "")
113
+ if category is None:
114
+ continue
115
+ key = (category.value, finding.evidence.file or "")
116
+ if key in seen:
117
+ continue
118
+ seen.add(key)
119
+ additions.append(
120
+ SandboxActionInstruction(
121
+ instruction_id=f"anchor-{len(additions) + 1}-{category.value.lower()}",
122
+ category=category,
123
+ reason=(finding.message or "deterministic anchor")[:500],
124
+ source=InstructionSource.DETERMINISTIC_ANCHOR,
125
+ check=category.value,
126
+ timeout_s=timeout,
127
+ required=False,
128
+ )
129
+ )
130
+
131
+ plan = SandboxExecutionPlan(
132
+ instructions=baseline + additions,
133
+ baseline_instructions=baseline,
134
+ declared_entrypoint=list(package.entrypoint or []),
135
+ canary_context=(package.declared_intent or "")[:200],
136
+ )
137
+ problems = validate_plan(plan)
138
+ plan.problems = problems
139
+ plan.validation_status = "invalid" if problems else "valid"
140
+ return plan
141
+
142
+
143
+ def validate_plan(plan: SandboxExecutionPlan) -> list[str]:
144
+ """Return the reasons a plan must not run; an empty list means it may run."""
145
+
146
+ problems: list[str] = []
147
+ if not plan.declared_entrypoint:
148
+ problems.append(
149
+ "no declared entrypoint, so the scripts were not executed; declare 'entrypoint:' "
150
+ "in SKILL.md so they can be tested. A fallback command is never guessed"
151
+ )
152
+ present = {instruction.category for instruction in plan.baseline_instructions}
153
+ missing = [category.value for category in BASELINE_CATEGORIES if category not in present]
154
+ if missing:
155
+ problems.append(f"baseline checks missing: {', '.join(missing)}")
156
+ for instruction in plan.instructions:
157
+ if not 1 <= instruction.timeout_s <= MAX_CHECK_TIMEOUT_S:
158
+ problems.append(f"timeout out of bounds for {instruction.instruction_id}")
159
+ return problems
160
+
161
+
162
+ __all__ = [
163
+ "BASELINE_CATEGORIES",
164
+ "InstructionCategory",
165
+ "InstructionSource",
166
+ "SandboxActionInstruction",
167
+ "SandboxExecutionPlan",
168
+ "build_plan",
169
+ "validate_plan",
170
+ ]
@@ -0,0 +1,265 @@
1
+ """Masquerade configuration, shim source, and verification helpers.
2
+
3
+ The shim hides ``/.dockerenv`` and redirects fake ``/proc`` views while logging
4
+ canary reads and sandbox probes. A failed verification is an **engine error**:
5
+ the runner must not continue and must never emit a clean verdict.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import shutil
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ ASSET_DIR = Path(__file__).resolve().parent / "assets"
18
+ MASQUERADE_SOURCE_PATH = ASSET_DIR / "masquerade.c"
19
+ RUN_SCRIPT_PATH = ASSET_DIR / "run_skill.sh"
20
+ DOCKERFILE_PATH = ASSET_DIR / "Dockerfile.sandbox"
21
+
22
+ SHIM_PATH = "/opt/skillsentry/masquerade.so"
23
+ SHIM_LOG_PATH = "/tmp/skillsentry/events.jsonl"
24
+ FAKE_CGROUP_PATH = "/tmp/skillsentry/cgroup"
25
+ FAKE_MOUNTINFO_PATH = "/tmp/skillsentry/mountinfo"
26
+ DOCKERENV_PATH = "/.dockerenv"
27
+ CGROUP_PATH = "/proc/1/cgroup"
28
+ MOUNTINFO_PATH = "/proc/self/mountinfo"
29
+
30
+ FAKE_CGROUP_CONTENT = "0::/\n"
31
+ FAKE_MOUNTINFO_CONTENT = (
32
+ "1 0 0:1 / / ext4 rw,relatime 0 0\n"
33
+ "2 1 0:2 / /proc proc rw,nosuid,nodev,noexec,relatime 0 0\n"
34
+ "3 1 0:3 / /sys sysfs ro,nosuid,nodev,noexec,relatime 0 0\n"
35
+ "4 1 0:4 / /tmp tmpfs rw,nosuid,nodev,noexec,relatime 0 0\n"
36
+ "5 1 0:5 / /home/sandbox tmpfs rw,nosuid,nodev,noexec,relatime 0 0\n"
37
+ )
38
+
39
+
40
+ class MasqueradeUnavailable(RuntimeError):
41
+ """The shim cannot be built or is absent from the image."""
42
+
43
+
44
+ class MasqueradeError(RuntimeError):
45
+ """The shim is present but its hides/redirects did not verify."""
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class MasqueradeConfig:
50
+ shim_path: str = SHIM_PATH
51
+ require_shim: bool = True
52
+ dockerenv_source: str = "/dev/null"
53
+ dockerenv_target: str = DOCKERENV_PATH
54
+
55
+ def environment(self) -> dict[str, str]:
56
+ return {"LD_PRELOAD": self.shim_path}
57
+
58
+ def volumes(self) -> dict[str, dict[str, str]]:
59
+ # Docker 29+ rejects bind mounts whose target is inside /proc, so the
60
+ # fake cgroup/mountinfo views are served by the shim's path redirect
61
+ # rather than host bind mounts.
62
+ return {
63
+ self.dockerenv_source: {
64
+ "bind": self.dockerenv_target,
65
+ "mode": "ro",
66
+ }
67
+ }
68
+
69
+ def tmpfs(self) -> dict[str, str]:
70
+ # Kept as an extension point for callers that need a directory tmpfs.
71
+ return {}
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class MasqueradeCheck:
76
+ dockerenv_hidden: bool
77
+ cgroup_fake: bool
78
+ mountinfo_clean: bool
79
+ shim_present: bool
80
+ errors: tuple[str, ...] = ()
81
+
82
+ @property
83
+ def ok(self) -> bool:
84
+ return not self.errors
85
+
86
+ def to_dict(self) -> dict[str, Any]:
87
+ return {
88
+ "ok": self.ok,
89
+ "dockerenvHidden": self.dockerenv_hidden,
90
+ "cgroupFake": self.cgroup_fake,
91
+ "mountinfoClean": self.mountinfo_clean,
92
+ "shimPresent": self.shim_present,
93
+ "errors": list(self.errors),
94
+ }
95
+
96
+
97
+ def source_text() -> str:
98
+ return MASQUERADE_SOURCE_PATH.read_text(encoding="utf-8")
99
+
100
+
101
+ def run_script_text() -> str:
102
+ return RUN_SCRIPT_PATH.read_text(encoding="utf-8")
103
+
104
+
105
+ def compile_shim(
106
+ *,
107
+ source: str | Path | None = None,
108
+ output: str | Path | None = None,
109
+ compiler: str | None = None,
110
+ ) -> Path:
111
+ """Compile the shim with a local gcc (development/CI packaging helper)."""
112
+
113
+ gcc = compiler or shutil.which("gcc")
114
+ if not gcc:
115
+ raise MasqueradeUnavailable("gcc is unavailable; use the sandbox Docker image")
116
+ source_path = Path(source or MASQUERADE_SOURCE_PATH)
117
+ output_path = Path(output or (ASSET_DIR / "masquerade.so"))
118
+ output_path.parent.mkdir(parents=True, exist_ok=True)
119
+ try:
120
+ completed = subprocess.run(
121
+ [
122
+ gcc,
123
+ "-shared",
124
+ "-fPIC",
125
+ "-O2",
126
+ "-Wall",
127
+ "-Wextra",
128
+ "-o",
129
+ str(output_path),
130
+ str(source_path),
131
+ "-ldl",
132
+ ],
133
+ check=False,
134
+ capture_output=True,
135
+ text=True,
136
+ timeout=60,
137
+ )
138
+ except (OSError, subprocess.SubprocessError) as exc:
139
+ raise MasqueradeUnavailable(f"could not run gcc: {exc}") from exc
140
+ if completed.returncode != 0:
141
+ raise MasqueradeUnavailable(
142
+ f"gcc failed ({completed.returncode}): {completed.stderr.strip()}"
143
+ )
144
+ return output_path
145
+
146
+
147
+ def _decode_b64(value: str) -> str:
148
+ try:
149
+ return base64.b64decode(value.encode("ascii"), validate=False).decode(
150
+ "utf-8", errors="replace"
151
+ )
152
+ except (ValueError, UnicodeError):
153
+ return ""
154
+
155
+
156
+ def verify_outputs(
157
+ *,
158
+ dockerenv_exists: bool,
159
+ cgroup: str,
160
+ mountinfo: str,
161
+ shim_present: bool,
162
+ require_shim: bool = True,
163
+ ) -> MasqueradeCheck:
164
+ errors: list[str] = []
165
+ dockerenv_hidden = not dockerenv_exists
166
+ if not dockerenv_hidden:
167
+ errors.append("/.dockerenv is visible to the skill")
168
+
169
+ lowered_cgroup = cgroup.lower()
170
+ cgroup_fake = bool(cgroup.strip()) and not any(
171
+ marker in lowered_cgroup for marker in ("docker", "containerd", "kubepods")
172
+ )
173
+ if not cgroup_fake:
174
+ errors.append("/proc/1/cgroup still exposes container markers")
175
+
176
+ lowered_mountinfo = mountinfo.lower()
177
+ mountinfo_clean = not any(
178
+ marker in lowered_mountinfo
179
+ for marker in ("docker", "containerd", "overlay", "kubepods", "skillsentry")
180
+ )
181
+ if not mountinfo_clean:
182
+ errors.append("/proc/self/mountinfo still exposes container markers")
183
+
184
+ if require_shim and not shim_present:
185
+ errors.append("masquerade shim is missing from the sandbox image")
186
+
187
+ return MasqueradeCheck(
188
+ dockerenv_hidden=dockerenv_hidden,
189
+ cgroup_fake=cgroup_fake,
190
+ mountinfo_clean=mountinfo_clean,
191
+ shim_present=shim_present,
192
+ errors=tuple(errors),
193
+ )
194
+
195
+
196
+ def verification_script(shim_path: str = SHIM_PATH) -> str:
197
+ """POSIX shell probe used by :func:`verify_container`."""
198
+
199
+ return f"""
200
+ if [ -e {DOCKERENV_PATH} ]; then d=present; else d=absent; fi
201
+ if [ -f {shim_path} ]; then s=present; else s=missing; fi
202
+ c=$(base64 < {CGROUP_PATH} 2>/dev/null | tr -d '\\n')
203
+ m=$(base64 < {MOUNTINFO_PATH} 2>/dev/null | tr -d '\\n')
204
+ printf 'SKILLSENTRY_DOCKERENV=%s\\n' "$d"
205
+ printf 'SKILLSENTRY_SHIM=%s\\n' "$s"
206
+ printf 'SKILLSENTRY_CGROUP=%s\\n' "$c"
207
+ printf 'SKILLSENTRY_MOUNTINFO=%s\\n' "$m"
208
+ """.strip()
209
+
210
+
211
+ def parse_verification_output(
212
+ text: str | bytes,
213
+ *,
214
+ require_shim: bool = True,
215
+ ) -> MasqueradeCheck:
216
+ if isinstance(text, bytes):
217
+ text = text.decode("utf-8", errors="replace")
218
+ values: dict[str, str] = {}
219
+ for line in text.splitlines():
220
+ if "=" not in line:
221
+ continue
222
+ key, value = line.split("=", 1)
223
+ if key.startswith("SKILLSENTRY_"):
224
+ values[key] = value.strip()
225
+ return verify_outputs(
226
+ dockerenv_exists=values.get("SKILLSENTRY_DOCKERENV") != "absent",
227
+ cgroup=_decode_b64(values.get("SKILLSENTRY_CGROUP", "")),
228
+ mountinfo=_decode_b64(values.get("SKILLSENTRY_MOUNTINFO", "")),
229
+ shim_present=values.get("SKILLSENTRY_SHIM") == "present",
230
+ require_shim=require_shim,
231
+ )
232
+
233
+
234
+ def verify_container(
235
+ container: Any,
236
+ *,
237
+ config: MasqueradeConfig | None = None,
238
+ user: str = "65534:65534",
239
+ ) -> MasqueradeCheck:
240
+ """Run the verification probe in a started container."""
241
+
242
+ config = config or MasqueradeConfig()
243
+ result = container.exec_run(
244
+ ["/bin/sh", "-c", verification_script(config.shim_path)],
245
+ user=user,
246
+ environment=config.environment(),
247
+ )
248
+ if isinstance(result.output, bytes):
249
+ output = result.output.decode("utf-8", errors="replace")
250
+ else:
251
+ output = str(result.output or "")
252
+ if result.exit_code not in (0, None):
253
+ return MasqueradeCheck(
254
+ dockerenv_hidden=False,
255
+ cgroup_fake=False,
256
+ mountinfo_clean=False,
257
+ shim_present=False,
258
+ errors=(f"masquerade probe exited {result.exit_code}: {output.strip()}",),
259
+ )
260
+ return parse_verification_output(output, require_shim=config.require_shim)
261
+
262
+
263
+ def require_masquerade(check: MasqueradeCheck) -> None:
264
+ if not check.ok:
265
+ raise MasqueradeError("; ".join(check.errors))