@ictechgy/context-guard 0.4.15 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.ko.md +128 -2
  3. package/README.md +144 -3
  4. package/docs/distribution.md +100 -0
  5. package/package.json +4 -1
  6. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  7. package/plugins/context-guard/README.ko.md +43 -1
  8. package/plugins/context-guard/README.md +44 -1
  9. package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
  10. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  11. package/plugins/context-guard/bin/context-guard-audit +169 -66
  12. package/plugins/context-guard/bin/context-guard-bench +9865 -211
  13. package/plugins/context-guard/bin/context-guard-compress +90 -8
  14. package/plugins/context-guard/bin/context-guard-diet +1 -7
  15. package/plugins/context-guard/bin/context-guard-experiments +5 -1
  16. package/plugins/context-guard/bin/context-guard-failed-nudge +777 -83
  17. package/plugins/context-guard/bin/context-guard-guard-read +496 -57
  18. package/plugins/context-guard/bin/context-guard-mcp +2 -1
  19. package/plugins/context-guard/bin/context-guard-pack +1570 -150
  20. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  21. package/plugins/context-guard/bin/context-guard-rewrite-bash +2669 -236
  22. package/plugins/context-guard/bin/context-guard-sanitize-output +723 -92
  23. package/plugins/context-guard/bin/context-guard-setup +1944 -222
  24. package/plugins/context-guard/bin/context-guard-statusline +163 -55
  25. package/plugins/context-guard/bin/context-guard-statusline-merged +78 -23
  26. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  27. package/plugins/context-guard/bin/context-guard-trim-output +795 -48
  28. package/plugins/context-guard/brief/README.md +19 -0
  29. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  30. package/plugins/context-guard/lib/context_guard_commands.py +10 -2
  31. package/plugins/context-guard/lib/credential_policy.py +185 -0
  32. package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
@@ -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:
@@ -143,8 +159,15 @@ class UnsafeAdjacentModuleError(RuntimeError):
143
159
 
144
160
 
145
161
  class FallbackLineSanitizer:
146
- def __init__(self, *, show_paths: bool = False, diagnostic: str | None = None) -> None:
162
+ def __init__(
163
+ self,
164
+ *,
165
+ show_paths: bool = False,
166
+ context: str = "unknown_text",
167
+ diagnostic: str | None = None,
168
+ ) -> None:
147
169
  self.show_paths = show_paths
170
+ self.context = context
148
171
  self.diagnostic = diagnostic
149
172
  self.diagnostic_emitted = False
150
173
  self.redactions = 0
@@ -154,8 +177,6 @@ class FallbackLineSanitizer:
154
177
  print(f"context-guard-kit: sanitizer fallback active: {self.diagnostic}", file=sys.stderr)
155
178
  self.diagnostic_emitted = True
156
179
  line = strip_ansi(raw_line)
157
- if not self.show_paths:
158
- line = anonymize_absolute_paths(line)
159
180
  original = line
160
181
  auth_match = FALLBACK_AUTH_HEADER_RE.match(line)
161
182
  if auth_match:
@@ -223,11 +244,31 @@ def load_adjacent_python_module(script_dir: Path, name: str, *, module_prefix: s
223
244
  module = types.ModuleType(module_name)
224
245
  module.__file__ = str(script_dir / name)
225
246
  module.__package__ = ""
226
- exec(compile(source, str(script_dir / name), "exec"), module.__dict__)
247
+ sys.modules[module_name] = module
248
+ try:
249
+ exec(compile(source, str(script_dir / name), "exec"), module.__dict__)
250
+ except Exception:
251
+ sys.modules.pop(module_name, None)
252
+ raise
227
253
  return module
228
254
 
229
255
 
230
- def load_line_sanitizer(show_paths: bool) -> object:
256
+ def instantiate_line_sanitizer(factory: object, *, show_paths: bool, context: str) -> object:
257
+ try:
258
+ return factory(show_paths=show_paths, context=context) # type: ignore[operator]
259
+ except TypeError:
260
+ if context != "unknown_text":
261
+ raise RuntimeError(
262
+ "adjacent sanitizer does not support required explicit context"
263
+ )
264
+ # One compatibility window for unknown-text adjacent sanitizer stubs.
265
+ return factory(show_paths=show_paths) # type: ignore[operator]
266
+
267
+
268
+ def load_line_sanitizer(
269
+ show_paths: bool,
270
+ context: str = "unknown_text",
271
+ ) -> object:
231
272
  """Reuse the stronger sanitizer when it is shipped next to this wrapper."""
232
273
  script_dir = Path(__file__).resolve().parent
233
274
  load_errors: list[str] = []
@@ -240,14 +281,22 @@ def load_line_sanitizer(show_paths: bool) -> object:
240
281
  )
