@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.
- package/CHANGELOG.md +80 -0
- package/README.ko.md +128 -2
- package/README.md +144 -3
- package/docs/distribution.md +100 -0
- package/package.json +4 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +43 -1
- package/plugins/context-guard/README.md +44 -1
- package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
- package/plugins/context-guard/bin/context-guard-artifact +90 -9
- package/plugins/context-guard/bin/context-guard-audit +169 -66
- package/plugins/context-guard/bin/context-guard-bench +9865 -211
- package/plugins/context-guard/bin/context-guard-compress +90 -8
- package/plugins/context-guard/bin/context-guard-diet +1 -7
- package/plugins/context-guard/bin/context-guard-experiments +5 -1
- package/plugins/context-guard/bin/context-guard-failed-nudge +777 -83
- package/plugins/context-guard/bin/context-guard-guard-read +496 -57
- package/plugins/context-guard/bin/context-guard-mcp +2 -1
- package/plugins/context-guard/bin/context-guard-pack +1570 -150
- package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
- package/plugins/context-guard/bin/context-guard-rewrite-bash +2669 -236
- package/plugins/context-guard/bin/context-guard-sanitize-output +723 -92
- package/plugins/context-guard/bin/context-guard-setup +1944 -222
- package/plugins/context-guard/bin/context-guard-statusline +163 -55
- package/plugins/context-guard/bin/context-guard-statusline-merged +78 -23
- package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
- package/plugins/context-guard/bin/context-guard-trim-output +795 -48
- package/plugins/context-guard/brief/README.md +19 -0
- package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
- package/plugins/context-guard/lib/context_guard_commands.py +10 -2
- package/plugins/context-guard/lib/credential_policy.py +185 -0
- 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__(
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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.
|
|
1697
|
-
|
|
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(
|
|
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
|
|