@ictechgy/context-guard 0.4.16 → 0.5.1
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.
- package/CHANGELOG.md +72 -0
- package/README.ko.md +82 -1
- package/README.md +86 -1
- package/docs/distribution.md +100 -0
- package/package.json +4 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +25 -1
- package/plugins/context-guard/README.md +26 -1
- package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
- package/plugins/context-guard/bin/context-guard-bench +4216 -103
- package/plugins/context-guard/bin/context-guard-failed-nudge +95 -23
- package/plugins/context-guard/bin/context-guard-guard-read +6 -2
- package/plugins/context-guard/bin/context-guard-mcp +2 -1
- package/plugins/context-guard/bin/context-guard-pack +1463 -142
- package/plugins/context-guard/bin/context-guard-rewrite-bash +497 -45
- package/plugins/context-guard/bin/context-guard-sanitize-output +178 -22
- package/plugins/context-guard/bin/context-guard-setup +812 -16
- package/plugins/context-guard/bin/context-guard-statusline +33 -2
- package/plugins/context-guard/bin/context-guard-statusline-merged +71 -20
- package/plugins/context-guard/bin/context-guard-trim-output +706 -35
- package/plugins/context-guard/lib/context_guard_commands.py +4 -0
- package/plugins/context-guard/lib/credential_policy.py +10 -2
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
"""Narrow, fail-closed policy boundary for optional Bash receipt references.
|
|
2
|
+
|
|
3
|
+
Only an integrity-pinned Receipt CLI from the same project-local npm install
|
|
4
|
+
is eligible. Every discovery, launch, or response failure is an ordinary
|
|
5
|
+
legacy-routing outcome and never changes the wrapped command's result.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import select
|
|
18
|
+
import signal
|
|
19
|
+
import stat
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from typing import Protocol
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
REFERENCE_POLICY_VERSION = "bash_reference_v1"
|
|
27
|
+
REFERENCE_DISCLOSURE_DAYS = 7
|
|
28
|
+
REFERENCE_ADAPTER_TIMEOUT_SECONDS = 8
|
|
29
|
+
REFERENCE_MIN_SANITIZED_BYTES = 8_192
|
|
30
|
+
RECEIPT_PACKAGE_NAME = "@ictechgy/context-guard-receipt"
|
|
31
|
+
ROOT_PACKAGE_NAME = "@ictechgy/context-guard"
|
|
32
|
+
RECEIPT_CLI_RELATIVE_PATH = Path("bin/context-guard-receipt.cjs")
|
|
33
|
+
RECEIPT_LAUNCHER_RELATIVE_PATH = Path("bin/launcher.cjs")
|
|
34
|
+
RECEIPT_STATE_DIRECTORY_PREFIX = ".context-guard-receipt-state-"
|
|
35
|
+
_RECEIPT_STATE_SELECTOR_DOMAIN = b"contextguard/bash-reference-state-selector/v1\0"
|
|
36
|
+
# Audited digest of Receipt's package-files.json for each exact dependency
|
|
37
|
+
# version. Invalid or missing pins are deliberately unavailable in production.
|
|
38
|
+
EXPECTED_RECEIPT_PACKAGE_FILES_SHA256_BY_VERSION: dict[str, str] = {
|
|
39
|
+
"0.2.0": "303d39dcddef994edf14de146e4a9d0ffe2cc1bbf67645c6017012b55cd1d62d",
|
|
40
|
+
"0.2.1": "8d7b5cd94b34c89f37547eaf069be21a117b30a519bf499df2c52bd6459fe946",
|
|
41
|
+
}
|
|
42
|
+
_TRANSACTION_ID_RE = re.compile(r"^[a-f0-9]{64}$")
|
|
43
|
+
_EXACT_NPM_VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")
|
|
44
|
+
_MAX_PACKAGE_JSON_BYTES = 128 * 1024
|
|
45
|
+
_MAX_VERIFIED_PACKAGE_FILE_BYTES = 8 * 1024 * 1024
|
|
46
|
+
_TRUSTED_NODE_CANDIDATES: tuple[Path, ...] = (
|
|
47
|
+
Path("/usr/bin/node"),
|
|
48
|
+
Path("/usr/local/bin/node"),
|
|
49
|
+
Path("/opt/homebrew/bin/node"),
|
|
50
|
+
)
|
|
51
|
+
_TRUSTED_GITHUB_TOOLCACHE_PREFIXES: tuple[Path, ...] = (
|
|
52
|
+
Path("/opt/hostedtoolcache"),
|
|
53
|
+
Path("/Users/runner/hostedtoolcache"),
|
|
54
|
+
Path("/hostedtoolcache"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class ReceiptAdapterResult:
|
|
60
|
+
status: str
|
|
61
|
+
reference: str | None = None
|
|
62
|
+
reason_code: str = "receipt_adapter_unavailable"
|
|
63
|
+
actionable: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ReceiptAdapter(Protocol):
|
|
67
|
+
def start_broker(self, capture_fd: int, *, root: str, transaction_id: str,
|
|
68
|
+
disclosure_days: int, timeout_seconds: int) -> tuple[object | None, str]: ...
|
|
69
|
+
|
|
70
|
+
def query_reference(self, reference: str, *, root: str, offset: int,
|
|
71
|
+
timeout_seconds: int) -> object: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_BROKER_READY = b"READY contextguard-bash-reference-broker/v1\n"
|
|
75
|
+
_BROKER_FINAL_PREFIX = b"FINAL "
|
|
76
|
+
_BROKER_MAX_LINE_BYTES = 4096
|
|
77
|
+
_REFERENCE_HANDLE_RE = re.compile(r"^cgr1p_[A-Za-z0-9_-]{43}$", re.ASCII)
|
|
78
|
+
_REFERENCE_QUERY_SCHEMA = "contextguard-receipt-bash-reference-query/v1"
|
|
79
|
+
_REFERENCE_QUERY_MAX_PAYLOAD_BYTES = 20_000
|
|
80
|
+
_REFERENCE_QUERY_MAX_ARTIFACT_BYTES = 10_000_000
|
|
81
|
+
_REFERENCE_QUERY_MAX_STDOUT_BYTES = 28_000
|
|
82
|
+
_REFERENCE_QUERY_MAX_STDERR_BYTES = 4096
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ReferenceQueryResult:
|
|
87
|
+
status: str
|
|
88
|
+
reference: str | None = None
|
|
89
|
+
payload: bytes = field(default=b"", repr=False)
|
|
90
|
+
offset: int = 0
|
|
91
|
+
next_offset: int = 0
|
|
92
|
+
total_bytes: int = 0
|
|
93
|
+
reason_code: str = "reference_query_unavailable"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _read_bounded_process_channels(
|
|
97
|
+
process: subprocess.Popen[bytes],
|
|
98
|
+
*,
|
|
99
|
+
stdout_maximum: int,
|
|
100
|
+
stderr_maximum: int,
|
|
101
|
+
timeout_seconds: int,
|
|
102
|
+
) -> tuple[int, bytes, bytes] | None:
|
|
103
|
+
"""Drain stdout and stderr together without unbounded communicate buffers."""
|
|
104
|
+
|
|
105
|
+
if process.stdout is None or process.stderr is None:
|
|
106
|
+
return None
|
|
107
|
+
stdout_fd = process.stdout.fileno()
|
|
108
|
+
stderr_fd = process.stderr.fileno()
|
|
109
|
+
buffers = {stdout_fd: bytearray(), stderr_fd: bytearray()}
|
|
110
|
+
maxima = {stdout_fd: stdout_maximum, stderr_fd: stderr_maximum}
|
|
111
|
+
active = {stdout_fd, stderr_fd}
|
|
112
|
+
deadline = time.monotonic() + timeout_seconds
|
|
113
|
+
try:
|
|
114
|
+
while active:
|
|
115
|
+
remaining = deadline - time.monotonic()
|
|
116
|
+
if remaining <= 0:
|
|
117
|
+
return None
|
|
118
|
+
readable, _, _ = select.select(list(active), [], [], remaining)
|
|
119
|
+
if not readable:
|
|
120
|
+
return None
|
|
121
|
+
for descriptor in readable:
|
|
122
|
+
target = buffers[descriptor]
|
|
123
|
+
maximum = maxima[descriptor]
|
|
124
|
+
chunk = os.read(
|
|
125
|
+
descriptor, min(4096, maximum + 1 - len(target))
|
|
126
|
+
)
|
|
127
|
+
if not chunk:
|
|
128
|
+
active.remove(descriptor)
|
|
129
|
+
continue
|
|
130
|
+
target.extend(chunk)
|
|
131
|
+
if len(target) > maximum:
|
|
132
|
+
return None
|
|
133
|
+
remaining = deadline - time.monotonic()
|
|
134
|
+
if remaining <= 0:
|
|
135
|
+
return None
|
|
136
|
+
status = process.wait(timeout=remaining)
|
|
137
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
138
|
+
return None
|
|
139
|
+
return status, bytes(buffers[stdout_fd]), bytes(buffers[stderr_fd])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _read_bounded_line(
|
|
143
|
+
process: subprocess.Popen[bytes], *, maximum: int, timeout_seconds: int
|
|
144
|
+
) -> bytes | None:
|
|
145
|
+
stream = process.stdout
|
|
146
|
+
if stream is None:
|
|
147
|
+
return None
|
|
148
|
+
deadline = time.monotonic() + timeout_seconds
|
|
149
|
+
data = bytearray()
|
|
150
|
+
try:
|
|
151
|
+
descriptor = stream.fileno()
|
|
152
|
+
while len(data) <= maximum:
|
|
153
|
+
remaining = deadline - time.monotonic()
|
|
154
|
+
if remaining <= 0:
|
|
155
|
+
return None
|
|
156
|
+
readable, _, _ = select.select([descriptor], [], [], remaining)
|
|
157
|
+
if not readable:
|
|
158
|
+
return None
|
|
159
|
+
chunk = os.read(descriptor, 1)
|
|
160
|
+
if not chunk:
|
|
161
|
+
return None
|
|
162
|
+
data.extend(chunk)
|
|
163
|
+
if chunk == b"\n":
|
|
164
|
+
return bytes(data)
|
|
165
|
+
except (OSError, ValueError):
|
|
166
|
+
return None
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class PreparedReceiptBroker:
|
|
171
|
+
"""Bounded control channel for one already-READY Receipt transaction."""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
process: subprocess.Popen[bytes],
|
|
176
|
+
*,
|
|
177
|
+
transaction_id: str,
|
|
178
|
+
timeout_seconds: int,
|
|
179
|
+
) -> None:
|
|
180
|
+
self._process = process
|
|
181
|
+
self._transaction_id = transaction_id
|
|
182
|
+
self._timeout_seconds = timeout_seconds
|
|
183
|
+
self._finished = False
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _parse_final(raw: bytes, transaction_id: str) -> ReceiptAdapterResult:
|
|
187
|
+
if not raw.startswith(_BROKER_FINAL_PREFIX) or not raw.endswith(b"\n"):
|
|
188
|
+
return ReceiptAdapterResult(
|
|
189
|
+
status="failure", reason_code="receipt_broker_response_invalid"
|
|
190
|
+
)
|
|
191
|
+
document = raw[len(_BROKER_FINAL_PREFIX) : -1]
|
|
192
|
+
try:
|
|
193
|
+
response = json.loads(document.decode("utf-8", errors="strict"))
|
|
194
|
+
canonical = json.dumps(
|
|
195
|
+
response,
|
|
196
|
+
ensure_ascii=False,
|
|
197
|
+
separators=(",", ":"),
|
|
198
|
+
sort_keys=True,
|
|
199
|
+
).encode("utf-8")
|
|
200
|
+
except (TypeError, UnicodeDecodeError, UnicodeEncodeError, json.JSONDecodeError):
|
|
201
|
+
return ReceiptAdapterResult(
|
|
202
|
+
status="failure", reason_code="receipt_broker_response_invalid"
|
|
203
|
+
)
|
|
204
|
+
reference = response.get("reference") if isinstance(response, dict) else None
|
|
205
|
+
deadline = (
|
|
206
|
+
response.get("expires_at_unix_ms")
|
|
207
|
+
if isinstance(response, dict)
|
|
208
|
+
else None
|
|
209
|
+
)
|
|
210
|
+
if (
|
|
211
|
+
canonical != document
|
|
212
|
+
or not isinstance(response, dict)
|
|
213
|
+
or response.get("status") != "registered"
|
|
214
|
+
or response.get("transaction_id") != transaction_id
|
|
215
|
+
or response.get("actionable") is not True
|
|
216
|
+
or not isinstance(deadline, int)
|
|
217
|
+
or deadline <= time.time_ns() // 1_000_000
|
|
218
|
+
or not isinstance(reference, str)
|
|
219
|
+
or _REFERENCE_HANDLE_RE.fullmatch(reference) is None
|
|
220
|
+
):
|
|
221
|
+
return ReceiptAdapterResult(
|
|
222
|
+
status="failure", reason_code="receipt_broker_response_invalid"
|
|
223
|
+
)
|
|
224
|
+
return ReceiptAdapterResult(
|
|
225
|
+
status="success",
|
|
226
|
+
reference=reference,
|
|
227
|
+
reason_code="reference_published",
|
|
228
|
+
actionable=True,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def _send(self, command: bytes) -> bool:
|
|
232
|
+
stream = self._process.stdin
|
|
233
|
+
if stream is None or self._finished:
|
|
234
|
+
return False
|
|
235
|
+
try:
|
|
236
|
+
stream.write(command)
|
|
237
|
+
stream.flush()
|
|
238
|
+
stream.close()
|
|
239
|
+
return True
|
|
240
|
+
except (OSError, ValueError):
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
def commit(self) -> ReceiptAdapterResult:
|
|
244
|
+
if not self._send(b"COMMIT\n"):
|
|
245
|
+
self.close()
|
|
246
|
+
return ReceiptAdapterResult(
|
|
247
|
+
status="failure", reason_code="receipt_broker_unavailable"
|
|
248
|
+
)
|
|
249
|
+
raw = _read_bounded_line(
|
|
250
|
+
self._process,
|
|
251
|
+
maximum=_BROKER_MAX_LINE_BYTES,
|
|
252
|
+
timeout_seconds=self._timeout_seconds,
|
|
253
|
+
)
|
|
254
|
+
try:
|
|
255
|
+
status = self._process.wait(timeout=self._timeout_seconds)
|
|
256
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
257
|
+
status = None
|
|
258
|
+
self._finished = True
|
|
259
|
+
if status != 0 or raw is None:
|
|
260
|
+
_terminate_adapter_process(self._process)
|
|
261
|
+
return ReceiptAdapterResult(
|
|
262
|
+
status="failure", reason_code="receipt_broker_unavailable"
|
|
263
|
+
)
|
|
264
|
+
_close_adapter_streams(self._process)
|
|
265
|
+
return self._parse_final(raw, self._transaction_id)
|
|
266
|
+
|
|
267
|
+
def abort(self) -> None:
|
|
268
|
+
if self._finished:
|
|
269
|
+
return
|
|
270
|
+
sent = self._send(b"ABORT\n")
|
|
271
|
+
try:
|
|
272
|
+
status = self._process.wait(timeout=1) if sent else None
|
|
273
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
274
|
+
status = None
|
|
275
|
+
self._finished = True
|
|
276
|
+
if status != 0:
|
|
277
|
+
_terminate_adapter_process(self._process)
|
|
278
|
+
else:
|
|
279
|
+
_close_adapter_streams(self._process)
|
|
280
|
+
|
|
281
|
+
def close(self) -> None:
|
|
282
|
+
if not self._finished and self._process.poll() is None:
|
|
283
|
+
self.abort()
|
|
284
|
+
else:
|
|
285
|
+
_close_adapter_streams(self._process)
|
|
286
|
+
self._finished = True
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class NpmReceiptCliAdapter:
|
|
290
|
+
"""Verified package-local Receipt CLI; never resolves the executable via PATH."""
|
|
291
|
+
|
|
292
|
+
def __init__(
|
|
293
|
+
self,
|
|
294
|
+
cli_path: Path,
|
|
295
|
+
*,
|
|
296
|
+
node_path: Path | None = None,
|
|
297
|
+
node_identity: tuple[int, ...] | None = None,
|
|
298
|
+
protected_paths: tuple[Path, ...] | None = None,
|
|
299
|
+
protected_hashes: tuple[tuple[Path, str], ...] | None = None,
|
|
300
|
+
) -> None:
|
|
301
|
+
self._cli_path = cli_path
|
|
302
|
+
self._node_path = Path(node_path).absolute() if node_path is not None else None
|
|
303
|
+
self._node_identity = node_identity or (
|
|
304
|
+
_executable_identity(self._node_path)
|
|
305
|
+
if self._node_path is not None
|
|
306
|
+
else None
|
|
307
|
+
)
|
|
308
|
+
self._python_path = Path(sys.executable).resolve()
|
|
309
|
+
self._python_identity = _executable_identity(self._python_path)
|
|
310
|
+
if protected_hashes is None:
|
|
311
|
+
paths = protected_paths or (cli_path,)
|
|
312
|
+
self._protected_hashes = tuple(
|
|
313
|
+
(Path(path), _sha256_file(Path(path)) or "")
|
|
314
|
+
for path in paths
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
self._protected_hashes = tuple(
|
|
318
|
+
(Path(path), digest if re.fullmatch(r"[a-f0-9]{64}", digest) else "")
|
|
319
|
+
for path, digest in protected_hashes
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def _protected_package_intact(self) -> bool:
|
|
323
|
+
return bool(self._protected_hashes) and all(
|
|
324
|
+
expected and _sha256_file(path) == expected
|
|
325
|
+
for path, expected in self._protected_hashes
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def _parse_reference_query_response(
|
|
330
|
+
raw: bytes, *, reference: str, offset: int
|
|
331
|
+
) -> ReferenceQueryResult:
|
|
332
|
+
failure = ReferenceQueryResult(
|
|
333
|
+
status="failure", reason_code="receipt_query_response_invalid"
|
|
334
|
+
)
|
|
335
|
+
if (
|
|
336
|
+
type(raw) is not bytes
|
|
337
|
+
or len(raw) > _REFERENCE_QUERY_MAX_STDOUT_BYTES
|
|
338
|
+
or type(reference) is not str
|
|
339
|
+
or _REFERENCE_HANDLE_RE.fullmatch(reference) is None
|
|
340
|
+
or type(offset) is not int
|
|
341
|
+
or offset < 0
|
|
342
|
+
):
|
|
343
|
+
return failure
|
|
344
|
+
try:
|
|
345
|
+
response = json.loads(raw.decode("utf-8", errors="strict"))
|
|
346
|
+
canonical = json.dumps(
|
|
347
|
+
response,
|
|
348
|
+
ensure_ascii=False,
|
|
349
|
+
separators=(",", ":"),
|
|
350
|
+
sort_keys=True,
|
|
351
|
+
).encode("utf-8") + b"\n"
|
|
352
|
+
except (TypeError, UnicodeDecodeError, UnicodeEncodeError, json.JSONDecodeError):
|
|
353
|
+
return failure
|
|
354
|
+
expected_keys = {
|
|
355
|
+
"next_offset",
|
|
356
|
+
"offset",
|
|
357
|
+
"payload_b64u",
|
|
358
|
+
"request",
|
|
359
|
+
"schema_version",
|
|
360
|
+
"status",
|
|
361
|
+
"total_bytes",
|
|
362
|
+
}
|
|
363
|
+
request = response.get("request") if type(response) is dict else None
|
|
364
|
+
if (
|
|
365
|
+
canonical != raw
|
|
366
|
+
or type(response) is not dict
|
|
367
|
+
or set(response) != expected_keys
|
|
368
|
+
or response.get("schema_version") != _REFERENCE_QUERY_SCHEMA
|
|
369
|
+
or response.get("status") != "exact"
|
|
370
|
+
or type(request) is not dict
|
|
371
|
+
or set(request) != {"offset", "reference"}
|
|
372
|
+
or type(request.get("reference")) is not str
|
|
373
|
+
or _REFERENCE_HANDLE_RE.fullmatch(request["reference"]) is None
|
|
374
|
+
or not hmac.compare_digest(request["reference"], reference)
|
|
375
|
+
or type(request.get("offset")) is not int
|
|
376
|
+
or request["offset"] != offset
|
|
377
|
+
or type(response.get("offset")) is not int
|
|
378
|
+
or response.get("offset") != offset
|
|
379
|
+
):
|
|
380
|
+
return failure
|
|
381
|
+
encoded_payload = response.get("payload_b64u")
|
|
382
|
+
next_offset = response.get("next_offset")
|
|
383
|
+
total_bytes = response.get("total_bytes")
|
|
384
|
+
if (
|
|
385
|
+
type(encoded_payload) is not str
|
|
386
|
+
or re.fullmatch(r"[A-Za-z0-9_-]*", encoded_payload, re.ASCII) is None
|
|
387
|
+
or type(next_offset) is not int
|
|
388
|
+
or type(total_bytes) is not int
|
|
389
|
+
or not 0 <= offset <= next_offset <= total_bytes <= _REFERENCE_QUERY_MAX_ARTIFACT_BYTES
|
|
390
|
+
):
|
|
391
|
+
return failure
|
|
392
|
+
try:
|
|
393
|
+
padded = encoded_payload + "=" * (-len(encoded_payload) % 4)
|
|
394
|
+
payload = base64.b64decode(
|
|
395
|
+
padded.encode("ascii"), altchars=b"-_", validate=True
|
|
396
|
+
)
|
|
397
|
+
if (
|
|
398
|
+
base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii")
|
|
399
|
+
!= encoded_payload
|
|
400
|
+
or len(payload) > _REFERENCE_QUERY_MAX_PAYLOAD_BYTES
|
|
401
|
+
or next_offset != offset + len(payload)
|
|
402
|
+
or (not payload and offset != total_bytes)
|
|
403
|
+
):
|
|
404
|
+
return failure
|
|
405
|
+
payload.decode("utf-8", errors="strict")
|
|
406
|
+
except (UnicodeDecodeError, UnicodeEncodeError, ValueError):
|
|
407
|
+
return failure
|
|
408
|
+
return ReferenceQueryResult(
|
|
409
|
+
status="success",
|
|
410
|
+
reference=reference,
|
|
411
|
+
payload=payload,
|
|
412
|
+
offset=offset,
|
|
413
|
+
next_offset=next_offset,
|
|
414
|
+
total_bytes=total_bytes,
|
|
415
|
+
reason_code="reference_query_exact",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def query_reference(
|
|
419
|
+
self,
|
|
420
|
+
reference: str,
|
|
421
|
+
*,
|
|
422
|
+
root: str,
|
|
423
|
+
offset: int,
|
|
424
|
+
timeout_seconds: int,
|
|
425
|
+
) -> ReferenceQueryResult:
|
|
426
|
+
failure = lambda reason: ReferenceQueryResult(
|
|
427
|
+
status="failure", reason_code=reason
|
|
428
|
+
)
|
|
429
|
+
repository_root = Path(root)
|
|
430
|
+
if (
|
|
431
|
+
_REFERENCE_HANDLE_RE.fullmatch(reference) is None
|
|
432
|
+
or type(offset) is not int
|
|
433
|
+
or not 0 <= offset <= _REFERENCE_QUERY_MAX_ARTIFACT_BYTES
|
|
434
|
+
or timeout_seconds != REFERENCE_ADAPTER_TIMEOUT_SECONDS
|
|
435
|
+
or not repository_root.is_absolute()
|
|
436
|
+
):
|
|
437
|
+
return failure("receipt_adapter_argument_invalid")
|
|
438
|
+
if self._node_path is None or self._node_identity is None:
|
|
439
|
+
return failure("receipt_node_interpreter_unavailable")
|
|
440
|
+
if _executable_identity(self._node_path) != self._node_identity:
|
|
441
|
+
return failure("receipt_node_interpreter_changed_before_launch")
|
|
442
|
+
if (
|
|
443
|
+
self._python_identity is None
|
|
444
|
+
or _executable_identity(self._python_path) != self._python_identity
|
|
445
|
+
):
|
|
446
|
+
return failure("receipt_python_interpreter_changed_before_launch")
|
|
447
|
+
if not self._protected_package_intact():
|
|
448
|
+
return failure("receipt_package_changed_before_launch")
|
|
449
|
+
try:
|
|
450
|
+
state_dir = receipt_state_directory(repository_root)
|
|
451
|
+
except (OSError, ValueError):
|
|
452
|
+
return failure("receipt_state_location_unavailable")
|
|
453
|
+
command = [
|
|
454
|
+
str(self._node_path),
|
|
455
|
+
str(self._cli_path),
|
|
456
|
+
"--private-bash-reference-query-v1",
|
|
457
|
+
reference,
|
|
458
|
+
"--root",
|
|
459
|
+
str(repository_root),
|
|
460
|
+
"--state-dir",
|
|
461
|
+
str(state_dir),
|
|
462
|
+
"--offset",
|
|
463
|
+
str(offset),
|
|
464
|
+
]
|
|
465
|
+
environment = {
|
|
466
|
+
"CONTEXT_GUARD_RECEIPT_PYTHON": str(self._python_path),
|
|
467
|
+
"LANG": "C",
|
|
468
|
+
"LC_ALL": "C",
|
|
469
|
+
"PATH": os.defpath,
|
|
470
|
+
"PYTHONDONTWRITEBYTECODE": "1",
|
|
471
|
+
"PYTHONUTF8": "1",
|
|
472
|
+
}
|
|
473
|
+
try:
|
|
474
|
+
process = subprocess.Popen(
|
|
475
|
+
command,
|
|
476
|
+
cwd=str(repository_root),
|
|
477
|
+
env=environment,
|
|
478
|
+
stdin=subprocess.DEVNULL,
|
|
479
|
+
stdout=subprocess.PIPE,
|
|
480
|
+
stderr=subprocess.PIPE,
|
|
481
|
+
text=False,
|
|
482
|
+
bufsize=0,
|
|
483
|
+
close_fds=True,
|
|
484
|
+
start_new_session=(os.name != "nt"),
|
|
485
|
+
)
|
|
486
|
+
except OSError:
|
|
487
|
+
return failure("receipt_adapter_launch_failed")
|
|
488
|
+
captured = _read_bounded_process_channels(
|
|
489
|
+
process,
|
|
490
|
+
stdout_maximum=_REFERENCE_QUERY_MAX_STDOUT_BYTES,
|
|
491
|
+
stderr_maximum=_REFERENCE_QUERY_MAX_STDERR_BYTES,
|
|
492
|
+
timeout_seconds=timeout_seconds,
|
|
493
|
+
)
|
|
494
|
+
if captured is None:
|
|
495
|
+
_terminate_adapter_process(process)
|
|
496
|
+
return failure("receipt_query_unavailable")
|
|
497
|
+
status, stdout, stderr = captured
|
|
498
|
+
_close_adapter_streams(process)
|
|
499
|
+
if status != 0 or stderr:
|
|
500
|
+
return failure("receipt_query_unavailable")
|
|
501
|
+
return self._parse_reference_query_response(
|
|
502
|
+
stdout, reference=reference, offset=offset
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
def start_broker(
|
|
506
|
+
self,
|
|
507
|
+
capture_fd: int,
|
|
508
|
+
*,
|
|
509
|
+
root: str,
|
|
510
|
+
transaction_id: str,
|
|
511
|
+
disclosure_days: int,
|
|
512
|
+
timeout_seconds: int,
|
|
513
|
+
) -> tuple[PreparedReceiptBroker | None, str]:
|
|
514
|
+
repository_root = Path(root)
|
|
515
|
+
if (
|
|
516
|
+
type(capture_fd) is not int
|
|
517
|
+
or capture_fd < 0
|
|
518
|
+
or not repository_root.is_absolute()
|
|
519
|
+
or not _TRANSACTION_ID_RE.fullmatch(transaction_id)
|
|
520
|
+
or disclosure_days != REFERENCE_DISCLOSURE_DAYS
|
|
521
|
+
or timeout_seconds != REFERENCE_ADAPTER_TIMEOUT_SECONDS
|
|
522
|
+
):
|
|
523
|
+
return None, "receipt_adapter_argument_invalid"
|
|
524
|
+
if self._node_path is None or self._node_identity is None:
|
|
525
|
+
return None, "receipt_node_interpreter_unavailable"
|
|
526
|
+
if _executable_identity(self._node_path) != self._node_identity:
|
|
527
|
+
return None, "receipt_node_interpreter_changed_before_launch"
|
|
528
|
+
if (
|
|
529
|
+
self._python_identity is None
|
|
530
|
+
or _executable_identity(self._python_path) != self._python_identity
|
|
531
|
+
):
|
|
532
|
+
return None, "receipt_python_interpreter_changed_before_launch"
|
|
533
|
+
if not self._protected_package_intact():
|
|
534
|
+
return None, "receipt_package_changed_before_launch"
|
|
535
|
+
try:
|
|
536
|
+
state_dir = receipt_state_directory(repository_root)
|
|
537
|
+
except (OSError, ValueError):
|
|
538
|
+
return None, "receipt_state_location_unavailable"
|
|
539
|
+
command = [
|
|
540
|
+
str(self._node_path),
|
|
541
|
+
str(self._cli_path),
|
|
542
|
+
"--private-bash-reference-broker-v1",
|
|
543
|
+
"--capture-fd",
|
|
544
|
+
str(capture_fd),
|
|
545
|
+
"--transaction-id",
|
|
546
|
+
transaction_id,
|
|
547
|
+
"--root",
|
|
548
|
+
str(repository_root),
|
|
549
|
+
"--state-dir",
|
|
550
|
+
str(state_dir),
|
|
551
|
+
"--disclosure-days",
|
|
552
|
+
str(REFERENCE_DISCLOSURE_DAYS),
|
|
553
|
+
]
|
|
554
|
+
environment = {
|
|
555
|
+
"CONTEXT_GUARD_RECEIPT_PYTHON": str(self._python_path),
|
|
556
|
+
"LANG": "C",
|
|
557
|
+
"LC_ALL": "C",
|
|
558
|
+
"PATH": os.defpath,
|
|
559
|
+
"PYTHONDONTWRITEBYTECODE": "1",
|
|
560
|
+
"PYTHONUTF8": "1",
|
|
561
|
+
}
|
|
562
|
+
process: subprocess.Popen[bytes] | None = None
|
|
563
|
+
try:
|
|
564
|
+
process = subprocess.Popen(
|
|
565
|
+
command,
|
|
566
|
+
cwd=str(repository_root),
|
|
567
|
+
env=environment,
|
|
568
|
+
stdin=subprocess.PIPE,
|
|
569
|
+
stdout=subprocess.PIPE,
|
|
570
|
+
stderr=subprocess.DEVNULL,
|
|
571
|
+
text=False,
|
|
572
|
+
bufsize=0,
|
|
573
|
+
pass_fds=(capture_fd,),
|
|
574
|
+
close_fds=True,
|
|
575
|
+
start_new_session=(os.name != "nt"),
|
|
576
|
+
)
|
|
577
|
+
except OSError:
|
|
578
|
+
return None, "receipt_adapter_launch_failed"
|
|
579
|
+
ready = _read_bounded_line(
|
|
580
|
+
process,
|
|
581
|
+
maximum=len(_BROKER_READY),
|
|
582
|
+
timeout_seconds=timeout_seconds,
|
|
583
|
+
)
|
|
584
|
+
if ready != _BROKER_READY or process.poll() is not None:
|
|
585
|
+
_terminate_adapter_process(process)
|
|
586
|
+
return None, "receipt_broker_unavailable"
|
|
587
|
+
return (
|
|
588
|
+
PreparedReceiptBroker(
|
|
589
|
+
process,
|
|
590
|
+
transaction_id=transaction_id,
|
|
591
|
+
timeout_seconds=timeout_seconds,
|
|
592
|
+
),
|
|
593
|
+
"receipt_broker_ready",
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def receipt_state_directory(repository_root: Path) -> Path:
|
|
598
|
+
"""Select one stable private sibling so Receipt state stays outside the repo."""
|
|
599
|
+
|
|
600
|
+
root = Path(repository_root)
|
|
601
|
+
root_text = str(root)
|
|
602
|
+
if (
|
|
603
|
+
not root.is_absolute()
|
|
604
|
+
or os.path.normpath(root_text) != root_text
|
|
605
|
+
or root.is_symlink()
|
|
606
|
+
):
|
|
607
|
+
raise ValueError("repository root must be a normalized physical path")
|
|
608
|
+
status = root.lstat()
|
|
609
|
+
if not stat.S_ISDIR(status.st_mode):
|
|
610
|
+
raise ValueError("repository root must be a directory")
|
|
611
|
+
selector = hashlib.sha256()
|
|
612
|
+
selector.update(_RECEIPT_STATE_SELECTOR_DOMAIN)
|
|
613
|
+
for field in (
|
|
614
|
+
os.fsencode(root_text),
|
|
615
|
+
str(status.st_dev).encode("ascii"),
|
|
616
|
+
str(status.st_ino).encode("ascii"),
|
|
617
|
+
):
|
|
618
|
+
selector.update(len(field).to_bytes(8, "big"))
|
|
619
|
+
selector.update(field)
|
|
620
|
+
return root.parent / f"{RECEIPT_STATE_DIRECTORY_PREFIX}{selector.hexdigest()}"
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _close_adapter_streams(process: subprocess.Popen[bytes]) -> None:
|
|
624
|
+
for name in ("stdin", "stdout", "stderr"):
|
|
625
|
+
stream = getattr(process, name, None)
|
|
626
|
+
if stream is not None:
|
|
627
|
+
try:
|
|
628
|
+
stream.close()
|
|
629
|
+
except (OSError, ValueError):
|
|
630
|
+
pass
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _terminate_adapter_process(process: subprocess.Popen[bytes]) -> None:
|
|
634
|
+
"""Bounded cleanup for a timed-out adapter; never touches the caller child."""
|
|
635
|
+
try:
|
|
636
|
+
if process.poll() is None:
|
|
637
|
+
try:
|
|
638
|
+
if os.name != "nt":
|
|
639
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
640
|
+
else:
|
|
641
|
+
process.kill()
|
|
642
|
+
except (OSError, ProcessLookupError):
|
|
643
|
+
pass
|
|
644
|
+
try:
|
|
645
|
+
process.wait(timeout=1)
|
|
646
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
647
|
+
pass
|
|
648
|
+
finally:
|
|
649
|
+
_close_adapter_streams(process)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _stat_identity(value: os.stat_result) -> tuple[int, ...]:
|
|
653
|
+
return (
|
|
654
|
+
value.st_dev, value.st_ino, value.st_mode, value.st_nlink,
|
|
655
|
+
value.st_uid, value.st_gid, value.st_size,
|
|
656
|
+
value.st_mtime_ns, value.st_ctime_ns,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _trusted_github_toolcache_roots() -> tuple[Path, ...]:
|
|
661
|
+
if os.environ.get("GITHUB_ACTIONS", "").lower() != "true":
|
|
662
|
+
return ()
|
|
663
|
+
roots: list[Path] = []
|
|
664
|
+
for prefix in _TRUSTED_GITHUB_TOOLCACHE_PREFIXES:
|
|
665
|
+
try:
|
|
666
|
+
roots.append(prefix.resolve(strict=True))
|
|
667
|
+
except OSError:
|
|
668
|
+
continue
|
|
669
|
+
return tuple(roots)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _path_is_under(path: Path, roots: tuple[Path, ...]) -> bool:
|
|
673
|
+
try:
|
|
674
|
+
path = path.resolve(strict=True)
|
|
675
|
+
except OSError:
|
|
676
|
+
return False
|
|
677
|
+
return any(path == root or root in path.parents for root in roots)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _executable_identity(path: Path) -> tuple[int, ...] | None:
|
|
681
|
+
"""Bind one already-absolute interpreter without following a final link."""
|
|
682
|
+
try:
|
|
683
|
+
status = path.lstat()
|
|
684
|
+
except OSError:
|
|
685
|
+
return None
|
|
686
|
+
if (
|
|
687
|
+
not path.is_absolute()
|
|
688
|
+
or not stat.S_ISREG(status.st_mode)
|
|
689
|
+
or status.st_nlink != 1
|
|
690
|
+
or status.st_uid not in {0, os.geteuid()}
|
|
691
|
+
or status.st_mode & 0o022
|
|
692
|
+
or not status.st_mode & 0o111
|
|
693
|
+
):
|
|
694
|
+
return None
|
|
695
|
+
return _stat_identity(status)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _trusted_ci_node_from_path(project_root: Path) -> tuple[Path, tuple[int, ...]] | None:
|
|
699
|
+
"""Resolve PATH's Node only for GitHub Actions under fixed toolcache roots."""
|
|
700
|
+
try:
|
|
701
|
+
project_root = project_root.resolve(strict=True)
|
|
702
|
+
except OSError:
|
|
703
|
+
return None
|
|
704
|
+
if not project_root.is_dir():
|
|
705
|
+
return None
|
|
706
|
+
trusted_prefixes = _trusted_github_toolcache_roots()
|
|
707
|
+
if not trusted_prefixes:
|
|
708
|
+
return None
|
|
709
|
+
for entry in os.environ.get("PATH", "").split(os.pathsep):
|
|
710
|
+
if not entry:
|
|
711
|
+
continue
|
|
712
|
+
candidate = Path(entry) / "node"
|
|
713
|
+
try:
|
|
714
|
+
resolved = candidate.resolve(strict=True)
|
|
715
|
+
resolved.relative_to(project_root)
|
|
716
|
+
continue
|
|
717
|
+
except ValueError:
|
|
718
|
+
pass
|
|
719
|
+
except OSError:
|
|
720
|
+
continue
|
|
721
|
+
if not _path_is_under(resolved, trusted_prefixes):
|
|
722
|
+
continue
|
|
723
|
+
identity = _executable_identity(resolved)
|
|
724
|
+
if identity is not None:
|
|
725
|
+
return resolved, identity
|
|
726
|
+
return None
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _trusted_node_interpreter(project_root: Path) -> tuple[Path, tuple[int, ...]] | None:
|
|
730
|
+
"""Resolve Node from fixed locations, with a CI-only toolcache fallback."""
|
|
731
|
+
try:
|
|
732
|
+
project_root = project_root.resolve(strict=True)
|
|
733
|
+
except OSError:
|
|
734
|
+
return None
|
|
735
|
+
if not project_root.is_dir():
|
|
736
|
+
return None
|
|
737
|
+
for candidate in _TRUSTED_NODE_CANDIDATES:
|
|
738
|
+
try:
|
|
739
|
+
resolved = candidate.resolve(strict=True)
|
|
740
|
+
resolved.relative_to(project_root)
|
|
741
|
+
except ValueError:
|
|
742
|
+
identity = _executable_identity(resolved)
|
|
743
|
+
if identity is not None:
|
|
744
|
+
return resolved, identity
|
|
745
|
+
except OSError:
|
|
746
|
+
continue
|
|
747
|
+
return _trusted_ci_node_from_path(project_root)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _read_stable_regular_bytes(path: Path, *, max_bytes: int) -> bytes | None:
|
|
751
|
+
"""Read one no-follow file only if its identity remains unchanged."""
|
|
752
|
+
if not hasattr(os, "O_NOFOLLOW"):
|
|
753
|
+
return None
|
|
754
|
+
try:
|
|
755
|
+
before = path.lstat()
|
|
756
|
+
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 or before.st_size > max_bytes:
|
|
757
|
+
return None
|
|
758
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
|
|
759
|
+
fd = os.open(path, flags)
|
|
760
|
+
except OSError:
|
|
761
|
+
return None
|
|
762
|
+
try:
|
|
763
|
+
opened = os.fstat(fd)
|
|
764
|
+
if not stat.S_ISREG(opened.st_mode) or _stat_identity(opened) != _stat_identity(before):
|
|
765
|
+
return None
|
|
766
|
+
chunks: list[bytes] = []
|
|
767
|
+
total = 0
|
|
768
|
+
while True:
|
|
769
|
+
chunk = os.read(fd, min(64 * 1024, max_bytes + 1 - total))
|
|
770
|
+
if not chunk:
|
|
771
|
+
break
|
|
772
|
+
chunks.append(chunk)
|
|
773
|
+
total += len(chunk)
|
|
774
|
+
if total > max_bytes:
|
|
775
|
+
return None
|
|
776
|
+
after_fd = os.fstat(fd)
|
|
777
|
+
after_path = path.lstat()
|
|
778
|
+
if (
|
|
779
|
+
_stat_identity(after_fd) != _stat_identity(opened)
|
|
780
|
+
or _stat_identity(after_path) != _stat_identity(opened)
|
|
781
|
+
):
|
|
782
|
+
return None
|
|
783
|
+
return b"".join(chunks)
|
|
784
|
+
except OSError:
|
|
785
|
+
return None
|
|
786
|
+
finally:
|
|
787
|
+
os.close(fd)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _read_regular_json(path: Path) -> dict[str, object] | None:
|
|
791
|
+
"""Read a small package descriptor without accepting links or swaps."""
|
|
792
|
+
raw = _read_stable_regular_bytes(path, max_bytes=_MAX_PACKAGE_JSON_BYTES)
|
|
793
|
+
if raw is None:
|
|
794
|
+
return None
|
|
795
|
+
try:
|
|
796
|
+
value = json.loads(raw.decode("utf-8"))
|
|
797
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
798
|
+
return None
|
|
799
|
+
return value if isinstance(value, dict) else None
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _regular_descendant(root: Path, path: Path) -> bool:
|
|
803
|
+
try:
|
|
804
|
+
relative = path.relative_to(root)
|
|
805
|
+
except ValueError:
|
|
806
|
+
return False
|
|
807
|
+
current = root
|
|
808
|
+
try:
|
|
809
|
+
if current.is_symlink():
|
|
810
|
+
return False
|
|
811
|
+
for part in relative.parts:
|
|
812
|
+
current /= part
|
|
813
|
+
if current.is_symlink():
|
|
814
|
+
return False
|
|
815
|
+
return path.is_file() and stat.S_ISREG(path.stat().st_mode)
|
|
816
|
+
except OSError:
|
|
817
|
+
return False
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _sha256_file(path: Path) -> str | None:
|
|
821
|
+
raw = _read_stable_regular_bytes(path, max_bytes=_MAX_VERIFIED_PACKAGE_FILE_BYTES)
|
|
822
|
+
return hashlib.sha256(raw).hexdigest() if raw is not None else None
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _verified_package_hashes(
|
|
826
|
+
root: Path,
|
|
827
|
+
package_dir: Path,
|
|
828
|
+
*,
|
|
829
|
+
expected_manifest_sha256: str,
|
|
830
|
+
) -> tuple[tuple[Path, str], ...] | None:
|
|
831
|
+
"""Return only externally anchored package-file hashes."""
|
|
832
|
+
manifest_path = package_dir / "package-files.json"
|
|
833
|
+
if not _regular_descendant(root, manifest_path):
|
|
834
|
+
return None
|
|
835
|
+
if not re.fullmatch(r"[a-f0-9]{64}", expected_manifest_sha256):
|
|
836
|
+
return None
|
|
837
|
+
manifest_bytes = _read_stable_regular_bytes(
|
|
838
|
+
manifest_path,
|
|
839
|
+
max_bytes=_MAX_PACKAGE_JSON_BYTES,
|
|
840
|
+
)
|
|
841
|
+
if manifest_bytes is None or hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest_sha256:
|
|
842
|
+
return None
|
|
843
|
+
try:
|
|
844
|
+
manifest = json.loads(manifest_bytes.decode("utf-8"))
|
|
845
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
846
|
+
return None
|
|
847
|
+
entries = manifest.get("files") if isinstance(manifest, dict) else None
|
|
848
|
+
if not isinstance(entries, list):
|
|
849
|
+
return None
|
|
850
|
+
expected: dict[str, str] = {}
|
|
851
|
+
for entry in entries:
|
|
852
|
+
if not isinstance(entry, dict):
|
|
853
|
+
return None
|
|
854
|
+
path = entry.get("path")
|
|
855
|
+
digest = entry.get("sha256")
|
|
856
|
+
if isinstance(path, str) and isinstance(digest, str) and re.fullmatch(r"[a-f0-9]{64}", digest):
|
|
857
|
+
expected[path] = digest
|
|
858
|
+
required = {
|
|
859
|
+
"package.json",
|
|
860
|
+
str(RECEIPT_CLI_RELATIVE_PATH),
|
|
861
|
+
str(RECEIPT_LAUNCHER_RELATIVE_PATH),
|
|
862
|
+
}
|
|
863
|
+
if not required <= expected.keys():
|
|
864
|
+
return None
|
|
865
|
+
for relative in required:
|
|
866
|
+
candidate = package_dir / relative
|
|
867
|
+
if not _regular_descendant(root, candidate) or _sha256_file(candidate) != expected[relative]:
|
|
868
|
+
return None
|
|
869
|
+
return (
|
|
870
|
+
(manifest_path, expected_manifest_sha256),
|
|
871
|
+
*((package_dir / relative, expected[relative]) for relative in sorted(required)),
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _verified_package_cli(
|
|
876
|
+
root: Path,
|
|
877
|
+
package_dir: Path,
|
|
878
|
+
*,
|
|
879
|
+
expected_manifest_sha256: str,
|
|
880
|
+
) -> Path | None:
|
|
881
|
+
"""Compatibility helper returning the CLI after complete pin verification."""
|
|
882
|
+
protected_hashes = _verified_package_hashes(
|
|
883
|
+
root,
|
|
884
|
+
package_dir,
|
|
885
|
+
expected_manifest_sha256=expected_manifest_sha256,
|
|
886
|
+
)
|
|
887
|
+
return package_dir / RECEIPT_CLI_RELATIVE_PATH if protected_hashes is not None else None
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _installed_context_guard_package(project_root: Path) -> Path | None:
|
|
891
|
+
"""Prove this policy is inside the canonical npm package layout."""
|
|
892
|
+
policy_path = Path(__file__).absolute()
|
|
893
|
+
suffix = ("plugins", "context-guard", "bin", "bash_reference_policy.py")
|
|
894
|
+
if tuple(policy_path.parts[-4:]) != suffix:
|
|
895
|
+
return None
|
|
896
|
+
package_root = policy_path.parents[3]
|
|
897
|
+
if (
|
|
898
|
+
package_root.name != "context-guard"
|
|
899
|
+
or package_root.parent.name != "@ictechgy"
|
|
900
|
+
or package_root.parent.parent.name != "node_modules"
|
|
901
|
+
or not _regular_descendant(project_root, policy_path)
|
|
902
|
+
):
|
|
903
|
+
return None
|
|
904
|
+
return package_root
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def discover_adapter(root: Path) -> tuple[ReceiptAdapter | None, str]:
|
|
908
|
+
"""Accept only a pinned, local npm package rooted in this exact project.
|
|
909
|
+
|
|
910
|
+
PATH, global npm folders, arbitrary checkout paths, symlinks, and unpinned
|
|
911
|
+
source workspaces are intentionally not discovery candidates.
|
|
912
|
+
"""
|
|
913
|
+
try:
|
|
914
|
+
if root.is_symlink():
|
|
915
|
+
return None, "receipt_root_symlink_rejected"
|
|
916
|
+
root = root.absolute()
|
|
917
|
+
except OSError:
|
|
918
|
+
return None, "receipt_root_unavailable"
|
|
919
|
+
context_guard_root = _installed_context_guard_package(root)
|
|
920
|
+
if context_guard_root is None:
|
|
921
|
+
return None, "receipt_source_or_plugin_only"
|
|
922
|
+
context_guard_package = _read_regular_json(context_guard_root / "package.json")
|
|
923
|
+
if not context_guard_package or context_guard_package.get("name") != ROOT_PACKAGE_NAME:
|
|
924
|
+
return None, "receipt_context_guard_package_unverified"
|
|
925
|
+
dependencies = context_guard_package.get("dependencies")
|
|
926
|
+
requested = dependencies.get(RECEIPT_PACKAGE_NAME) if isinstance(dependencies, dict) else None
|
|
927
|
+
if not isinstance(requested, str) or not _EXACT_NPM_VERSION_RE.fullmatch(requested):
|
|
928
|
+
return None, "receipt_dependency_unpinned"
|
|
929
|
+
nested = context_guard_root / "node_modules" / "@ictechgy" / "context-guard-receipt"
|
|
930
|
+
hoisted = root / "node_modules" / "@ictechgy" / "context-guard-receipt"
|
|
931
|
+
try:
|
|
932
|
+
(nested / "package.json").lstat()
|
|
933
|
+
except FileNotFoundError:
|
|
934
|
+
package_dir = hoisted
|
|
935
|
+
except OSError:
|
|
936
|
+
return None, "receipt_npm_package_unavailable"
|
|
937
|
+
else:
|
|
938
|
+
package_dir = nested
|
|
939
|
+
package_json = package_dir / "package.json"
|
|
940
|
+
if not _regular_descendant(root, package_json):
|
|
941
|
+
return None, "receipt_npm_package_unavailable"
|
|
942
|
+
receipt_package = _read_regular_json(package_json)
|
|
943
|
+
if not receipt_package or receipt_package.get("name") != RECEIPT_PACKAGE_NAME:
|
|
944
|
+
return None, "receipt_npm_package_invalid"
|
|
945
|
+
if receipt_package.get("version") != requested:
|
|
946
|
+
return None, "receipt_npm_package_version_mismatch"
|
|
947
|
+
manifest_pin = EXPECTED_RECEIPT_PACKAGE_FILES_SHA256_BY_VERSION.get(requested, "")
|
|
948
|
+
if not re.fullmatch(r"[a-f0-9]{64}", manifest_pin):
|
|
949
|
+
return None, "receipt_package_manifest_pin_unavailable"
|
|
950
|
+
protected_hashes = _verified_package_hashes(
|
|
951
|
+
root,
|
|
952
|
+
package_dir,
|
|
953
|
+
expected_manifest_sha256=manifest_pin,
|
|
954
|
+
)
|
|
955
|
+
if protected_hashes is None:
|
|
956
|
+
return None, "receipt_npm_package_integrity_invalid"
|
|
957
|
+
node_interpreter = _trusted_node_interpreter(root)
|
|
958
|
+
if node_interpreter is None:
|
|
959
|
+
return None, "receipt_node_interpreter_unavailable"
|
|
960
|
+
node_path, node_identity = node_interpreter
|
|
961
|
+
cli_path = package_dir / RECEIPT_CLI_RELATIVE_PATH
|
|
962
|
+
return NpmReceiptCliAdapter(
|
|
963
|
+
cli_path,
|
|
964
|
+
node_path=node_path,
|
|
965
|
+
node_identity=node_identity,
|
|
966
|
+
protected_hashes=protected_hashes,
|
|
967
|
+
), "receipt_adapter_available"
|