241
282
  if module is None:
242
283
  continue
243
- return module.LineSanitizer(show_paths=show_paths)
284
+ return instantiate_line_sanitizer(
285
+ module.LineSanitizer,
286
+ show_paths=show_paths,
287
+ context=context,
288
+ )
244
289
  except UnsafeAdjacentModuleError:
245
290
  raise
246
291
  except Exception as exc:
247
292
  load_errors.append(f"{name} failed to load: {exc.__class__.__name__}: {exc}")
248
293
  continue
249
294
  diagnostic = "; ".join(load_errors) if load_errors else "strong sanitizer not found next to trim wrapper"
250
- return FallbackLineSanitizer(show_paths=show_paths, diagnostic=diagnostic)
295
+ return FallbackLineSanitizer(
296
+ show_paths=show_paths,
297
+ context=context,
298
+ diagnostic=diagnostic,
299
+ )
251
300
 
252
301
 
253
302
  def load_artifact_store_module() -> object:
@@ -400,32 +449,63 @@ def store_sanitized_artifact_receipt(
400
449
 
401
450
 
402
451
  class SanitizedArtifactCapture:
403
- def __init__(self, *, enabled: bool, max_bytes: int) -> None:
452
+ def __init__(self, *, enabled: bool, max_bytes: int, reference_spool: bool = False) -> None:
404
453
  self.enabled = enabled
405
454
  self.max_bytes = max_bytes
455
+ self.reference_spool = reference_spool
406
456
  self.bytes = 0
407
457
  self.overflow = False
408
458
  self.error: str | None = None
409
459
  self._file: BinaryIO | None = None
460
+ if self.enabled and self.reference_spool:
461
+ self._ensure_file()
410
462
 
411
463
  def _ensure_file(self) -> BinaryIO | None:
412
464
  if self._file is not None:
413
465
  return self._file
414
466
  try:
415
- 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")
416
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
417
485
  self._record_error(exc)
418
486
  return None
419
487
  return self._file
420
488
 
421
489
  def _record_error(self, exc: OSError) -> None:
422
490
  if self.error is None:
423
- 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
+ )
424
496
 
425
497
  def add(self, sanitized_line: str) -> None:
426
498
  if not self.enabled or self.overflow or self.error:
427
499
  return
428
- 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
429
509
  source_bytes = len(encoded)
430
510
  if self.bytes + source_bytes > self.max_bytes:
431
511
  self.overflow = True
@@ -443,6 +523,8 @@ class SanitizedArtifactCapture:
443
523
  self.bytes += source_bytes
444
524
 
445
525
  def text(self) -> str:
526
+ if self.reference_spool:
527
+ return ""
446
528
  if self._file is None:
447
529
  return ""
448
530
  try:
@@ -454,6 +536,12 @@ class SanitizedArtifactCapture:
454
536
  self.close()
455
537
  return ""
456
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
+
457
545
  def close(self) -> None:
458
546
  target = self._file
459
547
  self._file = None
@@ -470,6 +558,49 @@ class SanitizedArtifactCapture:
470
558
  self.close()
471
559
 
472
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
+
473
604
  def unique_keep_order(lines: Iterable[str]) -> list[str]:
474
605
  seen: set[str] = set()
475
606
  out: list[str] = []
@@ -499,6 +630,217 @@ def cap_text(text: str, max_chars: int) -> tuple[str, bool]:
499
630
  return text[:keep].rstrip() + marker, True
500
631
 
