@ictechgy/context-guard 0.4.11 → 0.4.12

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.
@@ -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')