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.
- skill_sentry_cli/__init__.py +5 -0
- skill_sentry_cli/__main__.py +4 -0
- skill_sentry_cli/cli.py +918 -0
- skill_sentry_cli/core/__init__.py +1 -0
- skill_sentry_cli/core/anchors/__init__.py +5 -0
- skill_sentry_cli/core/anchors/credential_flow.py +440 -0
- skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
- skill_sentry_cli/core/cache/__init__.py +35 -0
- skill_sentry_cli/core/cache/hash_utils.py +206 -0
- skill_sentry_cli/core/cache/sqlite_store.py +925 -0
- skill_sentry_cli/core/config.py +299 -0
- skill_sentry_cli/core/contracts.py +551 -0
- skill_sentry_cli/core/decisions/__init__.py +44 -0
- skill_sentry_cli/core/decisions/base.py +174 -0
- skill_sentry_cli/core/decisions/calibration.py +325 -0
- skill_sentry_cli/core/decisions/laya.py +439 -0
- skill_sentry_cli/core/decisions/mapping.py +218 -0
- skill_sentry_cli/core/decisions/mock.py +118 -0
- skill_sentry_cli/core/decisions/questions.py +118 -0
- skill_sentry_cli/core/decisions/report.py +65 -0
- skill_sentry_cli/core/decisions/selector.py +151 -0
- skill_sentry_cli/core/engine.py +1412 -0
- skill_sentry_cli/core/evasion/__init__.py +27 -0
- skill_sentry_cli/core/evasion/detector.py +210 -0
- skill_sentry_cli/core/exit_codes.py +13 -0
- skill_sentry_cli/core/models/__init__.py +20 -0
- skill_sentry_cli/core/models/_json.py +147 -0
- skill_sentry_cli/core/models/base.py +50 -0
- skill_sentry_cli/core/models/gemma_cloud.py +240 -0
- skill_sentry_cli/core/models/gemma_local.py +273 -0
- skill_sentry_cli/core/models/image_utils.py +112 -0
- skill_sentry_cli/core/models/mock.py +152 -0
- skill_sentry_cli/core/models/selector.py +123 -0
- skill_sentry_cli/core/parser/__init__.py +29 -0
- skill_sentry_cli/core/parser/skill_parser.py +520 -0
- skill_sentry_cli/core/parser/typosquat.py +167 -0
- skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
- skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
- skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
- skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
- skill_sentry_cli/core/reports.py +114 -0
- skill_sentry_cli/core/sandbox/__init__.py +74 -0
- skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
- skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
- skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
- skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
- skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
- skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
- skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
- skill_sentry_cli/core/sandbox/fake_http.py +194 -0
- skill_sentry_cli/core/sandbox/instructions.py +170 -0
- skill_sentry_cli/core/sandbox/masquerade.py +265 -0
- skill_sentry_cli/core/sandbox/monitor.py +278 -0
- skill_sentry_cli/core/sandbox/runner.py +776 -0
- skill_sentry_cli/core/sandbox/sidecar.py +327 -0
- skill_sentry_cli/core/semantic/__init__.py +9 -0
- skill_sentry_cli/core/semantic/analyzer.py +443 -0
- skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
- skill_sentry_cli/core/semantic/ast_python.py +156 -0
- skill_sentry_cli/core/semantic/candidates.py +78 -0
- skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
- skill_sentry_cli/core/semantic/prompts.py +61 -0
- skill_sentry_cli/core/verdict/__init__.py +24 -0
- skill_sentry_cli/core/verdict/fusion.py +392 -0
- skill_sentry_cli/core/verdict/markdown_report.py +211 -0
- skill_sentry_cli/core/verdict/sarif_report.py +139 -0
- skill_sentry_cli/core/verdict/status.py +35 -0
- skill_sentry_cli/core/verdict/stix_report.py +132 -0
- skill_sentry_cli/core/version.py +38 -0
- skill_sentry_cli/core/vision/__init__.py +8 -0
- skill_sentry_cli/core/vision/image_extractor.py +91 -0
- skill_sentry_cli/core/vision/vision_scanner.py +274 -0
- skill_sentry_cli/data/__init__.py +1 -0
- skill_sentry_cli/data/known_skills_v1.json +276 -0
- skill_sentry_cli/data/sandbox_image.json +4 -0
- skill_sentry_cli/rules/credential_rules.toml +94 -0
- skill_sentry_cli/server/__init__.py +5 -0
- skill_sentry_cli/server/app.py +129 -0
- skill_sentry_cli/server/backend.py +63 -0
- skill_sentry_cli/server/demo_assets.py +62 -0
- skill_sentry_cli/server/demo_backend.py +475 -0
- skill_sentry_cli/server/demo_data.py +814 -0
- skill_sentry_cli/server/demo_mode.py +40 -0
- skill_sentry_cli/server/errors.py +48 -0
- skill_sentry_cli/server/live_backend.py +576 -0
- skill_sentry_cli/server/routes.py +191 -0
- skill_sentry_cli/skill/SKILL.md +67 -0
- skill_sentry_cli/skill/scripts/audit.sh +48 -0
- skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
- skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
- skill_sentry_cli/ui/dist/index.html +14 -0
- skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
- skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
- skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
- skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
- skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Per-scan sinkhole sidecar attached to the isolated Docker network.
|
|
2
|
+
|
|
3
|
+
The sidecar is the only component that receives sandbox egress. It is a
|
|
4
|
+
separate container on an ``internal`` bridge (no NAT), with a static address
|
|
5
|
+
used as the sandbox's DNS resolver. DNS A answers point back at the sidecar;
|
|
6
|
+
HTTP requests get an inert 200 response. Observations are emitted as JSON
|
|
7
|
+
lines on stdout and collected before teardown.
|
|
8
|
+
|
|
9
|
+
TLS is intentionally not intercepted in M1. A self-signed certificate would
|
|
10
|
+
require installing a trust anchor inside the skill image, which changes the
|
|
11
|
+
threat model; HTTPS fixtures must use HTTP or remain explicitly unverified.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import contextlib
|
|
18
|
+
import io
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import tarfile
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Protocol
|
|
28
|
+
from uuid import UUID
|
|
29
|
+
|
|
30
|
+
MAX_SIDECAR_LOG_BYTES = 8 * 1024 * 1024
|
|
31
|
+
MAX_SIDECAR_EVENTS = 10_000
|
|
32
|
+
MAX_SIDECAR_TAIL_LINES = 10_000
|
|
33
|
+
_SIDECAR_SCRIPT_PATH = Path(__file__).resolve().parent / "assets" / "sinkhole_sidecar.py"
|
|
34
|
+
_TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T")
|
|
35
|
+
_READY_MARKER = b'"event":"ready"'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SidecarError(RuntimeError):
|
|
39
|
+
"""The sinkhole sidecar could not be started or its evidence is unusable."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SinkholeObservation:
|
|
44
|
+
ts: datetime
|
|
45
|
+
protocol: str
|
|
46
|
+
query_or_url: str
|
|
47
|
+
body: bytes = b""
|
|
48
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
49
|
+
client: str = ""
|
|
50
|
+
responder_action: str = "fake"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def body_b64(self) -> str | None:
|
|
54
|
+
if not self.body:
|
|
55
|
+
return None
|
|
56
|
+
return base64.b64encode(self.body).decode("ascii")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class SinkholeConfig:
|
|
61
|
+
image: str = "python:3.11-slim"
|
|
62
|
+
ip: str = "172.30.77.2"
|
|
63
|
+
subnet: str = "172.30.77.0/24"
|
|
64
|
+
dns_port: int = 53
|
|
65
|
+
http_port: int = 80
|
|
66
|
+
timeout_s: float = 15.0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SidecarController(Protocol):
|
|
70
|
+
@property
|
|
71
|
+
def ip(self) -> str: ...
|
|
72
|
+
|
|
73
|
+
def start(self, client: Any, network: Any, scan_id: UUID) -> SinkholeSidecar: ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SinkholeSidecar:
|
|
77
|
+
"""Docker lifecycle plus observation parsing for one scan."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, client: Any, config: SinkholeConfig | None = None) -> None:
|
|
80
|
+
self.client = client
|
|
81
|
+
self.config = config or SinkholeConfig()
|
|
82
|
+
self.container: Any | None = None
|
|
83
|
+
self._ip = self.config.ip
|
|
84
|
+
self._lock = threading.RLock()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def ip(self) -> str:
|
|
88
|
+
return self._ip
|
|
89
|
+
|
|
90
|
+
def start(self, network: Any, scan_id: UUID, *, image: str | None = None) -> SinkholeSidecar:
|
|
91
|
+
# A sidecar is per-scan state. Serialise lifecycle calls so a
|
|
92
|
+
# concurrent teardown cannot remove a container while it is starting.
|
|
93
|
+
with self._lock:
|
|
94
|
+
if self.container is not None:
|
|
95
|
+
return self
|
|
96
|
+
network_name = getattr(network, "name", None) or str(network)
|
|
97
|
+
labels = {
|
|
98
|
+
"skillsentry": "true",
|
|
99
|
+
"skillsentry.role": "sinkhole-sidecar",
|
|
100
|
+
"skillsentry.scan_id": str(scan_id),
|
|
101
|
+
}
|
|
102
|
+
container = None
|
|
103
|
+
try:
|
|
104
|
+
container = self.client.containers.create(
|
|
105
|
+
image=image or self.config.image,
|
|
106
|
+
# Keep the script outside the /tmp tmpfs: files copied into
|
|
107
|
+
# a tmpfs-backed path are hidden when the container starts.
|
|
108
|
+
command=["python", "-u", "/opt/skillsentry_sidecar.py"],
|
|
109
|
+
detach=True,
|
|
110
|
+
# The rootfs must be writable long enough for put_archive()
|
|
111
|
+
# to copy the trusted sidecar script before start; Docker's
|
|
112
|
+
# archive endpoint rejects a read-only rootfs. /tmp is still
|
|
113
|
+
# a bounded tmpfs and all capabilities are dropped below.
|
|
114
|
+
read_only=False,
|
|
115
|
+
user="0:0",
|
|
116
|
+
working_dir="/tmp",
|
|
117
|
+
tmpfs={"/tmp": "rw,nosuid,nodev,noexec,size=16m,mode=1777"},
|
|
118
|
+
cap_drop=["ALL"],
|
|
119
|
+
cap_add=["NET_BIND_SERVICE"],
|
|
120
|
+
security_opt=["no-new-privileges"],
|
|
121
|
+
environment={
|
|
122
|
+
"SKILLSENTRY_SIDECAR_IP": self._ip,
|
|
123
|
+
"SKILLSENTRY_DNS_PORT": str(self.config.dns_port),
|
|
124
|
+
"SKILLSENTRY_HTTP_PORT": str(self.config.http_port),
|
|
125
|
+
},
|
|
126
|
+
labels=labels,
|
|
127
|
+
auto_remove=False,
|
|
128
|
+
network=network_name,
|
|
129
|
+
networking_config={
|
|
130
|
+
"EndpointsConfig": {network_name: {"IPAMConfig": {"IPv4Address": self._ip}}}
|
|
131
|
+
},
|
|
132
|
+
# The sidecar only needs enough memory for two small
|
|
133
|
+
# stdlib servers. Cap it independently from the skill so
|
|
134
|
+
# a compromised sidecar cannot exhaust the host.
|
|
135
|
+
mem_limit="64m",
|
|
136
|
+
memswap_limit="64m",
|
|
137
|
+
pids_limit=32,
|
|
138
|
+
nano_cpus=250_000_000,
|
|
139
|
+
)
|
|
140
|
+
self.container = container
|
|
141
|
+
script = _SIDECAR_SCRIPT_PATH.read_bytes()
|
|
142
|
+
buffer = io.BytesIO()
|
|
143
|
+
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
|
144
|
+
info = tarfile.TarInfo("skillsentry_sidecar.py")
|
|
145
|
+
info.size = len(script)
|
|
146
|
+
info.mode = 0o755
|
|
147
|
+
info.uid = 0
|
|
148
|
+
info.gid = 0
|
|
149
|
+
info.mtime = 0
|
|
150
|
+
archive.addfile(info, io.BytesIO(script))
|
|
151
|
+
# Copy the trusted script into the stopped container before it
|
|
152
|
+
# can execute anything. No host mount is exposed.
|
|
153
|
+
if container.put_archive("/opt", buffer.getvalue()) is False:
|
|
154
|
+
raise SidecarError("could not copy sidecar script into container")
|
|
155
|
+
self.container = container
|
|
156
|
+
container.start()
|
|
157
|
+
self._wait_until_ready(container)
|
|
158
|
+
return self
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
self.stop(collect=False)
|
|
161
|
+
if isinstance(exc, SidecarError):
|
|
162
|
+
raise
|
|
163
|
+
raise SidecarError(f"could not start sinkhole sidecar: {exc}") from exc
|
|
164
|
+
|
|
165
|
+
def _wait_until_ready(self, container: Any) -> None:
|
|
166
|
+
"""Wait for both DNS and HTTP listeners to report readiness.
|
|
167
|
+
|
|
168
|
+
Docker marks a container ``running`` before the Python process has
|
|
169
|
+
bound its sockets. Starting the skill at that point produces an
|
|
170
|
+
evidence hole (the first exfil attempt gets connection refused). The
|
|
171
|
+
sidecar therefore emits one ``{"event":"ready"}`` line after binding,
|
|
172
|
+
and the runner waits for it with a hard deadline.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
deadline = time.monotonic() + max(0.1, self.config.timeout_s)
|
|
176
|
+
while time.monotonic() < deadline:
|
|
177
|
+
status = ""
|
|
178
|
+
try:
|
|
179
|
+
reload = getattr(container, "reload", None)
|
|
180
|
+
if reload is not None:
|
|
181
|
+
reload()
|
|
182
|
+
status = str(getattr(container, "status", ""))
|
|
183
|
+
except Exception:
|
|
184
|
+
status = ""
|
|
185
|
+
if status in {"exited", "dead"}:
|
|
186
|
+
raise SidecarError(f"sinkhole sidecar exited during startup ({status})")
|
|
187
|
+
logs = _read_logs(container, tail=64)
|
|
188
|
+
if _READY_MARKER in logs:
|
|
189
|
+
return
|
|
190
|
+
time.sleep(0.05)
|
|
191
|
+
raise SidecarError(
|
|
192
|
+
f"sinkhole sidecar did not become ready within {self.config.timeout_s:.1f}s"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def stop(self, *, collect: bool = True) -> list[SinkholeObservation]:
|
|
196
|
+
observations: list[SinkholeObservation] = []
|
|
197
|
+
with self._lock:
|
|
198
|
+
container = self.container
|
|
199
|
+
self.container = None
|
|
200
|
+
if container is None:
|
|
201
|
+
return observations
|
|
202
|
+
# Stop first so a log stream terminates promptly and a hostile
|
|
203
|
+
# sidecar cannot keep writing while evidence is collected.
|
|
204
|
+
with contextlib.suppress(Exception):
|
|
205
|
+
container.stop(timeout=1)
|
|
206
|
+
if collect:
|
|
207
|
+
try:
|
|
208
|
+
raw = _read_logs(container, stream=True)
|
|
209
|
+
observations = parse_sidecar_log(raw)
|
|
210
|
+
except Exception:
|
|
211
|
+
observations = []
|
|
212
|
+
with contextlib.suppress(Exception):
|
|
213
|
+
container.remove(force=True, v=True)
|
|
214
|
+
return observations
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _read_logs(
|
|
218
|
+
container: Any,
|
|
219
|
+
*,
|
|
220
|
+
tail: int | None = None,
|
|
221
|
+
stream: bool = False,
|
|
222
|
+
limit: int = MAX_SIDECAR_LOG_BYTES,
|
|
223
|
+
) -> bytes:
|
|
224
|
+
"""Read Docker logs without allowing an unbounded response into memory."""
|
|
225
|
+
|
|
226
|
+
kwargs: dict[str, Any] = {"stdout": True, "stderr": False}
|
|
227
|
+
if tail is not None:
|
|
228
|
+
kwargs["tail"] = max(1, min(int(tail), MAX_SIDECAR_TAIL_LINES))
|
|
229
|
+
if stream:
|
|
230
|
+
kwargs["stream"] = True
|
|
231
|
+
try:
|
|
232
|
+
raw = container.logs(**kwargs)
|
|
233
|
+
except TypeError:
|
|
234
|
+
# A minimal fake client may not implement stream/tail kwargs.
|
|
235
|
+
raw = container.logs(stdout=True, stderr=False)
|
|
236
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
237
|
+
return bytes(raw)[-limit:]
|
|
238
|
+
if isinstance(raw, str):
|
|
239
|
+
return raw.encode("utf-8", errors="replace")[-limit:]
|
|
240
|
+
chunks: list[bytes] = []
|
|
241
|
+
total = 0
|
|
242
|
+
try:
|
|
243
|
+
for chunk in raw:
|
|
244
|
+
if isinstance(chunk, str):
|
|
245
|
+
chunk = chunk.encode("utf-8", errors="replace")
|
|
246
|
+
if not isinstance(chunk, (bytes, bytearray)):
|
|
247
|
+
continue
|
|
248
|
+
chunks.append(bytes(chunk))
|
|
249
|
+
total += len(chunk)
|
|
250
|
+
if total >= limit:
|
|
251
|
+
break
|
|
252
|
+
finally:
|
|
253
|
+
close = getattr(raw, "close", None)
|
|
254
|
+
if close is not None:
|
|
255
|
+
with contextlib.suppress(Exception):
|
|
256
|
+
close()
|
|
257
|
+
return b"".join(chunks)[-limit:]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def parse_sidecar_log(raw: bytes | str) -> list[SinkholeObservation]:
|
|
261
|
+
if isinstance(raw, bytes):
|
|
262
|
+
raw = raw[-MAX_SIDECAR_LOG_BYTES:].decode("utf-8", errors="replace")
|
|
263
|
+
elif len(raw) > MAX_SIDECAR_LOG_BYTES:
|
|
264
|
+
raw = raw[-MAX_SIDECAR_LOG_BYTES:]
|
|
265
|
+
observations: list[SinkholeObservation] = []
|
|
266
|
+
for line in raw.splitlines():
|
|
267
|
+
line = line.strip()
|
|
268
|
+
if not line.startswith("{"):
|
|
269
|
+
# Docker may prefix non-TTY streams with a multiplexing header.
|
|
270
|
+
brace = line.find("{")
|
|
271
|
+
if brace == -1:
|
|
272
|
+
continue
|
|
273
|
+
line = line[brace:]
|
|
274
|
+
try:
|
|
275
|
+
payload = json.loads(line)
|
|
276
|
+
except (TypeError, ValueError):
|
|
277
|
+
continue
|
|
278
|
+
if not isinstance(payload, dict):
|
|
279
|
+
continue
|
|
280
|
+
protocol = payload.get("protocol")
|
|
281
|
+
url = payload.get("query_or_url")
|
|
282
|
+
if protocol not in {"dns", "http"} or not isinstance(url, str) or not url:
|
|
283
|
+
continue
|
|
284
|
+
ts_raw = payload.get("ts")
|
|
285
|
+
try:
|
|
286
|
+
ts = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
|
287
|
+
if ts.tzinfo is None:
|
|
288
|
+
ts = ts.replace(tzinfo=UTC)
|
|
289
|
+
except (TypeError, ValueError):
|
|
290
|
+
continue
|
|
291
|
+
body_b64 = payload.get("body_b64")
|
|
292
|
+
body = b""
|
|
293
|
+
if isinstance(body_b64, str):
|
|
294
|
+
try:
|
|
295
|
+
body = base64.b64decode(body_b64, validate=False)[:2_000_000]
|
|
296
|
+
except (ValueError, TypeError):
|
|
297
|
+
body = b""
|
|
298
|
+
headers = payload.get("headers")
|
|
299
|
+
observations.append(
|
|
300
|
+
SinkholeObservation(
|
|
301
|
+
ts=ts,
|
|
302
|
+
protocol=protocol,
|
|
303
|
+
query_or_url=url[:4096],
|
|
304
|
+
body=body,
|
|
305
|
+
headers=(
|
|
306
|
+
{str(k)[:100]: str(v)[:1000] for k, v in headers.items()}
|
|
307
|
+
if isinstance(headers, dict)
|
|
308
|
+
else {}
|
|
309
|
+
),
|
|
310
|
+
client=str(payload.get("client") or "")[:200],
|
|
311
|
+
responder_action=str(payload.get("responder_action") or "fake")[:200],
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
if len(observations) >= MAX_SIDECAR_EVENTS:
|
|
315
|
+
break
|
|
316
|
+
return observations
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
__all__ = [
|
|
320
|
+
"MAX_SIDECAR_EVENTS",
|
|
321
|
+
"SidecarController",
|
|
322
|
+
"SidecarError",
|
|
323
|
+
"SinkholeConfig",
|
|
324
|
+
"SinkholeObservation",
|
|
325
|
+
"SinkholeSidecar",
|
|
326
|
+
"parse_sidecar_log",
|
|
327
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Deterministic anchors and semantic analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from skill_sentry_cli.core.semantic.analyzer import analyze_semantics
|
|
6
|
+
from skill_sentry_cli.core.semantic.ast_anchor import AnchorRule, scan_anchors
|
|
7
|
+
from skill_sentry_cli.core.semantic.prompts import SEMANTIC_SCHEMA, SYSTEM_PROMPT
|
|
8
|
+
|
|
9
|
+
__all__ = ["SEMANTIC_SCHEMA", "SYSTEM_PROMPT", "AnchorRule", "analyze_semantics", "scan_anchors"]
|