@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.
@@ -10,11 +10,13 @@ import argparse
10
10
  import codecs
11
11
  import collections
12
12
  import hashlib
13
+ import io
13
14
  import json
14
15
  import os
15
16
  from pathlib import Path, PurePosixPath
16
17
  import queue
17
18
  import re
19
+ import secrets
18
20
  import shlex
19
21
  import signal
20
22
  import stat
@@ -37,10 +39,24 @@ MAX_TIMEOUT_SECONDS = 86_400
37
39
  TIMEOUT_EXIT_CODE = 124
38
40
  DEFAULT_ARTIFACT_RECEIPT_MAX_BYTES = 10_000_000
39
41
  MAX_ARTIFACT_RECEIPT_MAX_BYTES = 100_000_000
42
+ # Frozen merged-capture v1 boundary. Keep independent from the legacy
43
+ # --artifact-max-bytes option so that mode-specific flags cannot drift it.
44
+ BASH_REFERENCE_MAX_SANITIZED_BYTES = 10_000_000
45
+ BASH_REFERENCE_MIN_SANITIZED_BYTES = 8_192
40
46
  COMMAND_READ_CHUNK_BYTES = 64 * 1024
41
47
  COMMAND_MAX_UNTERMINATED_LINE_CHARS = 4_096
42
48
  RAW_TRUNCATION_REDACTION_HOLDBACK_CHARS = 1_024
43
49
  MAX_DYNAMIC_SIBLING_MODULE_BYTES = 2_000_000
50
+ MAX_HOOK_INPUT_BYTES = 16 * 1024 * 1024
51
+ BASH_REFERENCE_QUERY_MAX_PAYLOAD_BYTES = 20_000
52
+ BASH_REFERENCE_HANDLE_RE = re.compile(r"^cgr1p_[A-Za-z0-9_-]{43}$", re.ASCII)
53
+ BASH_REFERENCE_COMMAND_PREFIX = "./node_modules/.bin/context-guard reference "
54
+ BASH_REFERENCE_BUDGET_PROBE = "cgr1p_" + "A" * 43
55
+ DIGEST_CAP_MARKER = "[context-guard-kit] digest capped by --max-chars.\n"
56
+
57
+
58
+ class HookInputError(ValueError):
59
+ """The PostToolUse payload is malformed or violates the bounded schema."""
44
60
 
45
61
 
46
62
  def bounded_int(value: object, default: int, minimum: int, maximum: int) -> int:
@@ -433,32 +449,63 @@ def store_sanitized_artifact_receipt(
433
449
 
434
450
 
435
451
  class SanitizedArtifactCapture:
436
- def __init__(self, *, enabled: bool, max_bytes: int) -> None:
452
+ def __init__(self, *, enabled: bool, max_bytes: int, reference_spool: bool = False) -> None:
437
453
  self.enabled = enabled
438
454
  self.max_bytes = max_bytes
455
+ self.reference_spool = reference_spool
439
456
  self.bytes = 0
440
457
  self.overflow = False
441
458
  self.error: str | None = None
442
459
  self._file: BinaryIO | None = None
460
+ if self.enabled and self.reference_spool:
461
+ self._ensure_file()
443
462
 
444
463
  def _ensure_file(self) -> BinaryIO | None:
445
464
  if self._file is not None:
446
465
  return self._file
447
466
  try:
448
- self._file = tempfile.TemporaryFile("w+b")
467
+ self._file = tempfile.TemporaryFile("w+b", buffering=0)
468
+ if self.reference_spool:
469
+ os.fchmod(self._file.fileno(), 0o600)
470
+ status = os.fstat(self._file.fileno())
471
+ if (
472
+ not stat.S_ISREG(status.st_mode)
473
+ or status.st_uid != os.geteuid()
474
+ or stat.S_IMODE(status.st_mode) != 0o600
475
+ or status.st_nlink != 0
476
+ ):
477
+ raise OSError("anonymous capture invariant unavailable")
449
478
  except OSError as exc:
479
+ if self._file is not None:
480
+ try:
481
+ self._file.close()
482
+ except OSError:
483
+ pass
484
+ self._file = None
450
485
  self._record_error(exc)
451
486
  return None
452
487
  return self._file
453
488
 
454
489
  def _record_error(self, exc: OSError) -> None:
455
490
  if self.error is None:
456
- self.error = f"{exc.__class__.__name__}: {exc}"
491
+ self.error = (
492
+ "capture_io_failed"
493
+ if self.reference_spool
494
+ else f"{exc.__class__.__name__}: {exc}"
495
+ )
457
496
 
458
497
  def add(self, sanitized_line: str) -> None:
459
498
  if not self.enabled or self.overflow or self.error:
460
499
  return
461
- encoded = sanitized_line.encode("utf-8", errors="replace")
500
+ try:
501
+ encoded = sanitized_line.encode(
502
+ "utf-8",
503
+ errors="strict" if self.reference_spool else "replace",
504
+ )
505
+ except UnicodeEncodeError:
506
+ self.error = "capture_encoding_failed"
507
+ self.close()
508
+ return
462
509
  source_bytes = len(encoded)
463
510
  if self.bytes + source_bytes > self.max_bytes:
464
511
  self.overflow = True
@@ -476,6 +523,8 @@ class SanitizedArtifactCapture:
476
523
  self.bytes += source_bytes
477
524
 
478
525
  def text(self) -> str:
526
+ if self.reference_spool:
527
+ return ""
479
528
  if self._file is None:
480
529
  return ""
481
530
  try:
@@ -487,6 +536,12 @@ class SanitizedArtifactCapture:
487
536
  self.close()
488
537
  return ""
489
538
 
539
+ def descriptor(self) -> int:
540
+ """Return the live anonymous reference descriptor without a pathname."""
541
+ if not self.reference_spool or self._file is None or self.error:
542
+ raise OSError("receipt capture descriptor unavailable")
543
+ return self._file.fileno()
544
+
490
545
  def close(self) -> None:
491
546
  target = self._file
492
547
  self._file = None
@@ -503,6 +558,49 @@ class SanitizedArtifactCapture:
503
558
  self.close()
504
559
 
505
560
 
561
+ def abort_bash_reference_broker(broker: object | None) -> None:
562
+ if broker is None:
563
+ return
564
+ try:
565
+ abort = getattr(broker, "abort", None)
566
+ if callable(abort):
567
+ abort()
568
+ except Exception:
569
+ pass
570
+ finally:
571
+ try:
572
+ close = getattr(broker, "close", None)
573
+ if callable(close):
574
+ close()
575
+ except Exception:
576
+ pass
577
+
578
+
579
+ def commit_bash_reference_broker(broker: object | None) -> tuple[str | None, str]:
580
+ if broker is None:
581
+ return None, "receipt_broker_unavailable"
582
+ try:
583
+ result = broker.commit() # type: ignore[attr-defined]
584
+ reference = getattr(result, "reference", None)
585
+ if (
586
+ getattr(result, "status", None) != "success"
587
+ or getattr(result, "actionable", False) is not True
588
+ or not isinstance(reference, str)
589
+ or BASH_REFERENCE_HANDLE_RE.fullmatch(reference) is None
590
+ ):
591
+ return None, str(
592
+ getattr(result, "reason_code", "receipt_broker_unavailable")
593
+ )
594
+ return reference, "reference_published"
595
+ except Exception:
596
+ return None, "receipt_broker_unavailable"
597
+ finally:
598
+ try:
599
+ broker.close() # type: ignore[attr-defined]
600
+ except Exception:
601
+ pass
602
+
603
+
506
604
  def unique_keep_order(lines: Iterable[str]) -> list[str]:
507
605
  seen: set[str] = set()
508
606
  out: list[str] = []
@@ -532,6 +630,217 @@ def cap_text(text: str, max_chars: int) -> tuple[str, bool]:
532
630
  return text[:keep].rstrip() + marker, True
533
631
 
534
632
 
633
+ def trim_captured_output(
634
+ text: str,
635
+ *,
636
+ exit_code: int | None = 0,
637
+ max_lines: int = 220,
638
+ max_chars: int = 20_000,
639
+ max_line_chars: int = 4_000,
640
+ head_lines: int = 40,
641
+ tail_lines: int = 80,
642
+ error_lines: int = 120,
643
+ runner_summary_items: int = 12,
644
+ ) -> str:
645
+ """Sanitize and budget already-captured command output without rerunning it."""
646
+ max_lines = bounded_int(max_lines, 220, 1, MAX_LINES_LIMIT)
647
+ max_chars = bounded_int(max_chars, 20_000, 1, MAX_CHARS_LIMIT)
648
+ max_line_chars = bounded_int(max_line_chars, 4_000, 1, MAX_LINE_CHARS_LIMIT)
649
+ head_lines = bounded_int(head_lines, 40, 0, MAX_SECTION_LINES_LIMIT)
650
+ tail_lines = bounded_int(tail_lines, 80, 0, MAX_SECTION_LINES_LIMIT)
651
+ error_lines = bounded_int(error_lines, 120, 0, MAX_SECTION_LINES_LIMIT)
652
+ runner_summary_items = bounded_int(
653
+ runner_summary_items,
654
+ 12,
655
+ 0,
656
+ MAX_RUNNER_SUMMARY_ITEMS_LIMIT,
657
+ )
658
+
659
+ line_sanitizer = load_line_sanitizer(False, context="unknown_text")
660
+ all_lines: list[str] = []
661
+ head: list[str] = []
662
+ tail: collections.deque[str] = collections.deque(maxlen=tail_lines)
663
+ matched_errors: list[str] = []
664
+ visible_chars = 0
665
+ redacted_lines = 0
666
+ any_line_capped = False
667
+ runner_summary = RunnerFailureSummary(runner_summary_items, show_paths=False)
668
+ total = 0
669
+ with io.StringIO(text, newline="") as lines:
670
+ for line_number, line in enumerate(lines, start=1):
671
+ total = line_number
672
+ visible_source, redacted = line_sanitizer.sanitize(line) # type: ignore[attr-defined]
673
+ path_safe_source = anonymize_absolute_paths(visible_source)
674
+ redacted = redacted or path_safe_source != visible_source
675
+ visible_source = path_safe_source
676
+ if redacted:
677
+ redacted_lines += 1
678
+ visible_line, line_capped = cap_line(visible_source, max_line_chars)
679
+ any_line_capped = any_line_capped or line_capped
680
+ visible_chars += len(visible_line)
681
+ if line_number <= head_lines:
682
+ head.append(visible_line)
683
+ tail.append(visible_line)
684
+ if ERROR_RE.search(visible_line) and len(matched_errors) < error_lines:
685
+ matched_errors.append(visible_line)
686
+ runner_summary.feed(line)
687
+ if line_number <= max_lines:
688
+ all_lines.append(visible_line)
689
+ if total <= max_lines and visible_chars <= max_chars and not any_line_capped:
690
+ return "".join(all_lines)
691
+
692
+ head_budget = min(head_lines, max(1, max_lines // 4))
693
+ tail_budget = min(tail_lines, max(1, max_lines // 3))
694
+ head_out = head[:head_budget]
695
+ tail_out = [line for line in list(tail)[-tail_budget:] if line not in set(head_out)]
696
+ remaining = max(0, max_lines - len(head_out) - len(tail_out))
697
+ error_out = unique_keep_order(matched_errors)[:remaining]
698
+
699
+ parts = [
700
+ (
701
+ f"[context-guard-kit] output trimmed: {total} lines/{len(text)} chars "
702
+ f"-> budget about {max_lines} log lines/{max_chars} chars\n"
703
+ )
704
+ ]
705
+ if exit_code is not None:
706
+ parts.append(f"[context-guard-kit] command exit_code={exit_code}\n")
707
+ if any_line_capped:
708
+ parts.append(
709
+ f"[context-guard-kit] one or more lines were capped at {max_line_chars} chars\n"
710
+ )
711
+ if redacted_lines:
712
+ parts.append(f"[context-guard-kit] redacted_lines={redacted_lines}\n")
713
+
714
+ summary_budget = max(0, min(max_lines, max(4, max_lines // 3)))
715
+ runner_lines = (
716
+ runner_summary.as_lines(max_line_chars, summary_budget)
717
+ if exit_code not in {None, 0}
718
+ else []
719
+ )
720
+ remaining_log_budget = max(0, max_lines - len("".join(runner_lines).splitlines()))
721
+ parts.extend(runner_lines)
722
+ parts.append("\n--- head ---\n")
723
+ if remaining_log_budget > 0:
724
+ head_out = head_out[:remaining_log_budget]
725
+ parts.extend(head_out)
726
+ remaining_log_budget -= len(head_out)
727
+ if error_out:
728
+ parts.append("\n--- matched error/failure lines ---\n")
729
+ error_out = error_out[:remaining_log_budget]
730
+ parts.extend(error_out)
731
+ remaining_log_budget -= len(error_out)
732
+ parts.append("\n--- tail ---\n")
733
+ if remaining_log_budget > 0:
734
+ parts.extend(tail_out[-remaining_log_budget:])
735
+ parts.append(
736
+ "\n[context-guard-kit] rerun the command without trim only if more context is essential.\n"
737
+ )
738
+ output, capped = cap_text("".join(parts), max_chars)
739
+ if capped:
740
+ output += "[context-guard-kit] final summary was capped by --max-chars.\n"
741
+ return output
742
+
743
+
744
+ def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
745
+ result: dict[str, object] = {}
746
+ for key, value in pairs:
747
+ if key in result:
748
+ raise HookInputError("duplicate JSON key")
749
+ result[key] = value
750
+ return result
751
+
752
+
753
+ def read_post_tool_hook_payload() -> object:
754
+ raw = sys.stdin.buffer.read(MAX_HOOK_INPUT_BYTES + 1)
755
+ if len(raw) > MAX_HOOK_INPUT_BYTES:
756
+ raise HookInputError("hook input exceeds byte cap")
757
+ try:
758
+ text = raw.decode("utf-8", errors="strict")
759
+ return json.loads(text, object_pairs_hook=reject_duplicate_json_keys)
760
+ except HookInputError:
761
+ raise
762
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
763
+ raise HookInputError("hook input is not valid JSON") from exc
764
+
765
+
766
+ def validate_post_tool_bash_response(payload: object) -> dict[str, object] | None:
767
+ if not isinstance(payload, dict):
768
+ raise HookInputError("hook payload must be an object")
769
+ if payload.get("hook_event_name") != "PostToolUse" or payload.get("tool_name") != "Bash":
770
+ return None
771
+ response = payload.get("tool_response")
772
+ if not isinstance(response, dict):
773
+ raise HookInputError("Bash tool_response must be an object")
774
+ if not isinstance(response.get("stdout"), str) or not isinstance(response.get("stderr"), str):
775
+ raise HookInputError("Bash stdout and stderr must be strings")
776
+ if not isinstance(response.get("interrupted"), bool) or not isinstance(response.get("isImage"), bool):
777
+ raise HookInputError("Bash flags must be booleans")
778
+ return response
779
+
780
+
781
+ def run_post_tool_use_hook(args: argparse.Namespace) -> int:
782
+ if (
783
+ args.command
784
+ or args.show_paths
785
+ or args.digest != "off"
786
+ or args.digest_always
787
+ or args.artifact_receipt
788
+ ):
789
+ print(
790
+ "context-guard-trim-output: incompatible PostToolUse hook options",
791
+ file=sys.stderr,
792
+ )
793
+ return 2
794
+ try:
795
+ response = validate_post_tool_bash_response(read_post_tool_hook_payload())
796
+ if response is None:
797
+ return 0
798
+ # Claude Code's documented Bash replacement shape has no exit-status
799
+ # field, so do not invent success or suppress diagnostics as if rc=0.
800
+ updated = {
801
+ "stdout": trim_captured_output(
802
+ response["stdout"],
803
+ exit_code=None,
804
+ max_lines=args.max_lines,
805
+ max_chars=args.max_chars,
806
+ max_line_chars=args.max_line_chars,
807
+ head_lines=args.head_lines,
808
+ tail_lines=args.tail_lines,
809
+ error_lines=args.error_lines,
810
+ runner_summary_items=args.runner_summary_items,
811
+ ),
812
+ "stderr": trim_captured_output(
813
+ response["stderr"],
814
+ exit_code=None,
815
+ max_lines=args.max_lines,
816
+ max_chars=args.max_chars,
817
+ max_line_chars=args.max_line_chars,
818
+ head_lines=args.head_lines,
819
+ tail_lines=args.tail_lines,
820
+ error_lines=args.error_lines,
821
+ runner_summary_items=args.runner_summary_items,
822
+ ),
823
+ "interrupted": response["interrupted"],
824
+ "isImage": response["isImage"],
825
+ }
826
+ output = {
827
+ "hookSpecificOutput": {
828
+ "hookEventName": "PostToolUse",
829
+ "updatedToolOutput": updated,
830
+ }
831
+ }
832
+ sys.stdout.write(
833
+ json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
834
+ )
835
+ return 0
836
+ except HookInputError:
837
+ print("context-guard-trim-output: invalid hook input", file=sys.stderr)
838
+ return 2
839
+ except Exception:
840
+ print("context-guard-trim-output: hook trim unavailable", file=sys.stderr)
841
+ return 2
842
+
843
+
535
844
  def compact_item(
536
845
  text: str,
537
846
  limit: int = MAX_SUMMARY_ITEM_CHARS,
@@ -945,7 +1254,86 @@ def compact_markdown_artifact_receipt(payload: dict[str, object], max_chars: int
945
1254
  return ""
946
1255
 
947
1256
 
1257
+ def markdown_bash_reference_line(payload: dict[str, object]) -> str:
1258
+ """Render the sole intentionally provider-visible Receipt value, if any."""
1259
+ reference = payload.get("bash_reference")
1260
+ if not isinstance(reference, dict) or reference.get("route") != "reference":
1261
+ return ""
1262
+ handle = reference.get("reference")
1263
+ if (
1264
+ not isinstance(handle, str)
1265
+ or BASH_REFERENCE_HANDLE_RE.fullmatch(handle) is None
1266
+ ):
1267
+ return ""
1268
+ command = BASH_REFERENCE_COMMAND_PREFIX + handle
1269
+ if reference.get("retrieval_command") != command:
1270
+ return ""
1271
+ return f"- bash_reference (scoped disclosure: 7 days): `{command}`\n"
1272
+
1273
+
1274
+ def bash_reference_metadata(handle: str) -> dict[str, object] | None:
1275
+ if BASH_REFERENCE_HANDLE_RE.fullmatch(handle) is None:
1276
+ return None
1277
+ return {
1278
+ "mode": "bash_reference_v1",
1279
+ "route": "reference",
1280
+ "reference": handle,
1281
+ "disclosure_days": 7,
1282
+ "retrieval_command": BASH_REFERENCE_COMMAND_PREFIX + handle,
1283
+ }
1284
+
1285
+
1286
+ def bash_reference_fits_digest_budget(digest: str, max_chars: int) -> bool:
1287
+ """Preflight the smallest provider-visible serialization before commit."""
1288
+
1289
+ reference = bash_reference_metadata(BASH_REFERENCE_BUDGET_PROBE)
1290
+ if reference is None or type(max_chars) is not int or max_chars < 1:
1291
+ return False
1292
+ if digest == "json":
1293
+ minimum = json.dumps(
1294
+ {"bash_reference": reference, "digest_capped": True},
1295
+ ensure_ascii=False,
1296
+ sort_keys=True,
1297
+ indent=2,
1298
+ ) + "\n"
1299
+ return len(minimum) <= max_chars
1300
+ if digest == "markdown":
1301
+ line = markdown_bash_reference_line({"bash_reference": reference})
1302
+ return bool(line) and len(line) + len(DIGEST_CAP_MARKER) <= max_chars
1303
+ return False
1304
+
1305
+
1306
+ def _sanitized_bash_reference_payload(
1307
+ payload: dict[str, object],
1308
+ ) -> dict[str, object]:
1309
+ """Remove any reference object that cannot be rendered as static syntax."""
1310
+
1311
+ reference = payload.get("bash_reference")
1312
+ if not isinstance(reference, dict) or reference.get("route") != "reference":
1313
+ return payload
1314
+ handle = reference.get("reference")
1315
+ expected_command = (
1316
+ BASH_REFERENCE_COMMAND_PREFIX + handle if isinstance(handle, str) else None
1317
+ )
1318
+ if (
1319
+ not isinstance(handle, str)
1320
+ or BASH_REFERENCE_HANDLE_RE.fullmatch(handle) is None
1321
+ or reference.get("retrieval_command") != expected_command
1322
+ ):
1323
+ sanitized = dict(payload)
1324
+ sanitized.pop("bash_reference", None)
1325
+ return sanitized
1326
+ return payload
1327
+
1328
+
948
1329
  def render_digest_markdown(payload: dict[str, object], max_chars: int) -> str:
1330
+ payload = _sanitized_bash_reference_payload(payload)
1331
+ if (
1332
+ isinstance(payload.get("bash_reference"), dict)
1333
+ and not bash_reference_fits_digest_budget("markdown", max_chars)
1334
+ ):
1335
+ payload = dict(payload)
1336
+ payload.pop("bash_reference", None)
949
1337
  raw_output = payload.get("raw_output", {})
950
1338
  budget = payload.get("budget", {})
951
1339
  lines: list[str] = []
@@ -983,6 +1371,9 @@ def render_digest_markdown(payload: dict[str, object], max_chars: int) -> str:
983
1371
  if isinstance(artifact_receipt, dict):
984
1372
  for line in markdown_artifact_receipt_lines(artifact_receipt):
985
1373
  add(line, receipt=True)
1374
+ bash_reference_line = markdown_bash_reference_line(payload)
1375
+ if bash_reference_line:
1376
+ add(bash_reference_line, receipt=True)
986
1377
  failure_signature = payload.get("failure_signature")
987
1378
  if isinstance(failure_signature, dict):
988
1379
  add(
@@ -1028,10 +1419,14 @@ def render_digest_markdown(payload: dict[str, object], max_chars: int) -> str:
1028
1419
  output, capped = cap_text(text, max_chars)
1029
1420
  if not capped:
1030
1421
  return output
1031
- marker = "[context-guard-kit] digest capped by --max-chars.\n"
1422
+ marker = DIGEST_CAP_MARKER
1032
1423
  if max_chars <= len(marker):
1033
1424
  return marker[:max_chars]
1034
1425
  reserved_receipt = compact_markdown_artifact_receipt(payload, max_chars - len(marker))
1426
+ if not reserved_receipt:
1427
+ candidate = markdown_bash_reference_line(payload)
1428
+ if len(candidate) <= max_chars - len(marker):
1429
+ reserved_receipt = candidate
1035
1430
  if reserved_receipt:
1036
1431
  head_budget = max_chars - len(marker) - len(reserved_receipt)
1037
1432
  head = ""
@@ -1045,12 +1440,21 @@ def render_digest_markdown(payload: dict[str, object], max_chars: int) -> str:
1045
1440
  head = non_receipt_text[:keep].rstrip() + text_cap_marker
1046
1441
  if head and not head.endswith("\n"):
1047
1442
  head += "\n"
1048
- return head + reserved_receipt + marker
1049
- output, _ = cap_text(text, max_chars - len(marker))
1050
- return output + marker
1443
+ candidate = head + reserved_receipt + marker
1444
+ return candidate if len(candidate) <= max_chars else reserved_receipt + marker
1445
+ head_budget = max(0, max_chars - len(marker))
1446
+ return text[:head_budget] + marker
1051
1447
 
1052
1448
 
1053
1449
  def render_digest_json(payload: dict[str, object], max_chars: int) -> str:
1450
+ payload = _sanitized_bash_reference_payload(payload)
1451
+ if (
1452
+ isinstance(payload.get("bash_reference"), dict)
1453
+ and not bash_reference_fits_digest_budget("json", max_chars)
1454
+ ):
1455
+ payload = dict(payload)
1456
+ payload.pop("bash_reference", None)
1457
+
1054
1458
  def dumps(data: dict[str, object]) -> str:
1055
1459
  return json.dumps(data, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
1056
1460
 
@@ -1075,7 +1479,10 @@ def render_digest_json(payload: dict[str, object], max_chars: int) -> str:
1075
1479
  output = dumps(candidate)
1076
1480
  if len(output) <= max_chars:
1077
1481
  return output
1078
- return dumps(candidates[-1])
1482
+ for fallback in ("{}\n", "{}", "0"):
1483
+ if len(fallback) <= max_chars:
1484
+ return fallback
1485
+ return ""
1079
1486
 
1080
1487
  def compact_artifact_receipt(*, include_exact_reexpand: bool) -> dict[str, object] | None:
1081
1488
  artifact_receipt = payload.get("artifact_receipt")
@@ -1196,8 +1603,7 @@ def render_digest_json(payload: dict[str, object], max_chars: int) -> str:
1196
1603
  minimal_receipt = compact_artifact_receipt(include_exact_reexpand=False)
1197
1604
  tiny_receipt = tiny_artifact_receipt()
1198
1605
 
1199
- return first_fitting(
1200
- [
1606
+ candidates = [
1201
1607
  attach_artifact_receipt(
1202
1608
  {
1203
1609
  "tool": payload.get("tool"),
@@ -1255,7 +1661,13 @@ def render_digest_json(payload: dict[str, object], max_chars: int) -> str:
1255
1661
  ),
1256
1662
  {"digest_capped": True},
1257
1663
  ]
1258
- )
1664
+ reference = payload.get("bash_reference")
1665
+ if isinstance(reference, dict) and reference.get("route") == "reference":
1666
+ candidates = [
1667
+ {**candidate, "bash_reference": reference}
1668
+ for candidate in candidates[:-1]
1669
+ ] + [{"bash_reference": reference, "digest_capped": True}]
1670
+ return first_fitting(candidates)
1259
1671
 
1260
1672
 
1261
1673
  _STREAM_END = object()
@@ -1474,7 +1886,109 @@ def process_group_id_for(proc: subprocess.Popen[str]) -> int | None:
1474
1886
  return proc.pid
1475
1887
 
1476
1888
 
1889
+ def _reference_offset(value: object) -> int | None:
1890
+ if (
1891
+ not isinstance(value, str)
1892
+ or len(value) > 20
1893
+ or not (value == "0" or re.fullmatch(r"[1-9][0-9]*", value, re.ASCII))
1894
+ ):
1895
+ return None
1896
+ return int(value, 10)
1897
+
1898
+
1899
+ def run_bash_reference_query(
1900
+ arguments: Iterable[str],
1901
+ *,
1902
+ output: BinaryIO | None = None,
1903
+ error: BinaryIO | None = None,
1904
+ cwd: Path | None = None,
1905
+ ) -> int:
1906
+ """Expand one bounded page through the verified package-local Receipt CLI."""
1907
+
1908
+ output_stream = sys.stdout.buffer if output is None else output
1909
+ error_stream = sys.stderr.buffer if error is None else error
1910
+ arguments = tuple(arguments)
1911
+ help_text = (
1912
+ "usage: context-guard reference <cgr1p handle> [--offset <decimal>]\n"
1913
+ "Print at most 20,000 exact sanitized UTF-8 bytes from one live local "
1914
+ "reference; use the stderr continuation offset for the next page.\n"
1915
+ ).encode("ascii")
1916
+ if arguments == ("--help",):
1917
+ output_stream.write(help_text)
1918
+ return 0
1919
+ if len(arguments) not in {1, 3}:
1920
+ error_stream.write(help_text)
1921
+ return 2
1922
+ handle = arguments[0]
1923
+ if (
1924
+ BASH_REFERENCE_HANDLE_RE.fullmatch(handle) is None
1925
+ or (len(arguments) == 3 and arguments[1] != "--offset")
1926
+ ):
1927
+ error_stream.write(b"context-guard: invalid reference request\n")
1928
+ return 2
1929
+ offset = 0 if len(arguments) == 1 else _reference_offset(arguments[2])
1930
+ if offset is None:
1931
+ error_stream.write(b"context-guard: invalid reference request\n")
1932
+ return 2
1933
+ try:
1934
+ root = (Path.cwd() if cwd is None else Path(cwd)).resolve(strict=True)
1935
+ policy = load_adjacent_python_module(
1936
+ Path(__file__).resolve().parent,
1937
+ "bash_reference_policy.py",
1938
+ module_prefix="context_guard_bash_reference_query",
1939
+ )
1940
+ discover = getattr(policy, "discover_adapter", None)
1941
+ if policy is None or not callable(discover):
1942
+ raise RuntimeError("reference policy unavailable")
1943
+ discovered = discover(root)
1944
+ if not isinstance(discovered, tuple) or len(discovered) != 2:
1945
+ raise RuntimeError("reference adapter unavailable")
1946
+ adapter, _reason = discovered
1947
+ query = getattr(adapter, "query_reference", None)
1948
+ if adapter is None or not callable(query):
1949
+ raise RuntimeError("reference adapter unavailable")
1950
+ result = query(
1951
+ handle,
1952
+ root=str(root),
1953
+ offset=offset,
1954
+ timeout_seconds=8,
1955
+ )
1956
+ payload = getattr(result, "payload", None)
1957
+ result_offset = getattr(result, "offset", None)
1958
+ next_offset = getattr(result, "next_offset", None)
1959
+ total_bytes = getattr(result, "total_bytes", None)
1960
+ if (
1961
+ getattr(result, "status", None) != "success"
1962
+ or getattr(result, "reference", None) != handle
1963
+ or type(payload) is not bytes
1964
+ or len(payload) > BASH_REFERENCE_QUERY_MAX_PAYLOAD_BYTES
1965
+ or type(result_offset) is not int
1966
+ or result_offset != offset
1967
+ or type(next_offset) is not int
1968
+ or next_offset != offset + len(payload)
1969
+ or type(total_bytes) is not int
1970
+ or total_bytes < next_offset
1971
+ ):
1972
+ raise RuntimeError("reference response invalid")
1973
+ payload.decode("utf-8", errors="strict")
1974
+ except Exception:
1975
+ error_stream.write(b"context-guard: reference unavailable\n")
1976
+ return 65
1977
+ output_stream.write(payload)
1978
+ if next_offset < total_bytes:
1979
+ hint = (
1980
+ "context-guard: more bytes available; continue with --offset "
1981
+ f"{next_offset}\n"
1982
+ )
1983
+ else:
1984
+ hint = f"context-guard: reference complete at offset {next_offset}\n"
1985
+ error_stream.write(hint.encode("ascii"))
1986
+ return 0
1987
+
1988
+
1477
1989
  def main() -> int:
1990
+ if sys.argv[1:2] == ["--expand-bash-reference"]:
1991
+ return run_bash_reference_query(sys.argv[2:])
1478
1992
  parser = argparse.ArgumentParser()
1479
1993
  parser.add_argument("--max-lines", type=int, default=220)
1480
1994
  parser.add_argument("--max-chars", type=int, default=20000)
@@ -1528,6 +2042,14 @@ def main() -> int:
1528
2042
  "context-guard-artifact receipt and include re-expand metadata"
1529
2043
  ),
1530
2044
  )
2045
+ parser.add_argument(
2046
+ "--bash-reference-v1",
2047
+ action="store_true",
2048
+ help=(
2049
+ "opt-in PreToolUse receipt reference mode; keeps command execution "
2050
+ "local and falls back to the normal digest when Receipt is unavailable"
2051
+ ),
2052
+ )
1531
2053
  parser.add_argument(
1532
2054
  "--artifact-dir",
1533
2055
  default=".context-guard/artifacts",
@@ -1542,6 +2064,11 @@ def main() -> int:
1542
2064
  f"(default: {DEFAULT_ARTIFACT_RECEIPT_MAX_BYTES}, max: {MAX_ARTIFACT_RECEIPT_MAX_BYTES})"
1543
2065
  ),
1544
2066
  )
2067
+ parser.add_argument(
2068
+ "--post-tool-use-hook",
2069
+ action="store_true",
2070
+ help="read one Bash PostToolUse JSON payload from stdin and emit updatedToolOutput JSON",
2071
+ )
1545
2072
  parser.add_argument("command", nargs=argparse.REMAINDER)
1546
2073
  args = parser.parse_args()
1547
2074
  normalize_budgets(args)
@@ -1551,9 +2078,17 @@ def main() -> int:
1551
2078
  1,
1552
2079
  MAX_ARTIFACT_RECEIPT_MAX_BYTES,
1553
2080
  )
2081
+ if args.post_tool_use_hook:
2082
+ return run_post_tool_use_hook(args)
1554
2083
  if args.artifact_receipt and args.digest == "off":
1555
2084
  print("trim_command_output.py: --artifact-receipt requires --digest markdown or --digest json", file=sys.stderr)
1556
2085
  return 2
2086
+ if args.bash_reference_v1 and args.digest == "off":
2087
+ print("trim_command_output.py: --bash-reference-v1 requires --digest markdown or --digest json", file=sys.stderr)
2088
+ return 2
2089
+ if args.artifact_receipt and args.bash_reference_v1:
2090
+ print("trim_command_output.py: --artifact-receipt and --bash-reference-v1 are mutually exclusive", file=sys.stderr)
2091
+ return 2
1557
2092
  if args.artifact_receipt:
1558
2093
  try:
1559
2094
  load_artifact_store_module()
@@ -1582,7 +2117,86 @@ def main() -> int:
1582
2117
  except UnsafeAdjacentModuleError as exc:
1583
2118
  print(f"context-guard-kit: unsafe adjacent helper: {exc}", file=sys.stderr)
1584
2119
  return 2
2120
+ bash_reference_strong_sanitizer = not isinstance(
2121
+ line_sanitizer,
2122
+ FallbackLineSanitizer,
2123
+ )
1585
2124
 
2125
+ artifact_capture = SanitizedArtifactCapture(
2126
+ enabled=(
2127
+ args.artifact_receipt
2128
+ or (args.bash_reference_v1 and bash_reference_strong_sanitizer)
2129
+ ),
2130
+ max_bytes=(
2131
+ BASH_REFERENCE_MAX_SANITIZED_BYTES
2132
+ if args.bash_reference_v1
2133
+ else args.artifact_max_bytes
2134
+ ),
2135
+ reference_spool=args.bash_reference_v1,
2136
+ )
2137
+ bash_reference_adapter: object | None = None
2138
+ bash_reference_broker: object | None = None
2139
+ bash_reference_adapter_reason = (
2140
+ "receipt_policy_unavailable"
2141
+ if bash_reference_strong_sanitizer
2142
+ else "receipt_strong_sanitizer_unavailable"
2143
+ )
2144
+ bash_reference_root: Path | None = None
2145
+ if args.bash_reference_v1 and bash_reference_strong_sanitizer:
2146
+ try:
2147
+ bash_reference_root = Path.cwd().resolve()
2148
+ bash_reference_policy = load_adjacent_python_module(
2149
+ Path(__file__).resolve().parent,
2150
+ "bash_reference_policy.py",
2151
+ module_prefix="context_guard_bash_reference",
2152
+ )
2153
+ discover = getattr(bash_reference_policy, "discover_adapter", None)
2154
+ if bash_reference_policy is None:
2155
+ bash_reference_adapter_reason = "receipt_policy_unavailable"
2156
+ elif not callable(discover):
2157
+ bash_reference_adapter_reason = "receipt_policy_invalid"
2158
+ else:
2159
+ discovered = discover(bash_reference_root)
2160
+ if not isinstance(discovered, tuple) or len(discovered) != 2:
2161
+ bash_reference_adapter_reason = "receipt_policy_invalid"
2162
+ else:
2163
+ bash_reference_adapter, reason = discovered
2164
+ bash_reference_adapter_reason = str(reason)
2165
+ if bash_reference_adapter is not None and not callable(
2166
+ getattr(bash_reference_adapter, "start_broker", None)
2167
+ ):
2168
+ bash_reference_adapter = None
2169
+ bash_reference_adapter_reason = "receipt_adapter_invalid"
2170
+ except Exception:
2171
+ bash_reference_adapter = None
2172
+ bash_reference_adapter_reason = "receipt_policy_load_failed"
2173
+ if (
2174
+ args.bash_reference_v1
2175
+ and bash_reference_adapter is not None
2176
+ and bash_reference_root is not None
2177
+ and not artifact_capture.error
2178
+ ):
2179
+ transaction_id = secrets.token_hex(32)
2180
+ try:
2181
+ bash_reference_broker, reason = bash_reference_adapter.start_broker( # type: ignore[attr-defined]
2182
+ artifact_capture.descriptor(),
2183
+ root=str(bash_reference_root),
2184
+ transaction_id=transaction_id,
2185
+ disclosure_days=7,
2186
+ timeout_seconds=8,
2187
+ )
2188
+ bash_reference_adapter_reason = str(reason)
2189
+ if bash_reference_broker is not None and not all(
2190
+ callable(getattr(bash_reference_broker, name, None))
2191
+ for name in ("abort", "close", "commit")
2192
+ ):
2193
+ abort_bash_reference_broker(bash_reference_broker)
2194
+ bash_reference_broker = None
2195
+ bash_reference_adapter_reason = "receipt_broker_invalid"
2196
+ except Exception:
2197
+ abort_bash_reference_broker(bash_reference_broker)
2198
+ bash_reference_broker = None
2199
+ bash_reference_adapter_reason = "receipt_broker_unavailable"
1586
2200
  popen_kwargs: dict[str, object] = {}
1587
2201
  if os.name != "nt":
1588
2202
  popen_kwargs["start_new_session"] = True
@@ -1593,9 +2207,12 @@ def main() -> int:
1593
2207
  stderr=subprocess.STDOUT,
1594
2208
  text=False,
1595
2209
  bufsize=0,
2210
+ close_fds=True,
1596
2211
  **popen_kwargs,
1597
2212
  )
1598
2213
  except OSError as exc:
2214
+ abort_bash_reference_broker(bash_reference_broker)
2215
+ artifact_capture.close()
1599
2216
  print(f"context-guard-kit: command failed to start: {exc}", file=sys.stderr)
1600
2217
  return 127
1601
2218
 
@@ -1610,9 +2227,8 @@ def main() -> int:
1610
2227
  runner_summary = RunnerFailureSummary(args.runner_summary_items, show_paths=args.show_paths)
1611
2228
  duplicate_tracker = DuplicateLineTracker()
1612
2229
  redacted_lines = 0
1613
- artifact_capture = SanitizedArtifactCapture(enabled=args.artifact_receipt, max_bytes=args.artifact_max_bytes)
1614
-
1615
2230
  if proc.stdout is None:
2231
+ abort_bash_reference_broker(bash_reference_broker)
1616
2232
  artifact_capture.close()
1617
2233
  print("trim_command_output.py: subprocess produced no stdout pipe", file=sys.stderr)
1618
2234
  return 1
@@ -1623,27 +2239,39 @@ def main() -> int:
1623
2239
  max_line_chars=COMMAND_MAX_UNTERMINATED_LINE_CHARS,
1624
2240
  process_group_id=process_group_id_for(proc),
1625
2241
  )
1626
- for line in command_stream:
1627
- total += 1
1628
- raw_chars += len(line)
1629
- visible_source, redacted = line_sanitizer.sanitize(line) # type: ignore[attr-defined]
1630
- if redacted:
1631
- redacted_lines += 1
1632
- artifact_capture.add(visible_source)
1633
- visible_line, line_capped = cap_line(visible_source, args.max_line_chars)
1634
- any_line_capped = any_line_capped or line_capped
1635
- visible_chars += len(visible_line)
1636
- duplicate_tracker.feed(total, visible_line)
1637
- if total <= args.head_lines:
1638
- head.append(visible_line)
1639
- tail.append(visible_line)
1640
- if ERROR_RE.search(visible_line) and len(error_lines) < args.error_lines:
1641
- error_lines.append(visible_line)
1642
- runner_summary.feed(line)
1643
- if total <= args.max_lines:
1644
- all_lines.append(visible_line)
1645
-
1646
- rc = command_stream.returncode()
2242
+ try:
2243
+ for line in command_stream:
2244
+ total += 1
2245
+ raw_chars += len(line)
2246
+ visible_source, redacted = line_sanitizer.sanitize(line) # type: ignore[attr-defined]
2247
+ if redacted:
2248
+ redacted_lines += 1
2249
+ artifact_capture.add(visible_source)
2250
+ visible_line, line_capped = cap_line(visible_source, args.max_line_chars)
2251
+ any_line_capped = any_line_capped or line_capped
2252
+ visible_chars += len(visible_line)
2253
+ duplicate_tracker.feed(total, visible_line)
2254
+ if total <= args.head_lines:
2255
+ head.append(visible_line)
2256
+ tail.append(visible_line)
2257
+ if ERROR_RE.search(visible_line) and len(error_lines) < args.error_lines:
2258
+ error_lines.append(visible_line)
2259
+ runner_summary.feed(line)
2260
+ if total <= args.max_lines:
2261
+ all_lines.append(visible_line)
2262
+
2263
+ rc = command_stream.returncode()
2264
+ proc.stdout.close()
2265
+ except BaseException:
2266
+ abort_bash_reference_broker(bash_reference_broker)
2267
+ artifact_capture.close()
2268
+ terminate_process_tree(
2269
+ proc,
2270
+ process_group_id=command_stream.process_group_id,
2271
+ include_exited_group=True,
2272
+ )
2273
+ proc.stdout.close()
2274
+ raise
1647
2275
  if command_stream.timed_out and not command_stream.timeout_reported:
1648
2276
  line = command_stream.timeout_message()
1649
2277
  command_stream.timeout_reported = True
@@ -1738,6 +2366,47 @@ def main() -> int:
1738
2366
  )
1739
2367
  if guidance not in next_queries:
1740
2368
  next_queries.insert(0, guidance)
2369
+ if args.bash_reference_v1:
2370
+ reference: str | None = None
2371
+ reason_code = (
2372
+ bash_reference_adapter_reason
2373
+ if not bash_reference_strong_sanitizer
2374
+ else "receipt_output_below_reference_threshold"
2375
+ )
2376
+ if artifact_capture.overflow:
2377
+ reason_code = "receipt_capture_overflow"
2378
+ abort_bash_reference_broker(bash_reference_broker)
2379
+ elif artifact_capture.error:
2380
+ reason_code = "receipt_capture_unavailable"
2381
+ abort_bash_reference_broker(bash_reference_broker)
2382
+ elif command_stream.timed_out:
2383
+ reason_code = "receipt_command_incomplete"
2384
+ abort_bash_reference_broker(bash_reference_broker)
2385
+ elif artifact_capture.bytes < BASH_REFERENCE_MIN_SANITIZED_BYTES:
2386
+ abort_bash_reference_broker(bash_reference_broker)
2387
+ elif bash_reference_broker is None:
2388
+ reason_code = bash_reference_adapter_reason
2389
+ elif not bash_reference_fits_digest_budget(args.digest, args.max_chars):
2390
+ reason_code = "receipt_reference_exceeds_digest_budget"
2391
+ abort_bash_reference_broker(bash_reference_broker)
2392
+ else:
2393
+ reference, reason_code = commit_bash_reference_broker(
2394
+ bash_reference_broker
2395
+ )
2396
+ bash_reference_broker = None
2397
+ reference_metadata = (
2398
+ bash_reference_metadata(reference)
2399
+ if reference is not None
2400
+ else None
2401
+ )
2402
+ if reference_metadata is not None:
2403
+ payload["bash_reference"] = reference_metadata
2404
+ else:
2405
+ payload["bash_reference"] = {
2406
+ "mode": "bash_reference_v1",
2407
+ "route": "legacy_trim",
2408
+ "reason_code": "receipt_reference_exceeds_digest_budget" if reference else reason_code,
2409
+ }
1741
2410
  rendered = (
1742
2411
  render_digest_json(payload, args.max_chars)
1743
2412
  if args.digest == "json"
@@ -1765,6 +2434,7 @@ def main() -> int:
1765
2434
  if (
1766
2435
  not args.digest_always
1767
2436
  and not args.artifact_receipt
2437
+ and not args.bash_reference_v1
1768
2438
  and rc == 0
1769
2439
  and complete_output_available
1770
2440
  and passthrough_bytes < digest_bytes
@@ -1821,6 +2491,7 @@ def main() -> int:
1821
2491
  output += "[context-guard-kit] final summary was capped by --max-chars.\n"
1822
2492
  sys.stdout.write(output)
1823
2493
 
2494
+ abort_bash_reference_broker(bash_reference_broker)
1824
2495
  artifact_capture.close()
1825
2496
  return rc
1826
2497