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