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,108 @@
1
+ """Materialise canary files into a tar stream or a local test directory.
2
+
3
+ The production path never writes fake credentials to the host: the tar stream is
4
+ uploaded into the container with ``put_archive`` after its tmpfs HOME exists.
5
+ ``materialise_directory`` exists for unit tests and local debugging only.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import io
12
+ import tarfile
13
+ from pathlib import Path, PurePosixPath
14
+
15
+ from skill_sentry_cli.core.poisoned_well.canary_gen import CANARY_ROOT, CanaryFile, CanarySet
16
+
17
+ _NOBODY = 65534
18
+ _NOGROUP = 65534
19
+
20
+
21
+ def validate_relative_path(relative_path: str) -> str:
22
+ path = PurePosixPath(relative_path)
23
+ if path.is_absolute() or not path.parts:
24
+ raise ValueError(f"canary path must be relative: {relative_path!r}")
25
+ if any(part in {"", ".", ".."} for part in path.parts):
26
+ raise ValueError(f"unsafe canary path: {relative_path!r}")
27
+ if "\\" in relative_path:
28
+ raise ValueError(f"canary path must use forward slashes: {relative_path!r}")
29
+ return path.as_posix()
30
+
31
+
32
+ def _tarinfo(name: str, *, mode: int, is_dir: bool) -> tarfile.TarInfo:
33
+ info = tarfile.TarInfo(name=name)
34
+ info.type = tarfile.DIRTYPE if is_dir else tarfile.REGTYPE
35
+ info.mode = mode
36
+ info.uid = _NOBODY
37
+ info.gid = _NOGROUP
38
+ info.uname = "nobody"
39
+ info.gname = "nogroup"
40
+ info.mtime = 0
41
+ return info
42
+
43
+
44
+ def build_canary_tar(canary_set: CanarySet) -> bytes:
45
+ """Return a deterministic tar archive rooted at ``CANARY_ROOT``.
46
+
47
+ Directory entries are created explicitly so extraction does not depend on
48
+ the destination's umask. Files are regular files only; no symlinks or
49
+ device nodes are ever emitted.
50
+ """
51
+
52
+ buffer = io.BytesIO()
53
+ directories: set[str] = set()
54
+ with tarfile.open(fileobj=buffer, mode="w") as archive:
55
+ for canary in canary_set.files:
56
+ rel = validate_relative_path(canary.relative_path)
57
+ parent = PurePosixPath(rel).parent
58
+ if parent != PurePosixPath("."):
59
+ parts = parent.parts
60
+ for index in range(1, len(parts) + 1):
61
+ directory = PurePosixPath(*parts[:index]).as_posix()
62
+ if directory in directories:
63
+ continue
64
+ directories.add(directory)
65
+ info = _tarinfo(f"./{directory}", mode=0o700, is_dir=True)
66
+ archive.addfile(info)
67
+
68
+ payload = canary.content.encode("utf-8")
69
+ info = _tarinfo(f"./{rel}", mode=canary.mode, is_dir=False)
70
+ info.size = len(payload)
71
+ archive.addfile(info, io.BytesIO(payload))
72
+
73
+ return buffer.getvalue()
74
+
75
+
76
+ def materialise_directory(root: str | Path, canary_set: CanarySet) -> list[Path]:
77
+ """Write canary files under *root* (tests/local debugging only).
78
+
79
+ The caller must pass an explicit scratch directory; this function never
80
+ touches a user's real home directory.
81
+ """
82
+
83
+ base = Path(root)
84
+ base.mkdir(parents=True, exist_ok=True)
85
+ written: list[Path] = []
86
+ for canary in canary_set.files:
87
+ rel = validate_relative_path(canary.relative_path)
88
+ target = base / rel
89
+ target.parent.mkdir(parents=True, exist_ok=True)
90
+ target.write_text(canary.content, encoding="utf-8")
91
+ # Windows test hosts may not implement POSIX modes; content and layout
92
+ # are still verified. The container path uses tar metadata.
93
+ with contextlib.suppress(OSError):
94
+ target.chmod(canary.mode)
95
+ written.append(target)
96
+ return written
97
+
98
+
99
+ def canary_paths(canary_set: CanarySet) -> list[str]:
100
+ """Container-absolute paths for monitor event mapping."""
101
+
102
+ return [
103
+ f"{CANARY_ROOT}/{validate_relative_path(item.relative_path)}" for item in canary_set.files
104
+ ]
105
+
106
+
107
+ def canary_for_path(canary_set: CanarySet, path: str) -> CanaryFile | None:
108
+ return canary_set.file_for(path)
@@ -0,0 +1,97 @@
1
+ """Intent-aware context honeypot.
2
+
3
+ The honeypot answers one question: *what would this skill plausibly be able to
4
+ steal?* A Slack skill gets a Slack token and generic AWS/SSH material; an
5
+ unknown skill gets the full set. Seeding is delegated to
6
+ :mod:`skill_sentry_cli.core.poisoned_well.canary_gen` so the marker lifecycle has a
7
+ single owner.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from uuid import UUID
15
+
16
+ from skill_sentry_cli.core.contracts import ContextCanary
17
+ from skill_sentry_cli.core.poisoned_well.canary_gen import (
18
+ CANARY_ROOT,
19
+ CanarySet,
20
+ generate_canary_set,
21
+ normalize_intent,
22
+ )
23
+
24
+ INTENT_KEYWORDS: Mapping[ContextCanary, tuple[str, ...]] = {
25
+ ContextCanary.AWS: ("aws", "amazon", "s3", "ec2", "iam", "cloud", "credential"),
26
+ ContextCanary.GITHUB: ("github", "git", "repository", "repo", "pull request"),
27
+ ContextCanary.SLACK: ("slack", "webhook", "notification", "chat", "workspace"),
28
+ ContextCanary.SSH: ("ssh", "devops", "deploy", "server", "remote", "ansible"),
29
+ ContextCanary.GENERIC: (),
30
+ }
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class HoneypotPlan:
35
+ """Small, serialisable summary of what the honeypot will seed."""
36
+
37
+ intent: ContextCanary
38
+ declared_intent: str
39
+ paths: tuple[str, ...]
40
+ env_keys: tuple[str, ...]
41
+ marker: str
42
+
43
+ @classmethod
44
+ def from_canaries(cls, canaries: CanarySet) -> HoneypotPlan:
45
+ return cls(
46
+ intent=canaries.intent,
47
+ declared_intent=canaries.declared_intent,
48
+ paths=tuple(item.display_path for item in canaries.files),
49
+ env_keys=tuple(sorted(canaries.env)),
50
+ marker=canaries.marker,
51
+ )
52
+
53
+
54
+ def classify(declared_intent: str | None) -> ContextCanary:
55
+ return normalize_intent(declared_intent)
56
+
57
+
58
+ def build_honeypot(
59
+ scan_id: UUID,
60
+ declared_intent: str | None,
61
+ *,
62
+ marker: str | None = None,
63
+ secondary_marker: str | None = None,
64
+ ) -> CanarySet:
65
+ return generate_canary_set(
66
+ scan_id,
67
+ declared_intent,
68
+ marker=marker,
69
+ secondary_marker=secondary_marker,
70
+ )
71
+
72
+
73
+ def runtime_environment(canaries: CanarySet) -> dict[str, str]:
74
+ """Environment for the skill process (canaries + realistic HOME)."""
75
+
76
+ env = {
77
+ "HOME": CANARY_ROOT,
78
+ "USER": "nobody",
79
+ "LOGNAME": "nobody",
80
+ "SHELL": "/bin/sh",
81
+ "PATH": "/usr/local/bin:/usr/bin:/bin",
82
+ }
83
+ env.update(canaries.env)
84
+ return env
85
+
86
+
87
+ def plan(declared_intent: str | None) -> HoneypotPlan:
88
+ """Preview the honeypot for a declared intent without consuming a marker."""
89
+
90
+ # A preview marker is deterministic and not used for correlation.
91
+ preview = generate_canary_set(
92
+ UUID(int=0),
93
+ declared_intent,
94
+ marker="SKS-00000000",
95
+ secondary_marker="SKS-ffffffff",
96
+ )
97
+ return HoneypotPlan.from_canaries(preview)
@@ -0,0 +1,218 @@
1
+ """Minimal, dependency-free DNS sinkhole and query codec.
2
+
3
+ The sinkhole answers A queries with a loopback address and returns an empty
4
+ NOERROR response for other types. It never forwards a query upstream. The
5
+ default bind address is loopback; the Docker integration binds the sidecar on
6
+ the internal sinkhole network only.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import contextlib
13
+ import ipaddress
14
+ import socket
15
+ import struct
16
+ import threading
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+ from datetime import datetime
20
+
21
+ from skill_sentry_cli.core.contracts import utc_now
22
+
23
+ _QTYPE_NAMES = {
24
+ 1: "A",
25
+ 2: "NS",
26
+ 5: "CNAME",
27
+ 6: "SOA",
28
+ 12: "PTR",
29
+ 15: "MX",
30
+ 16: "TXT",
31
+ 28: "AAAA",
32
+ 33: "SRV",
33
+ }
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class DnsQuery:
38
+ ts: datetime
39
+ qname: str
40
+ qtype: int
41
+ client: str
42
+ raw_b64: str
43
+ qclass: int = 1
44
+
45
+ @property
46
+ def qtype_name(self) -> str:
47
+ return _QTYPE_NAMES.get(self.qtype, f"TYPE{self.qtype}")
48
+
49
+
50
+ def parse_dns_query(
51
+ data: bytes, *, ts: datetime | None = None, client: str = ""
52
+ ) -> DnsQuery | None:
53
+ if len(data) < 12:
54
+ return None
55
+ _ident, flags, qdcount, _ancount, _nscount, _arcount = struct.unpack("!HHHHHH", data[:12])
56
+ if flags & 0x8000 or qdcount < 1:
57
+ return None
58
+ offset = 12
59
+ labels: list[str] = []
60
+ while True:
61
+ if offset >= len(data):
62
+ return None
63
+ length = data[offset]
64
+ offset += 1
65
+ if length == 0:
66
+ break
67
+ if length & 0xC0:
68
+ # Compression pointers are legal in messages but not in the
69
+ # question section of a query; reject rather than risk a loop.
70
+ return None
71
+ if length > 63 or offset + length > len(data):
72
+ return None
73
+ labels.append(data[offset : offset + length].decode("ascii", errors="replace"))
74
+ offset += length
75
+ if offset + 4 > len(data):
76
+ return None
77
+ qtype, qclass = struct.unpack("!HH", data[offset : offset + 4])
78
+ qname = ".".join(labels) or "."
79
+ return DnsQuery(
80
+ ts=ts or utc_now(),
81
+ qname=qname,
82
+ qtype=qtype,
83
+ qclass=qclass,
84
+ client=client,
85
+ raw_b64=base64.b64encode(data).decode("ascii"),
86
+ )
87
+
88
+
89
+ def _question_end(data: bytes) -> int | None:
90
+ if len(data) < 12:
91
+ return None
92
+ offset = 12
93
+ while offset < len(data):
94
+ length = data[offset]
95
+ offset += 1
96
+ if length == 0:
97
+ return offset + 4 if offset + 4 <= len(data) else None
98
+ if length & 0xC0 or offset + length > len(data):
99
+ return None
100
+ offset += length
101
+ return None
102
+
103
+
104
+ def build_dns_response(
105
+ query: bytes,
106
+ *,
107
+ answer_ip: str = "127.0.0.1",
108
+ rcode: int = 0,
109
+ ) -> bytes:
110
+ if len(query) < 12:
111
+ raise ValueError("DNS query is shorter than its header")
112
+ ident = query[:2]
113
+ question_end = _question_end(query)
114
+ if question_end is None:
115
+ raise ValueError("DNS query has no complete question section")
116
+ qtype = struct.unpack("!H", query[question_end - 4 : question_end - 2])[0]
117
+ answer = b""
118
+ ancount = 0
119
+ if rcode == 0 and qtype == 1:
120
+ try:
121
+ packed_ip = ipaddress.IPv4Address(answer_ip).packed
122
+ except ipaddress.AddressValueError as exc:
123
+ raise ValueError(f"answer_ip must be an IPv4 address: {answer_ip!r}") from exc
124
+ answer = b"\xc0\x0c" + struct.pack("!HHIH", 1, 1, 60, 4) + packed_ip
125
+ ancount = 1
126
+ flags = 0x8000 | 0x0100 | 0x0080 | (rcode & 0xF) # QR, RD, RA
127
+ header = ident + struct.pack("!HHHHH", flags, 1, ancount, 0, 0)
128
+ return header + query[12:question_end] + answer
129
+
130
+
131
+ class DnsSinkhole:
132
+ """Threaded UDP DNS responder that records every query it receives."""
133
+
134
+ def __init__(
135
+ self,
136
+ *,
137
+ host: str = "127.0.0.1",
138
+ port: int = 0,
139
+ answer_ip: str = "127.0.0.1",
140
+ on_query: Callable[[DnsQuery], None] | None = None,
141
+ max_queries: int = 5000,
142
+ ) -> None:
143
+ self.host = host
144
+ self.port = port
145
+ self.answer_ip = answer_ip
146
+ self.on_query = on_query
147
+ self.max_queries = max_queries
148
+ self._sock: socket.socket | None = None
149
+ self._thread: threading.Thread | None = None
150
+ self._stop = threading.Event()
151
+ self._lock = threading.Lock()
152
+ self._queries: list[DnsQuery] = []
153
+
154
+ @property
155
+ def address(self) -> tuple[str, int] | None:
156
+ if self._sock is None:
157
+ return None
158
+ host, port = self._sock.getsockname()[:2]
159
+ return str(host), int(port)
160
+
161
+ @property
162
+ def queries(self) -> tuple[DnsQuery, ...]:
163
+ with self._lock:
164
+ return tuple(self._queries)
165
+
166
+ def start(self) -> DnsSinkhole:
167
+ if self._sock is not None:
168
+ return self
169
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
170
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
171
+ sock.bind((self.host, self.port))
172
+ sock.settimeout(0.2)
173
+ self._sock = sock
174
+ self._stop.clear()
175
+ self._thread = threading.Thread(target=self._serve, name="skillsentry-dns", daemon=True)
176
+ self._thread.start()
177
+ return self
178
+
179
+ def stop(self) -> None:
180
+ self._stop.set()
181
+ if self._sock is not None:
182
+ with contextlib.suppress(OSError):
183
+ self._sock.close()
184
+ self._sock = None
185
+ if self._thread is not None:
186
+ self._thread.join(timeout=1.0)
187
+ self._thread = None
188
+
189
+ def __enter__(self) -> DnsSinkhole:
190
+ return self.start()
191
+
192
+ def __exit__(self, *exc_info: object) -> None:
193
+ self.stop()
194
+
195
+ def _serve(self) -> None:
196
+ sock = self._sock
197
+ if sock is None:
198
+ return
199
+ while not self._stop.is_set():
200
+ try:
201
+ data, peer = sock.recvfrom(4096)
202
+ except TimeoutError:
203
+ continue
204
+ except OSError:
205
+ break
206
+ query = parse_dns_query(data, client=f"{peer[0]}:{peer[1]}")
207
+ if query is None:
208
+ continue
209
+ with self._lock:
210
+ if len(self._queries) >= self.max_queries:
211
+ continue
212
+ self._queries.append(query)
213
+ if self.on_query is not None:
214
+ # A telemetry callback must never kill the responder.
215
+ with contextlib.suppress(Exception):
216
+ self.on_query(query)
217
+ with contextlib.suppress(OSError, ValueError):
218
+ sock.sendto(build_dns_response(data, answer_ip=self.answer_ip), peer)
@@ -0,0 +1,194 @@
1
+ """Inert fake HTTP responder for the Poisoned Well sinkhole.
2
+
3
+ Every request is recorded (method, URL, headers, body) and answered with a
4
+ fixed ``200 {"status":"ok","data":"<secondary canary>"}``. It never proxies,
5
+ resolves, or serves attacker-controlled content. Bind it to loopback for tests
6
+ or to the internal sinkhole network in the Docker integration.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import contextlib
13
+ import json
14
+ import threading
15
+ from collections import defaultdict, deque
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime
19
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
20
+ from time import monotonic
21
+ from typing import Any
22
+
23
+ from skill_sentry_cli.core.contracts import utc_now
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class HttpObservation:
28
+ ts: datetime
29
+ method: str
30
+ url: str
31
+ headers: dict[str, str] = field(default_factory=dict)
32
+ body: bytes = b""
33
+ client: str = ""
34
+ responder_action: str = "200+secondary"
35
+
36
+ @property
37
+ def body_b64(self) -> str | None:
38
+ if not self.body:
39
+ return None
40
+ return base64.b64encode(self.body).decode("ascii")
41
+
42
+ @property
43
+ def query_or_url(self) -> str:
44
+ return f"{self.method} {self.url}"
45
+
46
+
47
+ class _FakeHttpServer(ThreadingHTTPServer):
48
+ daemon_threads = True
49
+ allow_reuse_address = True
50
+
51
+ def __init__(self, address: tuple[str, int], app: FakeHttpResponder) -> None:
52
+ self.app = app
53
+ super().__init__(address, _FakeHttpHandler)
54
+
55
+
56
+ class _FakeHttpHandler(BaseHTTPRequestHandler):
57
+ server: _FakeHttpServer
58
+
59
+ def log_message(self, fmt: str, *args: Any) -> None:
60
+ # Keep test output quiet; the responder has its own structured log.
61
+ return
62
+
63
+ def _read_body(self) -> bytes:
64
+ raw_length = self.headers.get("Content-Length", "0")
65
+ try:
66
+ length = max(0, min(int(raw_length), 1_048_576))
67
+ except ValueError:
68
+ length = 0
69
+ return self.rfile.read(length) if length else b""
70
+
71
+ def _handle(self) -> None:
72
+ body = self._read_body()
73
+ observation = HttpObservation(
74
+ ts=utc_now(),
75
+ method=self.command,
76
+ url=self.path,
77
+ headers={key: value for key, value in self.headers.items()},
78
+ body=body,
79
+ client=f"{self.client_address[0]}:{self.client_address[1]}",
80
+ )
81
+ app = self.server.app
82
+ action = app._record(observation)
83
+ status = 429 if action == "429-rate-limited" else 200
84
+ payload = (
85
+ {"error": "rate limited"}
86
+ if status == 429
87
+ else {"status": "ok", "data": app.secondary_canary}
88
+ )
89
+ encoded = json.dumps(payload, separators=(",", ":")).encode("utf-8")
90
+ self.send_response(status)
91
+ self.send_header("Content-Type", "application/json")
92
+ self.send_header("Content-Length", str(len(encoded)))
93
+ self.send_header("Connection", "close")
94
+ self.end_headers()
95
+ self.wfile.write(encoded)
96
+
97
+ do_GET = _handle
98
+ do_POST = _handle
99
+ do_PUT = _handle
100
+ do_PATCH = _handle
101
+ do_DELETE = _handle
102
+ do_HEAD = _handle
103
+
104
+
105
+ class FakeHttpResponder:
106
+ def __init__(
107
+ self,
108
+ *,
109
+ host: str = "127.0.0.1",
110
+ port: int = 0,
111
+ secondary_canary: str = "SKS-00000000",
112
+ rate_limit_per_minute: int = 120,
113
+ on_request: Callable[[HttpObservation], None] | None = None,
114
+ ) -> None:
115
+ if rate_limit_per_minute <= 0:
116
+ raise ValueError("rate_limit_per_minute must be positive")
117
+ self.host = host
118
+ self.port = port
119
+ self.secondary_canary = secondary_canary
120
+ self.rate_limit_per_minute = rate_limit_per_minute
121
+ self.on_request = on_request
122
+ self._server: _FakeHttpServer | None = None
123
+ self._thread: threading.Thread | None = None
124
+ self._lock = threading.Lock()
125
+ self._requests: list[HttpObservation] = []
126
+ self._client_windows: dict[str, deque[float]] = defaultdict(deque)
127
+
128
+ @property
129
+ def address(self) -> tuple[str, int] | None:
130
+ if self._server is None:
131
+ return None
132
+ host, port = self._server.server_address[:2]
133
+ return str(host), int(port)
134
+
135
+ @property
136
+ def requests(self) -> tuple[HttpObservation, ...]:
137
+ with self._lock:
138
+ return tuple(self._requests)
139
+
140
+ def start(self) -> FakeHttpResponder:
141
+ if self._server is not None:
142
+ return self
143
+ self._server = _FakeHttpServer((self.host, self.port), self)
144
+ self._thread = threading.Thread(
145
+ target=self._server.serve_forever,
146
+ name="skillsentry-http",
147
+ daemon=True,
148
+ )
149
+ self._thread.start()
150
+ return self
151
+
152
+ def stop(self) -> None:
153
+ if self._server is not None:
154
+ self._server.shutdown()
155
+ self._server.server_close()
156
+ self._server = None
157
+ if self._thread is not None:
158
+ self._thread.join(timeout=1.0)
159
+ self._thread = None
160
+
161
+ def __enter__(self) -> FakeHttpResponder:
162
+ return self.start()
163
+
164
+ def __exit__(self, *exc_info: object) -> None:
165
+ self.stop()
166
+
167
+ def _record(self, observation: HttpObservation) -> str:
168
+ with self._lock:
169
+ # The ephemeral source port changes for every connection, so limit
170
+ # by host address rather than by "host:port".
171
+ client_key = observation.client.rsplit(":", 1)[0] if observation.client else "unknown"
172
+ window = self._client_windows[client_key]
173
+ now = monotonic()
174
+ while window and now - window[0] >= 60.0:
175
+ window.popleft()
176
+ if len(window) >= self.rate_limit_per_minute:
177
+ action = "429-rate-limited"
178
+ else:
179
+ window.append(now)
180
+ action = "200+secondary"
181
+ recorded = HttpObservation(
182
+ ts=observation.ts,
183
+ method=observation.method,
184
+ url=observation.url,
185
+ headers=observation.headers,
186
+ body=observation.body,
187
+ client=observation.client,
188
+ responder_action=action,
189
+ )
190
+ self._requests.append(recorded)
191
+ if self.on_request is not None:
192
+ with contextlib.suppress(Exception):
193
+ self.on_request(recorded)
194
+ return action