@ictechgy/context-guard 0.4.16 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/README.ko.md +91 -1
- package/README.md +95 -1
- package/docs/distribution.md +100 -0
- package/package.json +5 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +34 -1
- package/plugins/context-guard/README.md +36 -1
- package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
- package/plugins/context-guard/bin/context-guard-artifact +1 -1
- package/plugins/context-guard/bin/context-guard-bench +4216 -103
- package/plugins/context-guard/bin/context-guard-failed-nudge +95 -23
- package/plugins/context-guard/bin/context-guard-guard-read +6 -2
- package/plugins/context-guard/bin/context-guard-mcp +2 -1
- package/plugins/context-guard/bin/context-guard-pack +2086 -142
- package/plugins/context-guard/bin/context-guard-rewrite-bash +497 -45
- package/plugins/context-guard/bin/context-guard-sanitize-output +178 -22
- package/plugins/context-guard/bin/context-guard-setup +901 -28
- package/plugins/context-guard/bin/context-guard-statusline +33 -2
- package/plugins/context-guard/bin/context-guard-statusline-merged +71 -20
- package/plugins/context-guard/bin/context-guard-task-memory +635 -0
- package/plugins/context-guard/bin/context-guard-trim-output +706 -35
- package/plugins/context-guard/lib/context_guard_commands.py +25 -1
- package/plugins/context-guard/lib/context_pack_git_boundary.py +19 -0
- package/plugins/context-guard/lib/context_pack_identity.py +115 -0
- package/plugins/context-guard/lib/context_pack_receipts.py +9 -0
- package/plugins/context-guard/lib/context_pack_rendering.py +12 -0
- package/plugins/context-guard/lib/context_pack_scanning.py +10 -0
- package/plugins/context-guard/lib/context_pack_selection.py +6 -0
- package/plugins/context-guard/lib/credential_policy.py +10 -2
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
|