@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.
@@ -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"