@ictechgy/context-guard 0.4.13 → 0.4.15
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 +13 -0
- package/README.ko.md +36 -3
- package/README.md +40 -3
- package/docs/benchmark-fixtures/image-context-pack-full-evidence.prompt.example.md +28 -0
- package/docs/benchmark-fixtures/image-context-pack-packed-evidence.prompt.example.md +31 -0
- package/docs/benchmark-fixtures/image-context-pack.evidence.example.jsonl +2 -0
- package/docs/benchmark-fixtures/image-context-pack.tasks.example.json +18 -0
- package/docs/benchmark-fixtures/image-context-pack.variants.example.json +10 -0
- package/docs/benchmark-workflow-examples.md +16 -0
- package/docs/experimental-benchmark-fixtures.md +52 -1
- package/package.json +2 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +30 -1
- package/plugins/context-guard/README.md +33 -2
- package/plugins/context-guard/bin/context-guard-bench +1305 -115
- package/plugins/context-guard/bin/context-guard-experiments +3548 -146
- package/plugins/context-guard/bin/context-guard-mcp +999 -0
- package/plugins/context-guard/bin/context-guard-pack +634 -9
- package/plugins/context-guard/lib/context_guard_commands.py +8 -0
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Strict local stdio MCP adapter for the ContextGuard helper CLIs.
|
|
3
|
+
|
|
4
|
+
This module deliberately has no network/client configuration surface. It is a
|
|
5
|
+
small JSON-RPC membrane around the sibling compressor and artifact helpers.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import fcntl
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import signal
|
|
18
|
+
import stat
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
SUPPORTED_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26")
|
|
26
|
+
MAX_MESSAGE_BYTES = 1024 * 1024
|
|
27
|
+
MAX_CONTENT_BYTES = 768 * 1024
|
|
28
|
+
MAX_RETURN_BYTES = 20 * 1024
|
|
29
|
+
MAX_RETRIEVE_CHARS = 20_000
|
|
30
|
+
MAX_RETRIEVE_LINES = 500
|
|
31
|
+
MAX_CALLS = 1000
|
|
32
|
+
MAX_ARTIFACTS = 1000
|
|
33
|
+
MAX_VISITED_ENTRIES = 4000
|
|
34
|
+
HELPER_TIMEOUT = 10.0
|
|
35
|
+
MAX_HELPER_STDERR = 16 * 1024
|
|
36
|
+
MAX_HELPER_OUTPUT = 5 * 1024 * 1024
|
|
37
|
+
MAX_RESPONSE_BYTES = 128 * 1024
|
|
38
|
+
MAX_JSON_DEPTH = 64
|
|
39
|
+
ARTIFACT_ID_RE = re.compile(r"^[a-f0-9]{20}$")
|
|
40
|
+
NAMESPACE_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
|
|
41
|
+
LINES_RE = re.compile(r"^[1-9][0-9]{0,6}(?::[1-9][0-9]{0,6})?$")
|
|
42
|
+
CONTENT_TYPES = ("json", "diff", "log", "search", "code", "prose")
|
|
43
|
+
MODES = ("conservative", "readable")
|
|
44
|
+
PROTECTED_CLASSES = (
|
|
45
|
+
"code_fence", "diff", "identifier", "numeric_constant", "hash", "path",
|
|
46
|
+
"stack_frame", "quoted_string", "json_key",
|
|
47
|
+
)
|
|
48
|
+
ERROR_MESSAGES = {
|
|
49
|
+
"invalid_arguments": "Invalid tool arguments.",
|
|
50
|
+
"input_too_large": "Content exceeds the configured byte limit.",
|
|
51
|
+
"result_too_large_without_fallback": "Compressed result exceeds the response limit without a stored fallback.",
|
|
52
|
+
"artifact_not_found": "Artifact not found in this namespace.",
|
|
53
|
+
"artifact_invalid": "Artifact failed integrity validation.",
|
|
54
|
+
"namespace_full": "Namespace artifact limit reached.",
|
|
55
|
+
"namespace_ambiguous": "Namespace storage is ambiguous.",
|
|
56
|
+
"helper_failed": "Local helper failed.",
|
|
57
|
+
"rate_limit_reached": "Tool call limit reached.",
|
|
58
|
+
"response_too_large": "Response exceeds the configured limit.",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DuplicateKey(ValueError):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NonFinite(ValueError):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
71
|
+
value: dict[str, Any] = {}
|
|
72
|
+
for key, item in pairs:
|
|
73
|
+
if key in value:
|
|
74
|
+
raise DuplicateKey(key)
|
|
75
|
+
value[key] = item
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def reject_nonfinite(_value: str) -> None:
|
|
80
|
+
raise NonFinite()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compact(value: object) -> str:
|
|
84
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def line_count(value: str) -> int:
|
|
88
|
+
return value.count("\n") + (1 if value and not value.endswith("\n") else 0)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cap_utf8(value: str, limit: int) -> tuple[str, bool]:
|
|
92
|
+
data = value.encode("utf-8")
|
|
93
|
+
if len(data) <= limit:
|
|
94
|
+
return value, False
|
|
95
|
+
out: list[str] = []
|
|
96
|
+
used = 0
|
|
97
|
+
for char in value:
|
|
98
|
+
encoded = char.encode("utf-8")
|
|
99
|
+
if used + len(encoded) > limit:
|
|
100
|
+
break
|
|
101
|
+
out.append(char)
|
|
102
|
+
used += len(encoded)
|
|
103
|
+
return "".join(out), True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_safe_scalar(value: object) -> bool:
|
|
107
|
+
"""Validate JSON-shaped values without recursive traversal or encoding.
|
|
108
|
+
|
|
109
|
+
Parsed wire data is attacker-controlled. Keep its nesting below a fixed
|
|
110
|
+
bound so neither this guard nor a later serializer can reach Python's
|
|
111
|
+
recursion limit. The identity set also makes the helper safe for direct
|
|
112
|
+
unit-test inputs containing an accidental container cycle.
|
|
113
|
+
"""
|
|
114
|
+
pending: list[tuple[object, int]] = [(value, 0)]
|
|
115
|
+
seen: set[int] = set()
|
|
116
|
+
while pending:
|
|
117
|
+
item, depth = pending.pop()
|
|
118
|
+
if isinstance(item, str):
|
|
119
|
+
if any(0xD800 <= ord(char) <= 0xDFFF for char in item):
|
|
120
|
+
return False
|
|
121
|
+
continue
|
|
122
|
+
if isinstance(item, list):
|
|
123
|
+
if depth >= MAX_JSON_DEPTH or id(item) in seen:
|
|
124
|
+
return False
|
|
125
|
+
seen.add(id(item))
|
|
126
|
+
pending.extend((child, depth + 1) for child in item)
|
|
127
|
+
continue
|
|
128
|
+
if isinstance(item, dict):
|
|
129
|
+
if depth >= MAX_JSON_DEPTH or id(item) in seen:
|
|
130
|
+
return False
|
|
131
|
+
seen.add(id(item))
|
|
132
|
+
for key, child in item.items():
|
|
133
|
+
if not isinstance(key, str) or any(0xD800 <= ord(char) <= 0xDFFF for char in key):
|
|
134
|
+
return False
|
|
135
|
+
pending.append((child, depth + 1))
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def safe_utf8_bytes(value: str) -> bytes | None:
|
|
140
|
+
"""Encode only JSON strings that can safely be returned on the wire."""
|
|
141
|
+
try:
|
|
142
|
+
if not is_safe_scalar(value):
|
|
143
|
+
return None
|
|
144
|
+
return value.encode("utf-8")
|
|
145
|
+
except UnicodeEncodeError:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cap_codepoints(value: str, limit: int) -> tuple[str, bool]:
|
|
150
|
+
return (value, False) if len(value) <= limit else (value[:limit], True)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def error_response(request_id: object, code: int, message: str) -> dict[str, object]:
|
|
154
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def tool_error(code: str) -> dict[str, object]:
|
|
158
|
+
return {
|
|
159
|
+
"schema_version": "contextguard.mcp.tool-error.v1",
|
|
160
|
+
"error": {"code": code, "message": ERROR_MESSAGES[code], "retryable": False},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def call_result(payload: dict[str, object], is_error: bool) -> dict[str, object]:
|
|
165
|
+
text = compact(payload)
|
|
166
|
+
return {"content": [{"type": "text", "text": text}], "structuredContent": payload, "isError": is_error}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def nonnegative_int(value: object) -> bool:
|
|
170
|
+
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def exact_object(value: object, keys: set[str]) -> bool:
|
|
174
|
+
return isinstance(value, dict) and set(value) == keys
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def valid_tool_payload(payload: object, is_error: bool) -> bool:
|
|
178
|
+
"""The single recursive allowlist for all MCP structured tool payloads."""
|
|
179
|
+
if not isinstance(payload, dict) or not is_safe_scalar(payload):
|
|
180
|
+
return False
|
|
181
|
+
if is_error:
|
|
182
|
+
return (exact_object(payload, {"schema_version", "error"})
|
|
183
|
+
and payload.get("schema_version") == "contextguard.mcp.tool-error.v1"
|
|
184
|
+
and exact_object(payload.get("error"), {"code", "message", "retryable"})
|
|
185
|
+
and payload["error"].get("code") in ERROR_MESSAGES
|
|
186
|
+
and payload["error"].get("message") == ERROR_MESSAGES[payload["error"]["code"]]
|
|
187
|
+
and payload["error"].get("retryable") is False)
|
|
188
|
+
version = payload.get("schema_version")
|
|
189
|
+
if version == "contextguard.mcp.compress.v1":
|
|
190
|
+
if not exact_object(payload, {"schema_version", "content", "content_capped", "compression", "artifact"}):
|
|
191
|
+
return False
|
|
192
|
+
c = payload["compression"]
|
|
193
|
+
if not isinstance(payload["content"], str) or not isinstance(payload["content_capped"], bool) or not exact_object(c, {"content_type", "type_source", "strategy", "lossy", "bytes", "lines", "token_proxy", "redaction", "protected_policy"}):
|
|
194
|
+
return False
|
|
195
|
+
if c["content_type"] not in CONTENT_TYPES or c["type_source"] not in ("detected", "override") or c["strategy"] not in {"json-compact", "diff-keep-changes", "log-collapse-repeats", "search-dedupe", "code-whitespace", "prose-whitespace", "prose-readable-window"} or not isinstance(c["lossy"], bool):
|
|
196
|
+
return False
|
|
197
|
+
for section, keys in (("bytes", {"measurement", "original", "compressed", "returned"}), ("lines", {"measurement", "original", "compressed", "returned"})):
|
|
198
|
+
if not exact_object(c[section], keys) or c[section].get("measurement") != "observed" or not all(nonnegative_int(c[section][key]) for key in keys - {"measurement"}):
|
|
199
|
+
return False
|
|
200
|
+
token = c["token_proxy"]
|
|
201
|
+
redaction = c["redaction"]
|
|
202
|
+
policy = c["protected_policy"]
|
|
203
|
+
if not (exact_object(token, {"measurement", "method", "original", "compressed"}) and token.get("measurement") == "estimated" and token.get("method") == "chars_div_4" and nonnegative_int(token.get("original")) and nonnegative_int(token.get("compressed")) and exact_object(redaction, {"redacted_lines", "redacted_before_receipt"}) and nonnegative_int(redaction.get("redacted_lines")) and redaction.get("redacted_before_receipt") is True and exact_object(policy, {"enabled", "retrieval_required", "detected_classes"}) and isinstance(policy.get("enabled"), bool) and isinstance(policy.get("retrieval_required"), bool) and isinstance(policy.get("detected_classes"), list) and policy["detected_classes"] == sorted(set(policy["detected_classes"])) and all(item in PROTECTED_CLASSES for item in policy["detected_classes"])):
|
|
204
|
+
return False
|
|
205
|
+
artifact = payload["artifact"]
|
|
206
|
+
return artifact is None or (exact_object(artifact, {"artifact_id", "handle", "stored_bytes", "stored_lines", "exact_scope", "retrieve"}) and isinstance(artifact.get("artifact_id"), str) and bool(ARTIFACT_ID_RE.fullmatch(artifact["artifact_id"])) and artifact.get("handle") == "contextguard-artifact:" + artifact["artifact_id"] and nonnegative_int(artifact.get("stored_bytes")) and nonnegative_int(artifact.get("stored_lines")) and artifact.get("exact_scope") == "sanitized_accepted_input" and exact_object(artifact.get("retrieve"), {"tool", "arguments"}) and artifact["retrieve"].get("tool") == "context_guard_retrieve" and exact_object(artifact["retrieve"].get("arguments"), {"artifact_id"}) and artifact["retrieve"]["arguments"].get("artifact_id") == artifact["artifact_id"])
|
|
207
|
+
if version == "contextguard.mcp.retrieve.v1":
|
|
208
|
+
if not exact_object(payload, {"schema_version", "artifact_id", "content", "content_capped", "returned_bytes", "query", "stored"}):
|
|
209
|
+
return False
|
|
210
|
+
query = payload["query"]
|
|
211
|
+
stored = payload["stored"]
|
|
212
|
+
query_keys = {"type", "returned_lines", "matched_lines", "total_lines"}
|
|
213
|
+
if isinstance(query, dict) and query.get("type") == "lines":
|
|
214
|
+
query_keys |= {"start", "end"}
|
|
215
|
+
return (isinstance(payload["artifact_id"], str) and bool(ARTIFACT_ID_RE.fullmatch(payload["artifact_id"])) and isinstance(payload["content"], str) and isinstance(payload["content_capped"], bool) and nonnegative_int(payload["returned_bytes"]) and payload["returned_bytes"] == len(payload["content"].encode("utf-8")) and exact_object(query, query_keys) and query.get("type") in ("head", "lines", "pattern") and all(nonnegative_int(query[key]) for key in {"returned_lines", "matched_lines", "total_lines"}) and (query.get("type") != "lines" or nonnegative_int(query.get("start")) and nonnegative_int(query.get("end"))) and exact_object(stored, {"bytes", "lines"}) and nonnegative_int(stored.get("bytes")) and nonnegative_int(stored.get("lines")))
|
|
216
|
+
if version == "contextguard.mcp.stats.v1":
|
|
217
|
+
if not exact_object(payload, {"schema_version", "namespace", "session", "storage"}):
|
|
218
|
+
return False
|
|
219
|
+
namespace, session, storage = payload["namespace"], payload["session"], payload["storage"]
|
|
220
|
+
return (exact_object(namespace, {"fingerprint", "scope", "isolation"}) and isinstance(namespace.get("fingerprint"), str) and bool(re.fullmatch(r"[a-f0-9]{16}", namespace["fingerprint"])) and namespace.get("scope") == "session" and namespace.get("isolation") == "single_root_single_namespace" and exact_object(session, {"tool_calls", "tool_errors", "protocol_errors", "accepted_input_bytes", "compressed_bytes", "retrieved_bytes", "redacted_lines"}) and all(exact_object(session[key], set(TOOL_NAMES)) and all(nonnegative_int(item) for item in session[key].values()) for key in ("tool_calls", "tool_errors")) and all(nonnegative_int(session[key]) for key in {"protocol_errors", "accepted_input_bytes", "compressed_bytes", "retrieved_bytes", "redacted_lines"}) and exact_object(storage, {"artifacts_observed", "stored_bytes_observed", "visited_entries", "artifact_cap", "visited_entry_cap", "ambiguous", "artifact_cap_reached", "scan_capped"}) and all(nonnegative_int(storage[key]) for key in {"artifacts_observed", "stored_bytes_observed", "visited_entries", "artifact_cap", "visited_entry_cap"}) and isinstance(storage.get("ambiguous"), bool) and isinstance(storage.get("artifact_cap_reached"), bool) and isinstance(storage.get("scan_capped"), bool))
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def safe_regular(path: Path, *, executable: bool = False) -> Path:
|
|
225
|
+
st = os.lstat(path)
|
|
226
|
+
if not stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode):
|
|
227
|
+
raise ValueError("invalid trusted helper layout")
|
|
228
|
+
if executable and not st.st_mode & stat.S_IXUSR:
|
|
229
|
+
raise ValueError("invalid trusted helper layout")
|
|
230
|
+
return path.resolve(strict=True)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def directory_flags() -> int:
|
|
234
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
235
|
+
return flags | getattr(os, "O_CLOEXEC", 0)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def regular_read_flags() -> int:
|
|
239
|
+
return os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class Server:
|
|
243
|
+
def __init__(self, root: Path, namespace: str) -> None:
|
|
244
|
+
if not NAMESPACE_RE.fullmatch(namespace):
|
|
245
|
+
raise ValueError("invalid namespace")
|
|
246
|
+
if root.is_symlink() or not root.is_dir():
|
|
247
|
+
raise ValueError("invalid root")
|
|
248
|
+
self.root = root.resolve(strict=True)
|
|
249
|
+
self.root_fd = os.open(self.root, directory_flags())
|
|
250
|
+
if not stat.S_ISDIR(os.fstat(self.root_fd).st_mode):
|
|
251
|
+
os.close(self.root_fd)
|
|
252
|
+
raise ValueError("invalid root")
|
|
253
|
+
self.namespace_fd = -1
|
|
254
|
+
self.lock_fd = -1
|
|
255
|
+
self.server_dir = Path(__file__).resolve().parent
|
|
256
|
+
try:
|
|
257
|
+
self.compress_helper, self.artifact_helper = self._trusted_layout()
|
|
258
|
+
self.namespace_dir = self._namespace_dir(namespace)
|
|
259
|
+
self.lock_fd = self._lock_namespace()
|
|
260
|
+
except BaseException:
|
|
261
|
+
self.close()
|
|
262
|
+
raise
|
|
263
|
+
self.secret = os.urandom(32)
|
|
264
|
+
self.state = "PRE_INIT"
|
|
265
|
+
self.calls = {name: 0 for name in TOOL_NAMES}
|
|
266
|
+
self.errors = {name: 0 for name in TOOL_NAMES}
|
|
267
|
+
self.protocol_errors = 0
|
|
268
|
+
self.accepted_input_bytes = 0
|
|
269
|
+
self.compressed_bytes = 0
|
|
270
|
+
self.retrieved_bytes = 0
|
|
271
|
+
self.redacted_lines = 0
|
|
272
|
+
self.attempts = 0
|
|
273
|
+
|
|
274
|
+
def _trusted_layout(self) -> tuple[Path, Path]:
|
|
275
|
+
here = self.server_dir
|
|
276
|
+
current = Path(__file__).name
|
|
277
|
+
source = current == "context_guard_mcp.py"
|
|
278
|
+
expected = (
|
|
279
|
+
("context_guard_mcp.py", "context_compress.py", "context_escrow.py", "sanitize_output.py")
|
|
280
|
+
if source else
|
|
281
|
+
("context-guard-mcp", "context-guard-compress", "context-guard-artifact", "context-guard-sanitize-output")
|
|
282
|
+
)
|
|
283
|
+
if current != expected[0]:
|
|
284
|
+
raise ValueError("invalid trusted helper layout")
|
|
285
|
+
alternate = (
|
|
286
|
+
("context-guard-mcp", "context-guard-compress", "context-guard-artifact", "context-guard-sanitize-output")
|
|
287
|
+
if source else
|
|
288
|
+
("context_guard_mcp.py", "context_compress.py", "context_escrow.py", "sanitize_output.py")
|
|
289
|
+
)
|
|
290
|
+
if any((here / name).exists() or (here / name).is_symlink() for name in alternate):
|
|
291
|
+
raise ValueError("invalid trusted helper layout")
|
|
292
|
+
resolved = [safe_regular(here / name, executable=not source) for name in expected]
|
|
293
|
+
if any(item.parent != here for item in resolved):
|
|
294
|
+
raise ValueError("invalid trusted helper layout")
|
|
295
|
+
return resolved[1], resolved[2]
|
|
296
|
+
|
|
297
|
+
def _open_private_child_dir(self, parent_fd: int, name: str) -> int:
|
|
298
|
+
"""Create/open one storage component without ever following its name."""
|
|
299
|
+
try:
|
|
300
|
+
os.mkdir(name, 0o700, dir_fd=parent_fd)
|
|
301
|
+
except FileExistsError:
|
|
302
|
+
pass
|
|
303
|
+
fd = os.open(name, directory_flags(), dir_fd=parent_fd)
|
|
304
|
+
try:
|
|
305
|
+
st = os.fstat(fd)
|
|
306
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
307
|
+
raise ValueError("invalid namespace storage")
|
|
308
|
+
os.fchmod(fd, stat.S_IMODE(st.st_mode) & 0o700)
|
|
309
|
+
return fd
|
|
310
|
+
except BaseException:
|
|
311
|
+
os.close(fd)
|
|
312
|
+
raise
|
|
313
|
+
|
|
314
|
+
def _namespace_dir(self, namespace: str) -> Path:
|
|
315
|
+
digest = hashlib.sha256(
|
|
316
|
+
b"contextguard.mcp.namespace.v1\0" + str(self.root).encode("utf-8") + b"\0" + namespace.encode("utf-8")
|
|
317
|
+
).hexdigest()[:24]
|
|
318
|
+
parent_fd = self.root_fd
|
|
319
|
+
opened: list[int] = []
|
|
320
|
+
try:
|
|
321
|
+
for component in (".context-guard", "mcp", "ns-" + digest):
|
|
322
|
+
child = self._open_private_child_dir(parent_fd, component)
|
|
323
|
+
opened.append(child)
|
|
324
|
+
parent_fd = child
|
|
325
|
+
self.namespace_fd = opened[-1]
|
|
326
|
+
for fd in opened[:-1]:
|
|
327
|
+
os.close(fd)
|
|
328
|
+
return self.root / ".context-guard" / "mcp" / ("ns-" + digest)
|
|
329
|
+
except BaseException:
|
|
330
|
+
for fd in opened:
|
|
331
|
+
try:
|
|
332
|
+
os.close(fd)
|
|
333
|
+
except OSError:
|
|
334
|
+
pass
|
|
335
|
+
raise
|
|
336
|
+
|
|
337
|
+
def _lock_namespace(self) -> int:
|
|
338
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
|
|
339
|
+
fd = os.open(".context-guard-mcp.lock", flags, 0o600, dir_fd=self.namespace_fd)
|
|
340
|
+
try:
|
|
341
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
342
|
+
raise OSError()
|
|
343
|
+
os.fchmod(fd, 0o600)
|
|
344
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
345
|
+
return fd
|
|
346
|
+
except OSError:
|
|
347
|
+
os.close(fd)
|
|
348
|
+
raise ValueError("namespace is already in use")
|
|
349
|
+
|
|
350
|
+
def close(self) -> None:
|
|
351
|
+
if self.lock_fd >= 0:
|
|
352
|
+
try:
|
|
353
|
+
fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
|
|
354
|
+
except OSError:
|
|
355
|
+
pass
|
|
356
|
+
try:
|
|
357
|
+
os.close(self.lock_fd)
|
|
358
|
+
except OSError:
|
|
359
|
+
pass
|
|
360
|
+
self.lock_fd = -1
|
|
361
|
+
if self.namespace_fd >= 0:
|
|
362
|
+
try:
|
|
363
|
+
os.close(self.namespace_fd)
|
|
364
|
+
except OSError:
|
|
365
|
+
pass
|
|
366
|
+
self.namespace_fd = -1
|
|
367
|
+
if getattr(self, "root_fd", -1) >= 0:
|
|
368
|
+
try:
|
|
369
|
+
os.close(self.root_fd)
|
|
370
|
+
except OSError:
|
|
371
|
+
pass
|
|
372
|
+
self.root_fd = -1
|
|
373
|
+
|
|
374
|
+
def fingerprint(self) -> str:
|
|
375
|
+
return hmac.new(self.secret, self.namespace_dir.name.encode("ascii"), hashlib.sha256).hexdigest()[:16]
|
|
376
|
+
|
|
377
|
+
def run_helper(self, argv: list[str], stdin: bytes, stdout_cap: int) -> dict[str, object] | None:
|
|
378
|
+
env = {"PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1", "LANG": "C", "LC_ALL": "C"}
|
|
379
|
+
deadline = time.monotonic() + HELPER_TIMEOUT
|
|
380
|
+
try:
|
|
381
|
+
proc = subprocess.Popen(
|
|
382
|
+
[sys.executable, *map(str, argv)], cwd=self.root, env=env, stdin=subprocess.PIPE,
|
|
383
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False, close_fds=True, start_new_session=True,
|
|
384
|
+
)
|
|
385
|
+
except OSError:
|
|
386
|
+
return None
|
|
387
|
+
output = {"stdout": bytearray(), "stderr": bytearray()}
|
|
388
|
+
overflow = threading.Event()
|
|
389
|
+
writer_done = threading.Event()
|
|
390
|
+
writer_failed = threading.Event()
|
|
391
|
+
|
|
392
|
+
def drain(name: str, stream: Any, cap: int) -> None:
|
|
393
|
+
try:
|
|
394
|
+
while True:
|
|
395
|
+
part = stream.read(65536)
|
|
396
|
+
if not part:
|
|
397
|
+
return
|
|
398
|
+
if len(output[name]) + len(part) > cap:
|
|
399
|
+
overflow.set()
|
|
400
|
+
# Continue draining after the bounded capture is full so a
|
|
401
|
+
# malicious child cannot keep a pipe or a grandchild alive.
|
|
402
|
+
elif not overflow.is_set():
|
|
403
|
+
output[name].extend(part)
|
|
404
|
+
finally:
|
|
405
|
+
try:
|
|
406
|
+
stream.close()
|
|
407
|
+
except OSError:
|
|
408
|
+
pass
|
|
409
|
+
|
|
410
|
+
def write_stdin() -> None:
|
|
411
|
+
try:
|
|
412
|
+
assert proc.stdin is not None
|
|
413
|
+
view = memoryview(stdin)
|
|
414
|
+
while view:
|
|
415
|
+
written = proc.stdin.write(view)
|
|
416
|
+
if written is None:
|
|
417
|
+
written = 0
|
|
418
|
+
view = view[written:]
|
|
419
|
+
proc.stdin.flush()
|
|
420
|
+
except (BrokenPipeError, OSError, ValueError):
|
|
421
|
+
writer_failed.set()
|
|
422
|
+
finally:
|
|
423
|
+
try:
|
|
424
|
+
if proc.stdin is not None:
|
|
425
|
+
proc.stdin.close()
|
|
426
|
+
except OSError:
|
|
427
|
+
pass
|
|
428
|
+
writer_done.set()
|
|
429
|
+
|
|
430
|
+
threads = [
|
|
431
|
+
threading.Thread(target=drain, args=("stdout", proc.stdout, stdout_cap), daemon=True),
|
|
432
|
+
threading.Thread(target=drain, args=("stderr", proc.stderr, MAX_HELPER_STDERR), daemon=True),
|
|
433
|
+
]
|
|
434
|
+
for thread in threads:
|
|
435
|
+
thread.start()
|
|
436
|
+
writer = threading.Thread(target=write_stdin)
|
|
437
|
+
writer.start()
|
|
438
|
+
failed = False
|
|
439
|
+
termination_started = False
|
|
440
|
+
|
|
441
|
+
def terminate_group() -> None:
|
|
442
|
+
nonlocal termination_started
|
|
443
|
+
if termination_started:
|
|
444
|
+
return
|
|
445
|
+
termination_started = True
|
|
446
|
+
pgid = proc.pid
|
|
447
|
+
try:
|
|
448
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
449
|
+
except OSError:
|
|
450
|
+
try:
|
|
451
|
+
proc.terminate()
|
|
452
|
+
except OSError:
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
# The direct helper may exit while a forked descendant keeps the
|
|
456
|
+
# process group and inherited pipes alive. Give the whole group a
|
|
457
|
+
# bounded TERM grace period, then escalate the group independently
|
|
458
|
+
# of the direct child's return code.
|
|
459
|
+
grace_deadline = time.monotonic() + 0.5
|
|
460
|
+
group_exists = True
|
|
461
|
+
while time.monotonic() < grace_deadline:
|
|
462
|
+
try:
|
|
463
|
+
os.killpg(pgid, 0)
|
|
464
|
+
except ProcessLookupError:
|
|
465
|
+
group_exists = False
|
|
466
|
+
break
|
|
467
|
+
except OSError:
|
|
468
|
+
break
|
|
469
|
+
time.sleep(min(0.01, max(0.0, grace_deadline - time.monotonic())))
|
|
470
|
+
if group_exists:
|
|
471
|
+
try:
|
|
472
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
473
|
+
except ProcessLookupError:
|
|
474
|
+
pass
|
|
475
|
+
except OSError:
|
|
476
|
+
try:
|
|
477
|
+
proc.kill()
|
|
478
|
+
except OSError:
|
|
479
|
+
pass
|
|
480
|
+
try:
|
|
481
|
+
proc.wait(timeout=0.5)
|
|
482
|
+
except subprocess.TimeoutExpired:
|
|
483
|
+
try:
|
|
484
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
485
|
+
except OSError:
|
|
486
|
+
try:
|
|
487
|
+
proc.kill()
|
|
488
|
+
except OSError:
|
|
489
|
+
pass
|
|
490
|
+
|
|
491
|
+
while proc.poll() is None or not writer_done.is_set():
|
|
492
|
+
if overflow.is_set() or writer_failed.is_set() or time.monotonic() >= deadline:
|
|
493
|
+
failed = True
|
|
494
|
+
terminate_group()
|
|
495
|
+
break
|
|
496
|
+
time.sleep(0.01)
|
|
497
|
+
try:
|
|
498
|
+
proc.wait(timeout=0.5)
|
|
499
|
+
except subprocess.TimeoutExpired:
|
|
500
|
+
failed = True
|
|
501
|
+
terminate_group()
|
|
502
|
+
if not writer_done.is_set():
|
|
503
|
+
failed = True
|
|
504
|
+
terminate_group()
|
|
505
|
+
try:
|
|
506
|
+
if proc.stdin is not None:
|
|
507
|
+
proc.stdin.close()
|
|
508
|
+
except OSError:
|
|
509
|
+
pass
|
|
510
|
+
# All pipe workers must finish: never leave daemon pipe threads or a
|
|
511
|
+
# process group behind after a timed-out/non-reading helper.
|
|
512
|
+
for thread in (*threads, writer):
|
|
513
|
+
thread.join(1.0)
|
|
514
|
+
if any(thread.is_alive() for thread in (*threads, writer)):
|
|
515
|
+
failed = True
|
|
516
|
+
terminate_group()
|
|
517
|
+
for stream in (proc.stdin, proc.stdout, proc.stderr):
|
|
518
|
+
try:
|
|
519
|
+
if stream is not None:
|
|
520
|
+
stream.close()
|
|
521
|
+
except OSError:
|
|
522
|
+
pass
|
|
523
|
+
for thread in (*threads, writer):
|
|
524
|
+
thread.join(1.0)
|
|
525
|
+
if failed or overflow.is_set() or writer_failed.is_set() or any(thread.is_alive() for thread in (*threads, writer)) or proc.returncode != 0:
|
|
526
|
+
return None
|
|
527
|
+
try:
|
|
528
|
+
raw = bytes(output["stdout"]).decode("utf-8")
|
|
529
|
+
parsed = json.loads(raw, object_pairs_hook=reject_duplicates, parse_constant=reject_nonfinite)
|
|
530
|
+
if not isinstance(parsed, dict) or not is_safe_scalar(parsed):
|
|
531
|
+
return None
|
|
532
|
+
return parsed
|
|
533
|
+
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
def scan(self) -> dict[str, object]:
|
|
537
|
+
observed = 0
|
|
538
|
+
bytes_observed = 0
|
|
539
|
+
visited = 0
|
|
540
|
+
ambiguous = False
|
|
541
|
+
capped = False
|
|
542
|
+
names: dict[str, set[str]] = {}
|
|
543
|
+
try:
|
|
544
|
+
# scandir owns only the duplicate descriptor; the retained namespace
|
|
545
|
+
# descriptor remains the canonical authority for every later open.
|
|
546
|
+
with os.scandir(os.dup(self.namespace_fd)) as entries:
|
|
547
|
+
for entry in entries:
|
|
548
|
+
if entry.name == ".context-guard-mcp.lock":
|
|
549
|
+
continue
|
|
550
|
+
if visited >= MAX_VISITED_ENTRIES:
|
|
551
|
+
capped = True
|
|
552
|
+
ambiguous = True
|
|
553
|
+
break
|
|
554
|
+
visited += 1
|
|
555
|
+
match = re.fullmatch(r"([a-f0-9]{20})\.(txt|json)", entry.name)
|
|
556
|
+
if match is None:
|
|
557
|
+
ambiguous = True
|
|
558
|
+
continue
|
|
559
|
+
try:
|
|
560
|
+
st = os.stat(entry.name, dir_fd=self.namespace_fd, follow_symlinks=False)
|
|
561
|
+
if not stat.S_ISREG(st.st_mode):
|
|
562
|
+
ambiguous = True
|
|
563
|
+
continue
|
|
564
|
+
except OSError:
|
|
565
|
+
ambiguous = True
|
|
566
|
+
continue
|
|
567
|
+
names.setdefault(match.group(1), set()).add(match.group(2))
|
|
568
|
+
except OSError:
|
|
569
|
+
return {"observed": 0, "bytes": 0, "visited": 0, "ambiguous": True, "capped": False}
|
|
570
|
+
for artifact_id, pair in names.items():
|
|
571
|
+
if set(pair) != {"txt", "json"}:
|
|
572
|
+
ambiguous = True
|
|
573
|
+
continue
|
|
574
|
+
metadata_fd = -1
|
|
575
|
+
try:
|
|
576
|
+
txt_st = os.stat(artifact_id + ".txt", dir_fd=self.namespace_fd, follow_symlinks=False)
|
|
577
|
+
if not stat.S_ISREG(txt_st.st_mode):
|
|
578
|
+
raise ValueError()
|
|
579
|
+
metadata_fd = os.open(artifact_id + ".json", regular_read_flags(), dir_fd=self.namespace_fd)
|
|
580
|
+
st = os.fstat(metadata_fd)
|
|
581
|
+
if not stat.S_ISREG(st.st_mode) or st.st_size > 65536:
|
|
582
|
+
raise ValueError()
|
|
583
|
+
chunks: list[bytes] = []
|
|
584
|
+
remaining = st.st_size + 1
|
|
585
|
+
while remaining:
|
|
586
|
+
part = os.read(metadata_fd, min(65536, remaining))
|
|
587
|
+
if not part:
|
|
588
|
+
break
|
|
589
|
+
chunks.append(part)
|
|
590
|
+
remaining -= len(part)
|
|
591
|
+
os.close(metadata_fd)
|
|
592
|
+
metadata_fd = -1
|
|
593
|
+
raw_meta = b"".join(chunks)
|
|
594
|
+
if len(raw_meta) != st.st_size:
|
|
595
|
+
raise ValueError()
|
|
596
|
+
metadata = json.loads(raw_meta.decode("utf-8"), object_pairs_hook=reject_duplicates, parse_constant=reject_nonfinite)
|
|
597
|
+
stored = metadata.get("stored_output") if isinstance(metadata, dict) else None
|
|
598
|
+
valid = isinstance(stored, dict) and metadata.get("artifact_id") == artifact_id and stored.get("content_file") == artifact_id + ".txt" and stored.get("metadata_file") == artifact_id + ".json" and isinstance(stored.get("bytes"), int) and not isinstance(stored.get("bytes"), bool) and 0 <= stored["bytes"] <= MAX_CONTENT_BYTES and txt_st.st_size == stored["bytes"] and isinstance(stored.get("lines"), int) and not isinstance(stored.get("lines"), bool) and stored["lines"] >= 0 and isinstance(stored.get("sha256"), str) and bool(re.fullmatch(r"[a-f0-9]{64}", stored["sha256"]))
|
|
599
|
+
if not valid:
|
|
600
|
+
raise ValueError()
|
|
601
|
+
if observed < MAX_ARTIFACTS:
|
|
602
|
+
observed += 1
|
|
603
|
+
bytes_observed += stored["bytes"]
|
|
604
|
+
except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError, DuplicateKey, NonFinite):
|
|
605
|
+
ambiguous = True
|
|
606
|
+
finally:
|
|
607
|
+
if metadata_fd >= 0:
|
|
608
|
+
try:
|
|
609
|
+
os.close(metadata_fd)
|
|
610
|
+
except OSError:
|
|
611
|
+
pass
|
|
612
|
+
return {"observed": min(observed, MAX_ARTIFACTS), "bytes": bytes_observed, "visited": visited, "ambiguous": ambiguous, "capped": capped}
|
|
613
|
+
|
|
614
|
+
def result(self, tool: str, payload: dict[str, object], is_error: bool = False) -> dict[str, object]:
|
|
615
|
+
if not valid_tool_payload(payload, is_error) or self._payload_leaks_private_value(payload):
|
|
616
|
+
is_error = True
|
|
617
|
+
payload = tool_error("response_too_large")
|
|
618
|
+
if is_error:
|
|
619
|
+
self.errors[tool] += 1
|
|
620
|
+
result = call_result(payload, is_error)
|
|
621
|
+
if len(compact(result).encode("utf-8")) > MAX_RESPONSE_BYTES:
|
|
622
|
+
if not is_error:
|
|
623
|
+
self.errors[tool] += 1
|
|
624
|
+
return call_result(tool_error("response_too_large"), True)
|
|
625
|
+
return result
|
|
626
|
+
|
|
627
|
+
def _payload_leaks_private_value(self, value: object, *, content: bool = False) -> bool:
|
|
628
|
+
"""Reject internal values structurally; do not inspect sanctioned content."""
|
|
629
|
+
private = {
|
|
630
|
+
str(self.root), str(self.namespace_dir), str(self.compress_helper),
|
|
631
|
+
str(self.artifact_helper), self.namespace_dir.name,
|
|
632
|
+
}
|
|
633
|
+
if isinstance(value, dict):
|
|
634
|
+
return any(self._payload_leaks_private_value(item, content=content or key == "content") for key, item in value.items())
|
|
635
|
+
if isinstance(value, list):
|
|
636
|
+
return any(self._payload_leaks_private_value(item, content=content) for item in value)
|
|
637
|
+
if isinstance(value, str) and not content:
|
|
638
|
+
# Exact comparison protects short values; only long internal tokens
|
|
639
|
+
# receive containment checking, avoiding false positives for a one-byte
|
|
640
|
+
# namespace such as "A" in normal protocol strings.
|
|
641
|
+
return any(value == item or len(item) >= 8 and item in value for item in private)
|
|
642
|
+
return False
|
|
643
|
+
|
|
644
|
+
def fail(self, tool: str, code: str) -> dict[str, object]:
|
|
645
|
+
return self.result(tool, tool_error(code), True)
|
|
646
|
+
|
|
647
|
+
def compress(self, arguments: object) -> dict[str, object]:
|
|
648
|
+
tool = "context_guard_compress"
|
|
649
|
+
if not isinstance(arguments, dict) or set(arguments) - {"content", "content_type", "mode", "protected_policy", "store"} or not isinstance(arguments.get("content"), str):
|
|
650
|
+
return self.fail(tool, "invalid_arguments")
|
|
651
|
+
content = arguments["content"]
|
|
652
|
+
if not is_safe_scalar(content):
|
|
653
|
+
return self.fail(tool, "invalid_arguments")
|
|
654
|
+
content_type = arguments.get("content_type")
|
|
655
|
+
mode = arguments.get("mode", "conservative")
|
|
656
|
+
protected = arguments.get("protected_policy", True)
|
|
657
|
+
store = arguments.get("store", True)
|
|
658
|
+
if content_type is not None and content_type not in CONTENT_TYPES or mode not in MODES or not isinstance(protected, bool) or not isinstance(store, bool):
|
|
659
|
+
return self.fail(tool, "invalid_arguments")
|
|
660
|
+
raw = content.encode("utf-8")
|
|
661
|
+
if len(raw) > MAX_CONTENT_BYTES:
|
|
662
|
+
return self.fail(tool, "input_too_large")
|
|
663
|
+
self.accepted_input_bytes += len(raw)
|
|
664
|
+
if store:
|
|
665
|
+
scan = self.scan()
|
|
666
|
+
if scan["ambiguous"]:
|
|
667
|
+
return self.fail(tool, "namespace_ambiguous")
|
|
668
|
+
if scan["observed"] >= MAX_ARTIFACTS:
|
|
669
|
+
return self.fail(tool, "namespace_full")
|
|
670
|
+
argv: list[str] = [str(self.compress_helper), "--json", "--max-bytes", str(MAX_CONTENT_BYTES), "--mode", str(mode)]
|
|
671
|
+
if content_type is not None:
|
|
672
|
+
argv.extend(["--type", str(content_type)])
|
|
673
|
+
if protected:
|
|
674
|
+
argv.append("--protected-policy")
|
|
675
|
+
compressed = self.run_helper(argv, raw, min(MAX_HELPER_OUTPUT, len(raw) * 6 + 256 * 1024))
|
|
676
|
+
if compressed is None:
|
|
677
|
+
return self.fail(tool, "helper_failed")
|
|
678
|
+
text = compressed.get("content")
|
|
679
|
+
metadata = compressed.get("metadata")
|
|
680
|
+
if not isinstance(text, str) or not isinstance(metadata, dict):
|
|
681
|
+
return self.fail(tool, "helper_failed")
|
|
682
|
+
bytes_meta = metadata.get("bytes")
|
|
683
|
+
lines_meta = metadata.get("lines")
|
|
684
|
+
token_meta = metadata.get("token_proxy")
|
|
685
|
+
redaction = metadata.get("redaction")
|
|
686
|
+
policy = metadata.get("protected_zone_policy")
|
|
687
|
+
strategy = metadata.get("strategy")
|
|
688
|
+
selected_type = metadata.get("content_type")
|
|
689
|
+
type_source = metadata.get("type_source")
|
|
690
|
+
if not (isinstance(bytes_meta, dict) and isinstance(lines_meta, dict) and isinstance(token_meta, dict) and isinstance(redaction, dict) and isinstance(policy, dict) and selected_type in CONTENT_TYPES and type_source in ("detected", "override") and strategy in {"json-compact", "diff-keep-changes", "log-collapse-repeats", "search-dedupe", "code-whitespace", "prose-whitespace", "prose-readable-window"} and isinstance(metadata.get("lossy"), bool)):
|
|
691
|
+
return self.fail(tool, "helper_failed")
|
|
692
|
+
def integer(source: dict[str, object], key: str) -> int | None:
|
|
693
|
+
value = source.get(key)
|
|
694
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
|
|
695
|
+
original_b, compressed_b = integer(bytes_meta, "original"), integer(bytes_meta, "compressed")
|
|
696
|
+
original_l, compressed_l = integer(lines_meta, "original"), integer(lines_meta, "compressed")
|
|
697
|
+
original_t, compressed_t = integer(token_meta, "original"), integer(token_meta, "compressed")
|
|
698
|
+
redacted = integer(redaction, "redacted_lines")
|
|
699
|
+
if None in (original_b, compressed_b, original_l, compressed_l, original_t, compressed_t, redacted):
|
|
700
|
+
return self.fail(tool, "helper_failed")
|
|
701
|
+
self.compressed_bytes += compressed_b
|
|
702
|
+
self.redacted_lines += redacted
|
|
703
|
+
preview, capped = cap_utf8(text, MAX_RETURN_BYTES)
|
|
704
|
+
artifact: object = None
|
|
705
|
+
if store:
|
|
706
|
+
stored = self.run_helper([str(self.artifact_helper), "--dir", str(self.namespace_dir), "store", "--json", "--max-bytes", str(MAX_CONTENT_BYTES), "--command", "context-guard-mcp compress"], raw, 256 * 1024)
|
|
707
|
+
if stored is None:
|
|
708
|
+
return self.fail(tool, "helper_failed")
|
|
709
|
+
artifact_id = stored.get("artifact_id")
|
|
710
|
+
stored_output = stored.get("stored_output")
|
|
711
|
+
if not isinstance(artifact_id, str) or not ARTIFACT_ID_RE.fullmatch(artifact_id) or not isinstance(stored_output, dict):
|
|
712
|
+
return self.fail(tool, "helper_failed")
|
|
713
|
+
stored_bytes, stored_lines = integer(stored_output, "bytes"), integer(stored_output, "lines")
|
|
714
|
+
if stored_bytes is None or stored_lines is None:
|
|
715
|
+
return self.fail(tool, "helper_failed")
|
|
716
|
+
artifact = {"artifact_id": artifact_id, "handle": "contextguard-artifact:" + artifact_id, "stored_bytes": stored_bytes, "stored_lines": stored_lines, "exact_scope": "sanitized_accepted_input", "retrieve": {"tool": "context_guard_retrieve", "arguments": {"artifact_id": artifact_id}}}
|
|
717
|
+
elif capped:
|
|
718
|
+
return self.fail(tool, "result_too_large_without_fallback")
|
|
719
|
+
zone_counts = policy.get("zone_counts") if protected else {}
|
|
720
|
+
classes = sorted(key for key in PROTECTED_CLASSES if isinstance(zone_counts, dict) and isinstance(zone_counts.get(key), int) and zone_counts[key] > 0)
|
|
721
|
+
payload = {"schema_version": "contextguard.mcp.compress.v1", "content": preview, "content_capped": capped, "compression": {"content_type": selected_type, "type_source": type_source, "strategy": strategy, "lossy": metadata["lossy"], "bytes": {"measurement": "observed", "original": original_b, "compressed": compressed_b, "returned": len(preview.encode("utf-8"))}, "lines": {"measurement": "observed", "original": original_l, "compressed": compressed_l, "returned": line_count(preview)}, "token_proxy": {"measurement": "estimated", "method": "chars_div_4", "original": original_t, "compressed": compressed_t}, "redaction": {"redacted_lines": redacted, "redacted_before_receipt": True}, "protected_policy": {"enabled": protected, "retrieval_required": bool(policy.get("retrieval_required")) if protected else False, "detected_classes": classes}}, "artifact": artifact}
|
|
722
|
+
return self.result(tool, payload)
|
|
723
|
+
|
|
724
|
+
def retrieve(self, arguments: object) -> dict[str, object]:
|
|
725
|
+
tool = "context_guard_retrieve"
|
|
726
|
+
if not isinstance(arguments, dict) or set(arguments) - {"artifact_id", "lines", "pattern", "max_lines", "max_chars"}:
|
|
727
|
+
return self.fail(tool, "invalid_arguments")
|
|
728
|
+
artifact_id = arguments.get("artifact_id")
|
|
729
|
+
lines = arguments.get("lines")
|
|
730
|
+
pattern = arguments.get("pattern")
|
|
731
|
+
max_lines = arguments.get("max_lines", MAX_RETRIEVE_LINES)
|
|
732
|
+
max_chars = arguments.get("max_chars", MAX_RETRIEVE_CHARS)
|
|
733
|
+
if not isinstance(artifact_id, str) or not ARTIFACT_ID_RE.fullmatch(artifact_id) or lines is not None and not isinstance(lines, str) or pattern is not None and not isinstance(pattern, str) or lines is not None and pattern is not None or not isinstance(max_lines, int) or isinstance(max_lines, bool) or not 1 <= max_lines <= MAX_RETRIEVE_LINES or not isinstance(max_chars, int) or isinstance(max_chars, bool) or not 1 <= max_chars <= MAX_RETRIEVE_CHARS:
|
|
734
|
+
return self.fail(tool, "invalid_arguments")
|
|
735
|
+
if lines is not None:
|
|
736
|
+
if not LINES_RE.fullmatch(lines):
|
|
737
|
+
return self.fail(tool, "invalid_arguments")
|
|
738
|
+
parts = [int(item) for item in lines.split(":")]
|
|
739
|
+
if len(parts) == 1:
|
|
740
|
+
parts.append(parts[0])
|
|
741
|
+
if parts[0] > parts[1] or parts[1] > 10_000_000:
|
|
742
|
+
return self.fail(tool, "invalid_arguments")
|
|
743
|
+
if pattern is not None and (not is_safe_scalar(pattern) or "\0" in pattern or not 1 <= len(pattern.encode("utf-8")) <= 512):
|
|
744
|
+
return self.fail(tool, "invalid_arguments")
|
|
745
|
+
argv = [str(self.artifact_helper), "--dir", str(self.namespace_dir), "get", artifact_id, "--json", "--max-lines", str(max_lines), "--max-chars", str(max_chars)]
|
|
746
|
+
if lines is not None:
|
|
747
|
+
argv.extend(["--lines", lines])
|
|
748
|
+
elif pattern is not None:
|
|
749
|
+
argv.extend(["--pattern", pattern])
|
|
750
|
+
found = self.run_helper(argv, b"", 128 * 1024)
|
|
751
|
+
if found is None:
|
|
752
|
+
# The helper intentionally keeps error detail on stderr. It is not
|
|
753
|
+
# safe to classify it more finely, so verify pair shape locally.
|
|
754
|
+
present = []
|
|
755
|
+
malformed = False
|
|
756
|
+
for name in (artifact_id + ".txt", artifact_id + ".json"):
|
|
757
|
+
try:
|
|
758
|
+
st = os.stat(name, dir_fd=self.namespace_fd, follow_symlinks=False)
|
|
759
|
+
present.append(True)
|
|
760
|
+
malformed = malformed or not stat.S_ISREG(st.st_mode)
|
|
761
|
+
except FileNotFoundError:
|
|
762
|
+
present.append(False)
|
|
763
|
+
except OSError:
|
|
764
|
+
return self.fail(tool, "artifact_invalid")
|
|
765
|
+
if not any(present):
|
|
766
|
+
return self.fail(tool, "artifact_not_found")
|
|
767
|
+
return self.fail(tool, "artifact_invalid")
|
|
768
|
+
text, query, stored = found.get("content"), found.get("query"), found.get("stored_output")
|
|
769
|
+
if not isinstance(text, str) or not isinstance(query, dict) or not isinstance(stored, dict):
|
|
770
|
+
return self.fail(tool, "artifact_invalid")
|
|
771
|
+
selector = query.get("selector")
|
|
772
|
+
if not isinstance(selector, dict) or selector.get("type") not in ("head", "lines", "pattern"):
|
|
773
|
+
return self.fail(tool, "artifact_invalid")
|
|
774
|
+
returned_lines, matched_lines, total_lines = query.get("returned_lines"), query.get("matched_lines"), query.get("total_lines")
|
|
775
|
+
stored_bytes, stored_lines = stored.get("bytes"), stored.get("lines")
|
|
776
|
+
values = (returned_lines, matched_lines, total_lines, stored_bytes, stored_lines)
|
|
777
|
+
if not all(isinstance(value, int) and not isinstance(value, bool) and value >= 0 for value in values):
|
|
778
|
+
return self.fail(tool, "artifact_invalid")
|
|
779
|
+
text, chars_capped = cap_codepoints(text, max_chars)
|
|
780
|
+
text, bytes_capped = cap_utf8(text, MAX_RETURN_BYTES)
|
|
781
|
+
capped = chars_capped or bytes_capped
|
|
782
|
+
query_out: dict[str, object] = {"type": selector["type"], "returned_lines": returned_lines, "matched_lines": matched_lines, "total_lines": total_lines}
|
|
783
|
+
if selector["type"] == "lines":
|
|
784
|
+
start, end = selector.get("start"), selector.get("end")
|
|
785
|
+
if not isinstance(start, int) or not isinstance(end, int):
|
|
786
|
+
return self.fail(tool, "artifact_invalid")
|
|
787
|
+
query_out.update({"start": start, "end": end})
|
|
788
|
+
self.retrieved_bytes += len(text.encode("utf-8"))
|
|
789
|
+
return self.result(tool, {"schema_version": "contextguard.mcp.retrieve.v1", "artifact_id": artifact_id, "content": text, "content_capped": bool(found.get("capped")) or capped, "returned_bytes": len(text.encode("utf-8")), "query": query_out, "stored": {"bytes": stored_bytes, "lines": stored_lines}})
|
|
790
|
+
|
|
791
|
+
def stats(self, arguments: object) -> dict[str, object]:
|
|
792
|
+
tool = "context_guard_stats"
|
|
793
|
+
if not isinstance(arguments, dict) or arguments:
|
|
794
|
+
return self.fail(tool, "invalid_arguments")
|
|
795
|
+
scan = self.scan()
|
|
796
|
+
storage = {"artifacts_observed": scan["observed"], "stored_bytes_observed": scan["bytes"], "visited_entries": scan["visited"], "artifact_cap": MAX_ARTIFACTS, "visited_entry_cap": MAX_VISITED_ENTRIES, "ambiguous": scan["ambiguous"], "artifact_cap_reached": scan["observed"] >= MAX_ARTIFACTS, "scan_capped": scan["capped"]}
|
|
797
|
+
payload = {"schema_version": "contextguard.mcp.stats.v1", "namespace": {"fingerprint": self.fingerprint(), "scope": "session", "isolation": "single_root_single_namespace"}, "session": {"tool_calls": dict(self.calls), "tool_errors": dict(self.errors), "protocol_errors": self.protocol_errors, "accepted_input_bytes": self.accepted_input_bytes, "compressed_bytes": self.compressed_bytes, "retrieved_bytes": self.retrieved_bytes, "redacted_lines": self.redacted_lines}, "storage": storage}
|
|
798
|
+
return self.result(tool, payload)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
TOOL_NAMES = ("context_guard_compress", "context_guard_retrieve", "context_guard_stats")
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def tools() -> list[dict[str, object]]:
|
|
805
|
+
base = {"type": "object", "additionalProperties": False}
|
|
806
|
+
compress_schema = dict(base, properties={"content": {"type": "string", "maxLength": MAX_CONTENT_BYTES, "description": "Content to sanitize and compress; UTF-8 bytes are capped at 786432."}, "content_type": {"type": "string", "enum": list(CONTENT_TYPES), "description": "Optional content classification override."}, "mode": {"type": "string", "enum": list(MODES), "default": "conservative", "description": "Compression mode; conservative preserves more structure."}, "protected_policy": {"type": "boolean", "default": True, "description": "Preserve detected protected zones and require retrieval when needed."}, "store": {"type": "boolean", "default": True, "description": "Store sanitized accepted input for namespace-scoped retrieval."}}, required=["content"])
|
|
807
|
+
retrieve_schema = dict(base, properties={"artifact_id": {"type": "string", "pattern": "^[a-f0-9]{20}$", "description": "Namespace-scoped sanitized artifact identifier."}, "lines": {"type": "string", "pattern": "^[1-9][0-9]{0,6}(?::[1-9][0-9]{0,6})?$", "description": "Optional inclusive one-based line or line range selector."}, "pattern": {"type": "string", "minLength": 1, "maxLength": 512, "description": "Optional literal UTF-8 pattern selector."}, "max_lines": {"type": "integer", "minimum": 1, "maximum": MAX_RETRIEVE_LINES, "default": MAX_RETRIEVE_LINES, "description": "Maximum returned lines, limited to 500."}, "max_chars": {"type": "integer", "minimum": 1, "maximum": MAX_RETRIEVE_CHARS, "default": MAX_RETRIEVE_CHARS, "description": "Maximum returned Unicode code points, limited to 20000."}}, required=["artifact_id"], allOf=[{"not": {"required": ["lines", "pattern"]}}])
|
|
808
|
+
return [
|
|
809
|
+
{"name": "context_guard_compress", "description": "Sanitize, compress, and optionally retain local content.", "inputSchema": compress_schema, "annotations": {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": False, "openWorldHint": False}},
|
|
810
|
+
{"name": "context_guard_retrieve", "description": "Retrieve sanitized content from this local namespace.", "inputSchema": retrieve_schema, "annotations": {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False}},
|
|
811
|
+
{"name": "context_guard_stats", "description": "Return local session and namespace statistics.", "inputSchema": base, "annotations": {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False}},
|
|
812
|
+
]
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def valid_id(value: object) -> bool:
|
|
816
|
+
if value is None:
|
|
817
|
+
return True
|
|
818
|
+
if isinstance(value, str):
|
|
819
|
+
encoded = safe_utf8_bytes(value)
|
|
820
|
+
return encoded is not None and len(encoded) <= 128
|
|
821
|
+
return isinstance(value, int) and not isinstance(value, bool) and -(2**53 - 1) <= value <= 2**53 - 1
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def valid_wire_response(value: object) -> bool:
|
|
825
|
+
"""Final exact response schema check immediately before stdout writes."""
|
|
826
|
+
if not isinstance(value, dict) or not is_safe_scalar(value) or set(value) != {"jsonrpc", "id", "result"} and set(value) != {"jsonrpc", "id", "error"} or value.get("jsonrpc") != "2.0" or not valid_id(value.get("id")):
|
|
827
|
+
return False
|
|
828
|
+
if "error" in value:
|
|
829
|
+
error = value["error"]
|
|
830
|
+
return exact_object(error, {"code", "message"}) and isinstance(error.get("code"), int) and not isinstance(error.get("code"), bool) and isinstance(error.get("message"), str)
|
|
831
|
+
result = value["result"]
|
|
832
|
+
if result == {}:
|
|
833
|
+
return True
|
|
834
|
+
if exact_object(result, {"protocolVersion", "capabilities", "serverInfo"}):
|
|
835
|
+
return result.get("protocolVersion") in SUPPORTED_VERSIONS and result.get("capabilities") == {"tools": {"listChanged": False}} and result.get("serverInfo") == {"name": "context-guard-mcp", "version": "1.0.0"}
|
|
836
|
+
if exact_object(result, {"tools"}):
|
|
837
|
+
return result.get("tools") == tools()
|
|
838
|
+
if not exact_object(result, {"content", "structuredContent", "isError"}) or not isinstance(result.get("isError"), bool) or not isinstance(result.get("content"), list) or len(result["content"]) != 1:
|
|
839
|
+
return False
|
|
840
|
+
text = result["content"][0]
|
|
841
|
+
return exact_object(text, {"type", "text"}) and text.get("type") == "text" and isinstance(text.get("text"), str) and isinstance(result.get("structuredContent"), dict) and valid_tool_payload(result["structuredContent"], result["isError"]) and text["text"] == compact(result["structuredContent"])
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def base_request(value: object) -> tuple[bool, bool, object]:
|
|
845
|
+
if not isinstance(value, dict) or set(value) - {"jsonrpc", "method", "params", "id"} or value.get("jsonrpc") != "2.0" or not isinstance(value.get("method"), str) or "params" in value and not isinstance(value["params"], dict) or "id" in value and not valid_id(value["id"]):
|
|
846
|
+
return False, False, None
|
|
847
|
+
return True, "id" not in value, value.get("id")
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def params_only_meta(params: object) -> bool:
|
|
851
|
+
return params is None or isinstance(params, dict) and set(params) <= {"_meta"} and ("_meta" not in params or isinstance(params["_meta"], dict))
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def initialize_params(params: object) -> bool:
|
|
855
|
+
if not isinstance(params, dict) or set(params) - {"protocolVersion", "capabilities", "clientInfo", "_meta"} or not isinstance(params.get("protocolVersion"), str) or not isinstance(params.get("capabilities"), dict) or not isinstance(params.get("clientInfo"), dict) or "_meta" in params and not isinstance(params["_meta"], dict):
|
|
856
|
+
return False
|
|
857
|
+
info = params["clientInfo"]
|
|
858
|
+
return set(info) <= {"name", "version", "title", "description", "websiteUrl", "icons"} and isinstance(info.get("name"), str) and isinstance(info.get("version"), str) and all(isinstance(info[key], str) for key in ("title", "description", "websiteUrl") if key in info) and ("icons" not in info or isinstance(info["icons"], list))
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def handle(server: Server, request: dict[str, object], notification: bool) -> dict[str, object] | None:
|
|
862
|
+
method, params, request_id = request["method"], request.get("params"), request.get("id")
|
|
863
|
+
def response(result: dict[str, object]) -> dict[str, object] | None:
|
|
864
|
+
return None if notification else {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
865
|
+
def failure(code: int, message: str) -> dict[str, object] | None:
|
|
866
|
+
if code in (-32600, -32602, -32603):
|
|
867
|
+
server.protocol_errors += 1
|
|
868
|
+
return None if notification else error_response(request_id, code, message)
|
|
869
|
+
if method == "ping":
|
|
870
|
+
return response({}) if params_only_meta(params) else failure(-32602, "Invalid params")
|
|
871
|
+
if method == "initialize":
|
|
872
|
+
if server.state != "PRE_INIT":
|
|
873
|
+
return failure(-32600, "Already initialized")
|
|
874
|
+
if not initialize_params(params):
|
|
875
|
+
return failure(-32602, "Invalid params")
|
|
876
|
+
if notification:
|
|
877
|
+
return None
|
|
878
|
+
version = params["protocolVersion"]
|
|
879
|
+
negotiated = version if version in SUPPORTED_VERSIONS else SUPPORTED_VERSIONS[0]
|
|
880
|
+
server.state = "WAIT_INITIALIZED"
|
|
881
|
+
return response({"protocolVersion": negotiated, "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "context-guard-mcp", "version": "1.0.0"}})
|
|
882
|
+
if method == "notifications/initialized":
|
|
883
|
+
if notification and not params_only_meta(params):
|
|
884
|
+
return failure(-32602, "Invalid params")
|
|
885
|
+
if notification and server.state == "WAIT_INITIALIZED":
|
|
886
|
+
server.state = "READY"
|
|
887
|
+
if notification:
|
|
888
|
+
return None
|
|
889
|
+
if server.state in {"PRE_INIT", "WAIT_INITIALIZED"}:
|
|
890
|
+
return failure(-32002, "Server not initialized")
|
|
891
|
+
return failure(-32601, "Method not found")
|
|
892
|
+
if server.state == "PRE_INIT" or server.state == "WAIT_INITIALIZED":
|
|
893
|
+
return failure(-32002, "Server not initialized")
|
|
894
|
+
if method == "tools/list":
|
|
895
|
+
if not (params is None or isinstance(params, dict) and set(params) <= {"cursor", "_meta"} and (params.get("cursor") is None) and ("_meta" not in params or isinstance(params["_meta"], dict))):
|
|
896
|
+
return failure(-32602, "Invalid params")
|
|
897
|
+
return response({"tools": tools()})
|
|
898
|
+
if method == "tools/call":
|
|
899
|
+
if not isinstance(params, dict) or set(params) - {"name", "arguments", "_meta"} or not isinstance(params.get("name"), str) or "arguments" in params and not isinstance(params["arguments"], dict) or "_meta" in params and not isinstance(params["_meta"], dict):
|
|
900
|
+
return failure(-32602, "Invalid params")
|
|
901
|
+
name = params["name"]
|
|
902
|
+
if name not in TOOL_NAMES:
|
|
903
|
+
return failure(-32602, "Invalid params")
|
|
904
|
+
if notification:
|
|
905
|
+
return None
|
|
906
|
+
server.attempts += 1
|
|
907
|
+
server.calls[name] += 1
|
|
908
|
+
if server.attempts > MAX_CALLS:
|
|
909
|
+
return response(server.fail(name, "rate_limit_reached"))
|
|
910
|
+
if name == "context_guard_compress":
|
|
911
|
+
result = server.compress(params.get("arguments", {}))
|
|
912
|
+
elif name == "context_guard_retrieve":
|
|
913
|
+
result = server.retrieve(params.get("arguments", {}))
|
|
914
|
+
else:
|
|
915
|
+
result = server.stats(params.get("arguments", {}))
|
|
916
|
+
return response(result)
|
|
917
|
+
return failure(-32601, "Method not found")
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def emit(value: dict[str, object]) -> bool:
|
|
921
|
+
try:
|
|
922
|
+
if not valid_wire_response(value):
|
|
923
|
+
return False
|
|
924
|
+
wire = compact(value)
|
|
925
|
+
if len(wire.encode("utf-8")) > MAX_RESPONSE_BYTES:
|
|
926
|
+
return False
|
|
927
|
+
sys.stdout.write(wire + "\n")
|
|
928
|
+
sys.stdout.flush()
|
|
929
|
+
return True
|
|
930
|
+
except (BrokenPipeError, UnicodeEncodeError):
|
|
931
|
+
return False
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def serve(server: Server) -> int:
|
|
935
|
+
stream = sys.stdin.buffer
|
|
936
|
+
while True:
|
|
937
|
+
line = stream.readline(MAX_MESSAGE_BYTES + 3)
|
|
938
|
+
if not line:
|
|
939
|
+
return 0
|
|
940
|
+
if len(line) == MAX_MESSAGE_BYTES + 3:
|
|
941
|
+
emit(error_response(None, -32600, "Message too large"))
|
|
942
|
+
return 1
|
|
943
|
+
if not line.endswith(b"\n"):
|
|
944
|
+
emit(error_response(None, -32700, "Parse error"))
|
|
945
|
+
return 1
|
|
946
|
+
payload = line[:-1]
|
|
947
|
+
if payload.endswith(b"\r"):
|
|
948
|
+
payload = payload[:-1]
|
|
949
|
+
if len(payload) > MAX_MESSAGE_BYTES:
|
|
950
|
+
emit(error_response(None, -32600, "Message too large"))
|
|
951
|
+
return 1
|
|
952
|
+
try:
|
|
953
|
+
data = json.loads(payload.decode("utf-8"), object_pairs_hook=reject_duplicates, parse_constant=reject_nonfinite)
|
|
954
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
955
|
+
server.protocol_errors += 1
|
|
956
|
+
emit(error_response(None, -32700, "Parse error"))
|
|
957
|
+
continue
|
|
958
|
+
except (DuplicateKey, NonFinite, RecursionError, ValueError):
|
|
959
|
+
server.protocol_errors += 1
|
|
960
|
+
emit(error_response(None, -32600, "Invalid Request"))
|
|
961
|
+
continue
|
|
962
|
+
valid, notification, _request_id = base_request(data)
|
|
963
|
+
if not valid or not is_safe_scalar(data):
|
|
964
|
+
server.protocol_errors += 1
|
|
965
|
+
emit(error_response(None, -32600, "Invalid Request"))
|
|
966
|
+
continue
|
|
967
|
+
try:
|
|
968
|
+
result = handle(server, data, notification)
|
|
969
|
+
except Exception:
|
|
970
|
+
# A valid request must not let an implementation defect escape as a
|
|
971
|
+
# traceback or local detail. Notifications deliberately stay
|
|
972
|
+
# silent even on this path.
|
|
973
|
+
server.protocol_errors += 1
|
|
974
|
+
result = None if notification else error_response(data.get("id"), -32603, "Internal error")
|
|
975
|
+
if result is not None and not emit(result):
|
|
976
|
+
return 1
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def main(argv: list[str] | None = None) -> int:
|
|
980
|
+
parser = argparse.ArgumentParser(description="Run the local ContextGuard stdio MCP server.")
|
|
981
|
+
parser.add_argument("--root", type=Path, default=Path.cwd())
|
|
982
|
+
parser.add_argument("--namespace", default="default")
|
|
983
|
+
args = parser.parse_args(argv)
|
|
984
|
+
try:
|
|
985
|
+
server = Server(args.root, args.namespace)
|
|
986
|
+
except (OSError, ValueError, UnicodeError):
|
|
987
|
+
print("context-guard-mcp: startup failed", file=sys.stderr)
|
|
988
|
+
return 2
|
|
989
|
+
try:
|
|
990
|
+
return serve(server)
|
|
991
|
+
finally:
|
|
992
|
+
try:
|
|
993
|
+
server.close()
|
|
994
|
+
except OSError:
|
|
995
|
+
pass
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
if __name__ == "__main__":
|
|
999
|
+
raise SystemExit(main())
|