501
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
+
502
844
  def compact_item(
503
845
  text: str,
504
846
  limit: int = MAX_SUMMARY_ITEM_CHARS,
@@ -508,7 +850,7 @@ def compact_item(
508
850
  ) -> str:
509
851
  """Normalize a failure-summary item without letting one log line dominate memory/output."""
510
852
  if sanitizer is None:
511
- sanitizer = load_line_sanitizer(show_paths)
853
+ sanitizer = load_line_sanitizer(show_paths, context="unknown_text")
512
854
  sanitized, _ = sanitizer.sanitize(text) # type: ignore[attr-defined]
513
855
  item = re.sub(r"\s+", " ", strip_ansi(sanitized).strip())
514
856
  if len(item) <= limit:
@@ -529,7 +871,7 @@ class RunnerFailureSummary:
529
871
  def __init__(self, max_items_per_runner: int, *, show_paths: bool = False) -> None:
530
872
  self.max_items_per_runner = max(0, max_items_per_runner)
531
873
  self.show_paths = show_paths
532
- self.sanitizer = load_line_sanitizer(show_paths)
874
+ self.sanitizer = load_line_sanitizer(show_paths, context="unknown_text")
533
875
  self.items: dict[str, list[str]] = collections.defaultdict(list)
534
876
  self.seen: dict[str, set[str]] = collections.defaultdict(set)
535
877
  self.jest_active = False
@@ -912,7 +1254,86 @@ def compact_markdown_artifact_receipt(payload: dict[str, object], max_chars: int
912
1254
  return ""
913
1255
 
914
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
+
915
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)
916
1337
  raw_output = payload.get("raw_output", {})
917
1338
  budget = payload.get("budget", {})
918
1339
  lines: list[str] = []
@@ -950,6 +1371,9 @@ def render_digest_markdown(payload: dict[str, object], max_chars: int) -> str:
950
1371
  if isinstance(artifact_receipt, dict):
951
1372
  for line in markdown_artifact_receipt_lines(artifact_receipt):
952
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)
953
1377
  failure_signature = payload.get("failure_signature")
954
1378
  if isinstance(failure_signature, dict):
955
1379
  add(
@@ -995,10 +1419,14 @@ def render_digest_markdown(payload: dict[str, object], max_chars: int) -> str:
995
1419
  output, capped = cap_text(text, max_chars)
996
1420
  if not capped:
997
1421
  return output
998
- marker = "[context-guard-kit] digest capped by --max-chars.\n"
1422
+ marker = DIGEST_CAP_MARKER
999
1423
  if max_chars <= len(marker):
1000
1424
  return marker[:max_chars]
1001
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
1002
1430
  if reserved_receipt:
1003
1431
  head_budget = max_chars - len(marker) - len(reserved_receipt)
1004
1432
  head = ""
@@ -1012,12 +1440,21 @@ def render_digest_markdown(payload: dict[str, object], max_chars: int) -> str:
1012
1440
  head = non_receipt_text[:keep].rstrip() + text_cap_marker
1013
1441
  if head and not head.endswith("\n"):
1014
1442
  head += "\n"
1015
- return head + reserved_receipt + marker
1016
- output, _ = cap_text(text, max_chars - len(marker))
1017
- 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
1018
1447
 
1019
1448
 
1020
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
+
1021
1458
  def dumps(data: dict[str, object]) -> str:
1022
1459
  return json.dumps(data, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
1023
1460
 
@@ -1042,7 +1479,10 @@ def render_digest_json(payload: dict[str, object], max_chars: int) -> str:
1042
1479
  output = dumps(candidate)
1043
1480
  if len(output) <= max_chars:
1044
1481
  return output
1045
- return dumps(candidates[-1])
1482
+ for fallback in ("{}\n", "{}", "0"):
1483
+ if len(fallback) <= max_chars:
1484
+ return fallback
1485
+ return ""
1046
1486
 
1047
1487
  def compact_artifact_receipt(*, include_exact_reexpand: bool) -> dict[str, object] | None:
1048
1488
  artifact_receipt = payload.get("artifact_receipt")
@@ -1163,8 +1603,7 @@ def render_digest_json(payload: dict[str, object], max_chars: int) -> str:
1163
1603
  minimal_receipt = compact_artifact_receipt(include_exact_reexpand=False)
1164
1604
  tiny_receipt = tiny_artifact_receipt()
1165
1605
 
1166
- return first_fitting(
1167
- [
1606
+ candidates = [
1168
1607
  attach_artifact_receipt(
1169
1608
  {
1170
1609
  "tool": payload.get("tool"),
@@ -1222,7 +1661,13 @@ def render_digest_json(payload: dict[str, object], max_chars: int) -> str:
1222
1661
  ),
1223
1662
  {"digest_capped": True},
1224
1663
  ]
1225
- )
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)
1226
1671
 
1227
1672
 
1228
1673
  _STREAM_END = object()
@@ -1441,7 +1886,109 @@ def process_group_id_for(proc: subprocess.Popen[str]) -> int | None:
1441
1886
  return proc.pid
1442
1887
 
1443
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
+
1444
1989
  def main() -> int:
1990
+ if sys.argv[1:2] == ["--expand-bash-reference"]:
1991
+ return run_bash_reference_query(sys.argv[2:])
1445
1992
  parser = argparse.ArgumentParser()
1446
1993
  parser.add_argument("--max-lines", type=int, default=220)
1447
1994
  parser.add_argument("--max-chars", type=int, default=20000)
@@ -1478,6 +2025,15 @@ def main() -> int:
1478
2025
  "(default: off; formats: markdown, json)"
1479
2026
  ),
1480
2027
  )
2028
+ parser.add_argument(
2029
+ "--digest-always",
2030
+ action="store_true",
2031
+ help=(
2032
+ "keep the digest even when the command output is smaller than the digest; "
2033
+ "by default a smaller output is passed through so the digest cannot inflate "
2034
+ "context"
2035
+ ),
2036
+ )
1481
2037
  parser.add_argument(
1482
2038
  "--artifact-receipt",
1483
2039
  action="store_true",
@@ -1486,6 +2042,14 @@ def main() -> int:
1486
2042
  "context-guard-artifact receipt and include re-expand metadata"
1487
2043
  ),
1488
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
+ )
1489
2053
  parser.add_argument(
1490
2054
  "--artifact-dir",
1491
2055
  default=".context-guard/artifacts",
@@ -1500,6 +2064,11 @@ def main() -> int:
1500
2064
  f"(default: {DEFAULT_ARTIFACT_RECEIPT_MAX_BYTES}, max: {MAX_ARTIFACT_RECEIPT_MAX_BYTES})"
1501
2065
  ),
