@ictechgy/context-guard 0.4.11 → 0.4.13

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.
@@ -20,6 +20,7 @@ import signal
20
20
  import stat
21
21
  import subprocess
22
22
  import sys
23
+ import tempfile
23
24
  import threading
24
25
  import time
25
26
  import types
@@ -398,23 +399,75 @@ def store_sanitized_artifact_receipt(
398
399
  return receipt
399
400
 
400
401
 
401
- def capture_sanitized_artifact_line(
402
- *,
403
- capture_enabled: bool,
404
- sanitized_line: str,
405
- artifact_lines: list[str],
406
- capture_bytes: int,
407
- capture_overflow: bool,
408
- max_bytes: int,
409
- ) -> tuple[int, bool]:
410
- if not capture_enabled or capture_overflow:
411
- return capture_bytes, capture_overflow
412
- source_bytes = len(sanitized_line.encode("utf-8", errors="replace"))
413
- if capture_bytes + source_bytes <= max_bytes:
414
- artifact_lines.append(sanitized_line)
415
- return capture_bytes + source_bytes, False
416
- artifact_lines.clear()
417
- return capture_bytes, True
402
+ class SanitizedArtifactCapture:
403
+ def __init__(self, *, enabled: bool, max_bytes: int) -> None:
404
+ self.enabled = enabled
405
+ self.max_bytes = max_bytes
406
+ self.bytes = 0
407
+ self.overflow = False
408
+ self.error: str | None = None
409
+ self._file: BinaryIO | None = None
410
+
411
+ def _ensure_file(self) -> BinaryIO | None:
412
+ if self._file is not None:
413
+ return self._file
414
+ try:
415
+ self._file = tempfile.TemporaryFile("w+b")
416
+ except OSError as exc:
417
+ self._record_error(exc)
418
+ return None
419
+ return self._file
420
+
421
+ def _record_error(self, exc: OSError) -> None:
422
+ if self.error is None:
423
+ self.error = f"{exc.__class__.__name__}: {exc}"
424
+
425
+ def add(self, sanitized_line: str) -> None:
426
+ if not self.enabled or self.overflow or self.error:
427
+ return
428
+ encoded = sanitized_line.encode("utf-8", errors="replace")
429
+ source_bytes = len(encoded)
430
+ if self.bytes + source_bytes > self.max_bytes:
431
+ self.overflow = True
432
+ self.close()
433
+ return
434
+ target = self._ensure_file()
435
+ if target is None:
436
+ return
437
+ try:
438
+ target.write(encoded)
439
+ except OSError as exc:
440
+ self._record_error(exc)
441
+ self.close()
442
+ return
443
+ self.bytes += source_bytes
444
+
445
+ def text(self) -> str:
446
+ if self._file is None:
447
+ return ""
448
+ try:
449
+ self._file.flush()
450
+ self._file.seek(0)
451
+ return self._file.read().decode("utf-8", errors="replace")
452
+ except OSError as exc:
453
+ self._record_error(exc)
454
+ self.close()
455
+ return ""
456
+
457
+ def close(self) -> None:
458
+ target = self._file
459
+ self._file = None
460
+ if target is not None:
461
+ try:
462
+ target.close()
463
+ except OSError as exc:
464
+ self._record_error(exc)
465
+
466
+ def __enter__(self) -> "SanitizedArtifactCapture":
467
+ return self
468
+
469
+ def __exit__(self, *exc: object) -> None:
470
+ self.close()
418
471
 
419
472
 
420
473
  def unique_keep_order(lines: Iterable[str]) -> list[str]:
@@ -1512,11 +1565,10 @@ def main() -> int:
1512
1565
  runner_summary = RunnerFailureSummary(args.runner_summary_items, show_paths=args.show_paths)
1513
1566
  duplicate_tracker = DuplicateLineTracker()
1514
1567
  redacted_lines = 0
1515
- artifact_lines: list[str] = []
1516
- artifact_capture_bytes = 0
1517
- artifact_capture_overflow = False
1568
+ artifact_capture = SanitizedArtifactCapture(enabled=args.artifact_receipt, max_bytes=args.artifact_max_bytes)
1518
1569
 
1519
1570
  if proc.stdout is None:
1571
+ artifact_capture.close()
1520
1572
  print("trim_command_output.py: subprocess produced no stdout pipe", file=sys.stderr)
1521
1573
  return 1
1522
1574
  command_stream = TimedCommandStream(
@@ -1532,14 +1584,7 @@ def main() -> int:
1532
1584
  visible_source, redacted = line_sanitizer.sanitize(line) # type: ignore[attr-defined]
1533
1585
  if redacted:
1534
1586
  redacted_lines += 1
1535
- artifact_capture_bytes, artifact_capture_overflow = capture_sanitized_artifact_line(
1536
- capture_enabled=args.artifact_receipt,
1537
- sanitized_line=visible_source,
1538
- artifact_lines=artifact_lines,
1539
- capture_bytes=artifact_capture_bytes,
1540
- capture_overflow=artifact_capture_overflow,
1541
- max_bytes=args.artifact_max_bytes,
1542
- )
1587
+ artifact_capture.add(visible_source)
1543
1588
  visible_line, line_capped = cap_line(visible_source, args.max_line_chars)
1544
1589
  any_line_capped = any_line_capped or line_capped
1545
1590
  visible_chars += len(visible_line)
@@ -1562,14 +1607,7 @@ def main() -> int:
1562
1607
  visible_source, redacted = line_sanitizer.sanitize(line) # type: ignore[attr-defined]
1563
1608
  if redacted:
1564
1609
  redacted_lines += 1
1565
- artifact_capture_bytes, artifact_capture_overflow = capture_sanitized_artifact_line(
1566
- capture_enabled=args.artifact_receipt,
1567
- sanitized_line=visible_source,
1568
- artifact_lines=artifact_lines,
1569
- capture_bytes=artifact_capture_bytes,
1570
- capture_overflow=artifact_capture_overflow,
1571
- max_bytes=args.artifact_max_bytes,
1572
- )
1610
+ artifact_capture.add(visible_source)
1573
1611
  visible_line, line_capped = cap_line(visible_source, args.max_line_chars)
1574
1612
  any_line_capped = any_line_capped or line_capped
1575
1613
  visible_chars += len(visible_line)
@@ -1602,32 +1640,49 @@ def main() -> int:
1602
1640
  duplicate_line_groups=duplicate_tracker.as_list(),
1603
1641
  )
1604
1642
  if args.artifact_receipt:
1605
- if artifact_capture_overflow:
1643
+ if artifact_capture.overflow:
1606
1644
  payload["artifact_receipt"] = {
1607
1645
  "stored": False,
1608
1646
  "error": "sanitized_output_exceeds_artifact_max_bytes",
1609
1647
  "max_bytes": args.artifact_max_bytes,
1610
1648
  "exact_reexpand": {"available": False, "reason": "artifact size cap exceeded"},
1611
1649
  }
1650
+ elif artifact_capture.error:
1651
+ payload["artifact_receipt"] = {
1652
+ "stored": False,
1653
+ "error": "artifact_receipt_capture_unavailable",
1654
+ "reason": artifact_capture.error,
1655
+ "exact_reexpand": {"available": False, "reason": "artifact receipt capture unavailable"},
1656
+ }
1612
1657
  else:
1613
- try:
1614
- payload["artifact_receipt"] = store_sanitized_artifact_receipt(
1615
- sanitized_text="".join(artifact_lines),
1616
- command=command,
1617
- args=args,
1618
- line_sanitizer=line_sanitizer,
1619
- redacted_lines=redacted_lines,
1620
- )
1621
- except UnsafeAdjacentModuleError as exc:
1622
- print(f"context-guard-kit: unsafe adjacent helper: {exc}", file=sys.stderr)
1623
- return 2
1624
- except Exception as exc:
1658
+ sanitized_artifact_text = artifact_capture.text()
1659
+ if artifact_capture.error:
1625
1660
  payload["artifact_receipt"] = {
1626
1661
  "stored": False,
1627
- "error": "artifact_receipt_unavailable",
1628
- "reason": f"{exc.__class__.__name__}: {exc}",
1629
- "exact_reexpand": {"available": False, "reason": "artifact receipt unavailable"},
1662
+ "error": "artifact_receipt_capture_unavailable",
1663
+ "reason": artifact_capture.error,
1664
+ "exact_reexpand": {"available": False, "reason": "artifact receipt capture unavailable"},
1630
1665
  }
1666
+ else:
1667
+ try:
1668
+ payload["artifact_receipt"] = store_sanitized_artifact_receipt(
1669
+ sanitized_text=sanitized_artifact_text,
1670
+ command=command,
1671
+ args=args,
1672
+ line_sanitizer=line_sanitizer,
1673
+ redacted_lines=redacted_lines,
1674
+ )
1675
+ except UnsafeAdjacentModuleError as exc:
1676
+ artifact_capture.close()
1677
+ print(f"context-guard-kit: unsafe adjacent helper: {exc}", file=sys.stderr)
1678
+ return 2
1679
+ except Exception as exc:
1680
+ payload["artifact_receipt"] = {
1681
+ "stored": False,
1682
+ "error": "artifact_receipt_unavailable",
1683
+ "reason": f"{exc.__class__.__name__}: {exc}",
1684
+ "exact_reexpand": {"available": False, "reason": "artifact receipt unavailable"},
1685
+ }
1631
1686
  artifact_receipt = payload.get("artifact_receipt")
1632
1687
  if isinstance(artifact_receipt, dict) and artifact_receipt.get("stored"):
1633
1688
  next_queries = payload.setdefault("next_queries", [])
@@ -1642,6 +1697,7 @@ def main() -> int:
1642
1697
  sys.stdout.write(render_digest_json(payload, args.max_chars))
1643
1698
  else:
1644
1699
  sys.stdout.write(render_digest_markdown(payload, args.max_chars))
1700
+ artifact_capture.close()
1645
1701
  return rc
1646
1702
 
1647
1703
  if total <= args.max_lines and visible_chars <= args.max_chars and not any_line_capped:
@@ -1689,6 +1745,7 @@ def main() -> int:
1689
1745
  output += "[context-guard-kit] final summary was capped by --max-chars.\n"
1690
1746
  sys.stdout.write(output)
1691
1747
 
1748
+ artifact_capture.close()
1692
1749
  return rc
1693
1750
 
1694
1751
 
@@ -0,0 +1,123 @@
1
+ """Trusted literal loader for the ContextGuard command manifest.
2
+
3
+ The command manifest is intentionally a literal-only Python data file so release
4
+ gates and runtime dispatchers can inspect it without executing manifest code.
5
+ This helper centralizes the bounded no-follow read and AST-literal parsing logic
6
+ used by the runtime dispatcher, release gates, and tests.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import os
12
+ from pathlib import Path
13
+ import stat
14
+ from typing import Any, Iterable, Mapping
15
+
16
+ MAX_COMMAND_MANIFEST_BYTES = 128 * 1024
17
+
18
+ COMMAND_MANIFEST_LITERAL_NAMES = frozenset(
19
+ {
20
+ "IMPLEMENTATION_PAIRS",
21
+ "HELPER_PAIRS",
22
+ "NPM_BINS",
23
+ "NPM_BIN_PATHS",
24
+ "DISPATCHER_SUBCOMMANDS",
25
+ "LEGACY_WRAPPERS",
26
+ "ENTRYPOINT_SMOKE_CASES",
27
+ "PLUGIN_ENTRYPOINTS",
28
+ "DISPATCHER_SMOKE_CASES",
29
+ "EXPECTED_COMMAND_PACK_FILES",
30
+ }
31
+ )
32
+
33
+
34
+ def manifest_open_flags() -> int | None:
35
+ if not hasattr(os, "O_NOFOLLOW"):
36
+ return None
37
+ flags = os.O_RDONLY | os.O_NOFOLLOW
38
+ if hasattr(os, "O_CLOEXEC"):
39
+ flags |= os.O_CLOEXEC
40
+ if hasattr(os, "O_NONBLOCK"):
41
+ flags |= os.O_NONBLOCK
42
+ if hasattr(os, "O_NOCTTY"):
43
+ flags |= os.O_NOCTTY
44
+ return flags
45
+
46
+
47
+ def read_manifest_source(path: Path, *, max_bytes: int = MAX_COMMAND_MANIFEST_BYTES) -> str | None:
48
+ flags = manifest_open_flags()
49
+ if flags is None:
50
+ return None
51
+ fd = -1
52
+ try:
53
+ fd = os.open(path, flags)
54
+ st = os.fstat(fd)
55
+ if not stat.S_ISREG(st.st_mode) or st.st_size > max_bytes:
56
+ return None
57
+ chunks: list[bytes] = []
58
+ total = 0
59
+ while True:
60
+ chunk = os.read(fd, min(64 * 1024, max_bytes + 1 - total))
61
+ if not chunk:
62
+ break
63
+ chunks.append(chunk)
64
+ total += len(chunk)
65
+ if total > max_bytes:
66
+ return None
67
+ return b"".join(chunks).decode("utf-8")
68
+ except (OSError, UnicodeDecodeError):
69
+ return None
70
+ finally:
71
+ if fd >= 0:
72
+ try:
73
+ os.close(fd)
74
+ except OSError:
75
+ pass
76
+
77
+
78
+ def literal_command_manifest_from_source(
79
+ source: str,
80
+ *,
81
+ allowed_names: Iterable[str] = COMMAND_MANIFEST_LITERAL_NAMES,
82
+ ) -> dict[str, Any]:
83
+ try:
84
+ tree = ast.parse(source)
85
+ except SyntaxError as exc:
86
+ raise ValueError(f"invalid Python manifest syntax: line {exc.lineno}: {exc.msg}") from exc
87
+ allowed = set(allowed_names)
88
+ values: dict[str, Any] = {}
89
+ for node in tree.body:
90
+ if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
91
+ continue
92
+ target: str | None = None
93
+ value: ast.expr | None = None
94
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
95
+ target = node.target.id
96
+ value = node.value
97
+ elif isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
98
+ target = node.targets[0].id
99
+ value = node.value
100
+ if target is None:
101
+ raise ValueError(f"unsupported executable manifest statement: {type(node).__name__}")
102
+ if target not in allowed or value is None:
103
+ raise ValueError(f"unsupported manifest assignment: {target}")
104
+ try:
105
+ values[target] = ast.literal_eval(value)
106
+ except (SyntaxError, ValueError) as exc:
107
+ raise ValueError(f"manifest assignment must be a literal: {target}") from exc
108
+ return values
109
+
110
+
111
+ def command_manifest_namespace(values: Mapping[str, Any], *, required: Iterable[str] = ()) -> type:
112
+ missing = sorted(set(required) - set(values))
113
+ if missing:
114
+ raise ValueError(f"trusted command manifest missing required literals: {', '.join(missing)}")
115
+ return type("CommandManifest", (), dict(values))
116
+
117
+
118
+ def load_command_manifest(path: Path, *, required: Iterable[str] = ()) -> type:
119
+ source = read_manifest_source(path)
120
+ if source is None:
121
+ raise ValueError(f"could not load trusted command manifest source: {path}")
122
+ values = literal_command_manifest_from_source(source)
123
+ return command_manifest_namespace(values, required=required)
@@ -28,7 +28,9 @@ IMPLEMENTATION_PAIRS = (('context_guard_cli.py', 'context-guard'),
28
28
  ('trim_command_output.py', 'context-guard-trim-output'))
29
29
 
30
30
  HELPER_PAIRS = (('hook_secret_patterns.py', 'lib/hook_secret_patterns.py'),
31
- ('context_guard_commands.py', 'lib/context_guard_commands.py'))
31
+ ('context_guard_commands.py', 'lib/context_guard_commands.py'),
32
+ ('context_guard_command_manifest_loader.py',
33
+ 'lib/context_guard_command_manifest_loader.py'))
32
34
 
33
35
  NPM_BINS = ('context-guard',
34
36
  'context-guard-cost',
@@ -226,5 +228,6 @@ EXPECTED_COMMAND_PACK_FILES = ('plugins/context-guard/bin/claude-read-symbol',
226
228
  'plugins/context-guard/bin/context-guard-statusline-merged',
227
229
  'plugins/context-guard/bin/context-guard-tool-prune',
228
230
  'plugins/context-guard/bin/context-guard-trim-output',
231
+ 'plugins/context-guard/lib/context_guard_command_manifest_loader.py',
229
232
  'plugins/context-guard/lib/context_guard_commands.py',
230
233
  'plugins/context-guard/lib/hook_secret_patterns.py')