1502
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
+ )
1503
2072
  parser.add_argument("command", nargs=argparse.REMAINDER)
1504
2073
  args = parser.parse_args()
1505
2074
  normalize_budgets(args)
@@ -1509,9 +2078,17 @@ def main() -> int:
1509
2078
  1,
1510
2079
  MAX_ARTIFACT_RECEIPT_MAX_BYTES,
1511
2080
  )
2081
+ if args.post_tool_use_hook:
2082
+ return run_post_tool_use_hook(args)
1512
2083
  if args.artifact_receipt and args.digest == "off":
1513
2084
  print("trim_command_output.py: --artifact-receipt requires --digest markdown or --digest json", file=sys.stderr)
1514
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
1515
2092
  if args.artifact_receipt:
1516
2093
  try:
1517
2094
  load_artifact_store_module()
@@ -1533,11 +2110,93 @@ def main() -> int:
1533
2110
  return 2
1534
2111
 
1535
2112
  try:
1536
- line_sanitizer = load_line_sanitizer(args.show_paths)
2113
+ line_sanitizer = load_line_sanitizer(
2114
+ args.show_paths,
2115
+ context="unknown_text",
2116
+ )
1537
2117
  except UnsafeAdjacentModuleError as exc:
1538
2118
  print(f"context-guard-kit: unsafe adjacent helper: {exc}", file=sys.stderr)
1539
2119
  return 2
2120
+ bash_reference_strong_sanitizer = not isinstance(
2121
+ line_sanitizer,
2122
+ FallbackLineSanitizer,
2123
+ )
1540
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"
1541
2200
  popen_kwargs: dict[str, object] = {}
1542
2201
  if os.name != "nt":
1543
2202
  popen_kwargs["start_new_session"] = True
@@ -1548,9 +2207,12 @@ def main() -> int:
1548
2207
  stderr=subprocess.STDOUT,
1549
2208
  text=False,
1550
2209
  bufsize=0,
2210
+ close_fds=True,
1551
2211
  **popen_kwargs,
1552
2212
  )
1553
2213
  except OSError as exc:
2214
+ abort_bash_reference_broker(bash_reference_broker)
2215
+ artifact_capture.close()
1554
2216
  print(f"context-guard-kit: command failed to start: {exc}", file=sys.stderr)
1555
2217
  return 127
1556
2218
 
@@ -1565,9 +2227,8 @@ def main() -> int:
1565
2227
  runner_summary = RunnerFailureSummary(args.runner_summary_items, show_paths=args.show_paths)
1566
2228
  duplicate_tracker = DuplicateLineTracker()
1567
2229
  redacted_lines = 0
1568
- artifact_capture = SanitizedArtifactCapture(enabled=args.artifact_receipt, max_bytes=args.artifact_max_bytes)
1569
-
1570
2230
  if proc.stdout is None:
2231
+ abort_bash_reference_broker(bash_reference_broker)
1571
2232
  artifact_capture.close()
1572
2233
  print("trim_command_output.py: subprocess produced no stdout pipe", file=sys.stderr)
1573
2234
  return 1
@@ -1578,27 +2239,39 @@ def main() -> int:
1578
2239
  max_line_chars=COMMAND_MAX_UNTERMINATED_LINE_CHARS,
1579
2240
  process_group_id=process_group_id_for(proc),
1580
2241
  )
1581
- for line in command_stream:
1582
- total += 1
1583
- raw_chars += len(line)
1584
- visible_source, redacted = line_sanitizer.sanitize(line) # type: ignore[attr-defined]
1585
- if redacted:
1586
- redacted_lines += 1
1587
- artifact_capture.add(visible_source)
1588
- visible_line, line_capped = cap_line(visible_source, args.max_line_chars)
1589
- any_line_capped = any_line_capped or line_capped
1590
- visible_chars += len(visible_line)
1591
- duplicate_tracker.feed(total, visible_line)
1592
- if total <= args.head_lines:
1593
- head.append(visible_line)
1594
- tail.append(visible_line)
1595
- if ERROR_RE.search(visible_line) and len(error_lines) < args.error_lines:
1596
- error_lines.append(visible_line)
1597
- runner_summary.feed(line)
1598
- if total <= args.max_lines:
1599
- all_lines.append(visible_line)
1600
-
1601
- 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
1602
2275
  if command_stream.timed_out and not command_stream.timeout_reported:
1603
2276
  line = command_stream.timeout_message()
1604
2277
  command_stream.timeout_reported = True
@@ -1693,10 +2366,83 @@ def main() -> int:
1693
2366
  )
1694
2367
  if guidance not in next_queries:
1695
2368
  next_queries.insert(0, guidance)
1696
- if args.digest == "json":
1697
- sys.stdout.write(render_digest_json(payload, args.max_chars))
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
+ }
2410
+ rendered = (
2411
+ render_digest_json(payload, args.max_chars)
2412
+ if args.digest == "json"
2413
+ else render_digest_markdown(payload, args.max_chars)
2414
+ )
2415
+ # digest 는 큰 출력을 줄이려는 기능이다. 출력이 작으면 digest 가 오히려 커져서
2416
+ # 컨텍스트를 늘리므로, 그럴 때는 원래 출력을 그대로 통과시킨다. artifact receipt 를
2417
+ # 저장한 경우에는 handle/재확장 명령이 digest 의 존재 이유이므로 폴백하지 않는다.
2418
+ passthrough = "".join(all_lines)
2419
+ marker = (
2420
+ "[context-guard-kit] digest skipped: it was larger than the command output\n"
2421
+ )
2422
+ digest_bytes = len(rendered.encode("utf-8"))
2423
+ passthrough_bytes = len(passthrough.encode("utf-8")) + len(marker.encode("utf-8"))
2424
+ # 폴백 조건은 보수적으로 둔다.
2425
+ # - 전체 출력이 예산 안에 들어와 all_lines 가 완전한 출력일 때만 통과시킨다.
2426
+ # 그렇지 않으면 잘린 출력을 원본처럼 내보내 정보를 잃는다.
2427
+ # - 실패한 명령에서는 digest 가 종료 코드/실패 signature 를 담으므로 유지한다.
2428
+ # - artifact receipt 를 요청했다면 handle/재확장 명령이 digest 의 존재 이유다.
2429
+ complete_output_available = (
2430
+ total <= args.max_lines
2431
+ and visible_chars <= args.max_chars
2432
+ and not any_line_capped
2433
+ )
2434
+ if (
2435
+ not args.digest_always
2436
+ and not args.artifact_receipt
2437
+ and not args.bash_reference_v1
2438
+ and rc == 0
2439
+ and complete_output_available
2440
+ and passthrough_bytes < digest_bytes
2441
+ ):
2442
+ sys.stdout.write(marker)
2443
+ sys.stdout.write(passthrough)
1698
2444
  else:
1699
- sys.stdout.write(render_digest_markdown(payload, args.max_chars))
2445
+ sys.stdout.write(rendered)
1700
2446
  artifact_capture.close()
1701
2447
  return rc
1702
2448
 
@@ -1745,6 +2491,7 @@ def main() -> int:
1745
2491
  output += "[context-guard-kit] final summary was capped by --max-chars.\n"
1746
2492
  sys.stdout.write(output)
1747
2493
 
2494
+ abort_bash_reference_broker(bash_reference_broker)
1748
2495
  artifact_capture.close()
1749
2496
  return rc
1750
2497