redacted-context-mcp 0.7.0__py3-none-any.whl
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.
- redacted_context_mcp/__init__.py +23 -0
- redacted_context_mcp/config.py +347 -0
- redacted_context_mcp/core.py +1660 -0
- redacted_context_mcp/defaults.py +627 -0
- redacted_context_mcp/discovery.py +748 -0
- redacted_context_mcp/filesystem.py +474 -0
- redacted_context_mcp/github.py +306 -0
- redacted_context_mcp/limits.py +82 -0
- redacted_context_mcp/models.py +75 -0
- redacted_context_mcp/paths.py +36 -0
- redacted_context_mcp/redaction.py +371 -0
- redacted_context_mcp/server.py +1442 -0
- redacted_context_mcp-0.7.0.dist-info/METADATA +550 -0
- redacted_context_mcp-0.7.0.dist-info/RECORD +17 -0
- redacted_context_mcp-0.7.0.dist-info/WHEEL +4 -0
- redacted_context_mcp-0.7.0.dist-info/entry_points.txt +3 -0
- redacted_context_mcp-0.7.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Redacted local context access for coding agents."""
|
|
2
|
+
|
|
3
|
+
from .discovery import (
|
|
4
|
+
DiscoveryClient,
|
|
5
|
+
build_discovery_update,
|
|
6
|
+
discover_documents,
|
|
7
|
+
merge_discovery_toml,
|
|
8
|
+
write_discovery_update,
|
|
9
|
+
)
|
|
10
|
+
from .models import DiscoveryDocument, DiscoveryResult, DiscoveryUpdate
|
|
11
|
+
|
|
12
|
+
__version__ = "0.7.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DiscoveryDocument",
|
|
16
|
+
"DiscoveryClient",
|
|
17
|
+
"DiscoveryResult",
|
|
18
|
+
"DiscoveryUpdate",
|
|
19
|
+
"build_discovery_update",
|
|
20
|
+
"discover_documents",
|
|
21
|
+
"merge_discovery_toml",
|
|
22
|
+
"write_discovery_update",
|
|
23
|
+
]
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Local redaction configuration loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import BinaryIO, Iterable
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import tomllib
|
|
14
|
+
except ModuleNotFoundError: # pragma: no cover - Python < 3.11 fallback
|
|
15
|
+
tomllib = None
|
|
16
|
+
|
|
17
|
+
from .defaults import (
|
|
18
|
+
DETECTOR_PROFILE_ENV,
|
|
19
|
+
LOCAL_CONFIG,
|
|
20
|
+
SALT_ENV,
|
|
21
|
+
STATE_DIR_ENV,
|
|
22
|
+
TERMS_ENV,
|
|
23
|
+
)
|
|
24
|
+
from .models import GitHubRepoConfig, RedactionConfig
|
|
25
|
+
from .paths import rel_posix, resolve_under_root
|
|
26
|
+
|
|
27
|
+
def load_config(root: Path, config_path: Path | None) -> RedactionConfig:
|
|
28
|
+
root = root.expanduser().resolve()
|
|
29
|
+
values: dict[str, list[str]] = {
|
|
30
|
+
"clients": [],
|
|
31
|
+
"organizations": [],
|
|
32
|
+
"people": [],
|
|
33
|
+
"terms": [],
|
|
34
|
+
"allow": [],
|
|
35
|
+
"exclude_dirs": [],
|
|
36
|
+
"exclude_globs": [],
|
|
37
|
+
}
|
|
38
|
+
explicit_values: dict[str, list[str]] = {
|
|
39
|
+
"clients": [],
|
|
40
|
+
"organizations": [],
|
|
41
|
+
"people": [],
|
|
42
|
+
"terms": [],
|
|
43
|
+
}
|
|
44
|
+
github_repos: dict[str, GitHubRepoConfig] = {}
|
|
45
|
+
salt = ""
|
|
46
|
+
salt_source = "local-state"
|
|
47
|
+
detector_profile = "default"
|
|
48
|
+
term_files: list[str] = []
|
|
49
|
+
protected_paths: list[str] = []
|
|
50
|
+
|
|
51
|
+
root_terms = derive_root_terms(root)
|
|
52
|
+
for term in root_terms:
|
|
53
|
+
values["clients"].append(term)
|
|
54
|
+
|
|
55
|
+
default_config = root / LOCAL_CONFIG
|
|
56
|
+
path = config_path if config_path is not None else default_config
|
|
57
|
+
if path != default_config:
|
|
58
|
+
# An explicit --config file holds the same secrets as the default
|
|
59
|
+
# config and must never be served, wherever it lives under the root.
|
|
60
|
+
# Protected even when missing at load time: it may be created later.
|
|
61
|
+
explicit_config = path.expanduser().resolve()
|
|
62
|
+
if is_relative_to(explicit_config, root):
|
|
63
|
+
protected_paths.append(rel_posix(explicit_config, root))
|
|
64
|
+
if path.exists():
|
|
65
|
+
data = read_toml(path)
|
|
66
|
+
redaction = data.get("redaction", data)
|
|
67
|
+
for key in values:
|
|
68
|
+
loaded_values = as_string_list(redaction.get(key, []))
|
|
69
|
+
values[key].extend(loaded_values)
|
|
70
|
+
if key in explicit_values:
|
|
71
|
+
explicit_values[key].extend(loaded_values)
|
|
72
|
+
salt = str(redaction.get("salt", "")).strip()
|
|
73
|
+
if salt:
|
|
74
|
+
salt_source = "config"
|
|
75
|
+
detector_profile = str(redaction.get("detector_profile", detector_profile)).strip() or detector_profile
|
|
76
|
+
for term_file in as_string_list(redaction.get("term_files", [])):
|
|
77
|
+
term_path = resolve_under_root(root, term_file, allow_missing=True)
|
|
78
|
+
# Protected even when missing at load time: the operator may
|
|
79
|
+
# create the term file after the server has started, and its
|
|
80
|
+
# terms were never loaded into the redaction config.
|
|
81
|
+
term_files.append(rel_posix(term_path, root))
|
|
82
|
+
if term_path.exists():
|
|
83
|
+
term_file_values = read_term_file(term_path)
|
|
84
|
+
values["terms"].extend(term_file_values)
|
|
85
|
+
explicit_values["terms"].extend(term_file_values)
|
|
86
|
+
github_repos.update(parse_github_repos(data.get("github", {})))
|
|
87
|
+
for repo_config in github_repos.values():
|
|
88
|
+
# Private GitHub owner/repo identifiers are sensitive terms so a
|
|
89
|
+
# copy of the config cannot leak them verbatim.
|
|
90
|
+
values["terms"].append(repo_config.owner)
|
|
91
|
+
values["terms"].append(repo_config.repo)
|
|
92
|
+
values["terms"].append(f"{repo_config.owner}/{repo_config.repo}")
|
|
93
|
+
|
|
94
|
+
environment_terms: list[str] = []
|
|
95
|
+
env_terms = os.environ.get(TERMS_ENV)
|
|
96
|
+
if env_terms:
|
|
97
|
+
environment_terms = split_env_terms(env_terms)
|
|
98
|
+
values["terms"].extend(environment_terms)
|
|
99
|
+
env_salt = os.environ.get(SALT_ENV, "").strip()
|
|
100
|
+
if env_salt:
|
|
101
|
+
salt = env_salt
|
|
102
|
+
salt_source = "environment"
|
|
103
|
+
if not salt:
|
|
104
|
+
salt, salt_source = load_or_create_vault_salt(root)
|
|
105
|
+
detector_profile = os.environ.get(DETECTOR_PROFILE_ENV, "").strip() or detector_profile
|
|
106
|
+
if detector_profile not in {"default", "extended"}:
|
|
107
|
+
raise SystemExit("detector_profile must be default or extended.")
|
|
108
|
+
|
|
109
|
+
return RedactionConfig(
|
|
110
|
+
clients=dedupe(values["clients"]),
|
|
111
|
+
organizations=dedupe(values["organizations"]),
|
|
112
|
+
people=dedupe(expand_person_terms(values["people"])),
|
|
113
|
+
terms=dedupe(values["terms"]),
|
|
114
|
+
allow=dedupe(values["allow"]),
|
|
115
|
+
exclude_dirs=dedupe(values["exclude_dirs"]),
|
|
116
|
+
exclude_globs=dedupe(values["exclude_globs"]),
|
|
117
|
+
github_repos=github_repos,
|
|
118
|
+
salt=salt,
|
|
119
|
+
salt_source=salt_source,
|
|
120
|
+
root_terms=dedupe(root_terms),
|
|
121
|
+
environment_terms=dedupe(environment_terms),
|
|
122
|
+
explicit_clients=dedupe(explicit_values["clients"]),
|
|
123
|
+
explicit_organizations=dedupe(explicit_values["organizations"]),
|
|
124
|
+
explicit_people=dedupe(expand_person_terms(explicit_values["people"])),
|
|
125
|
+
explicit_terms=dedupe(explicit_values["terms"]),
|
|
126
|
+
detector_profile=detector_profile,
|
|
127
|
+
term_files=tuple(dedupe(term_files)),
|
|
128
|
+
protected_paths=tuple(dedupe(protected_paths)),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def read_toml(path: Path) -> dict:
|
|
133
|
+
if tomllib is None:
|
|
134
|
+
raise SystemExit("TOML config requires Python 3.11+ or no config file.")
|
|
135
|
+
return tomllib.loads(path.read_text(encoding="utf-8-sig"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def as_string_list(value: object) -> list[str]:
|
|
139
|
+
if value is None:
|
|
140
|
+
return []
|
|
141
|
+
if isinstance(value, str):
|
|
142
|
+
return [value]
|
|
143
|
+
if isinstance(value, list):
|
|
144
|
+
return [str(item) for item in value if str(item).strip()]
|
|
145
|
+
raise SystemExit(f"Expected string or list in redaction config, got {type(value).__name__}.")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_github_repos(value: object) -> dict[str, GitHubRepoConfig]:
|
|
149
|
+
if not isinstance(value, dict):
|
|
150
|
+
return {}
|
|
151
|
+
repos = value.get("repos", {})
|
|
152
|
+
if not isinstance(repos, dict):
|
|
153
|
+
raise SystemExit("Expected [github.repos.<alias>] tables in redaction config.")
|
|
154
|
+
|
|
155
|
+
parsed: dict[str, GitHubRepoConfig] = {}
|
|
156
|
+
for alias, raw_config in repos.items():
|
|
157
|
+
alias_value = str(alias).strip()
|
|
158
|
+
if not alias_value:
|
|
159
|
+
continue
|
|
160
|
+
if not isinstance(raw_config, dict):
|
|
161
|
+
raise SystemExit("Expected each GitHub repo config to be a table.")
|
|
162
|
+
owner = str(raw_config.get("owner", "")).strip()
|
|
163
|
+
repo = str(raw_config.get("repo", "")).strip()
|
|
164
|
+
if not owner or not repo:
|
|
165
|
+
raise SystemExit("GitHub repo config requires owner and repo.")
|
|
166
|
+
parsed[alias_value] = GitHubRepoConfig(
|
|
167
|
+
owner=owner,
|
|
168
|
+
repo=repo,
|
|
169
|
+
api_url=str(raw_config.get("api_url", "https://api.github.com")).strip()
|
|
170
|
+
or "https://api.github.com",
|
|
171
|
+
token_env=str(raw_config.get("token_env", "GITHUB_TOKEN")).strip() or "GITHUB_TOKEN",
|
|
172
|
+
)
|
|
173
|
+
return parsed
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def read_term_file(path: Path) -> list[str]:
|
|
177
|
+
terms: list[str] = []
|
|
178
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
179
|
+
line = line.strip()
|
|
180
|
+
if line and not line.startswith("#"):
|
|
181
|
+
terms.append(line)
|
|
182
|
+
return terms
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def split_env_terms(value: str) -> list[str]:
|
|
186
|
+
return [part.strip() for part in re.split(r"[\n,]", value) if part.strip()]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def load_or_create_vault_salt(root: Path) -> tuple[str, str]:
|
|
190
|
+
state_file = vault_salt_path(root)
|
|
191
|
+
ensure_state_outside_root(root, state_file)
|
|
192
|
+
try:
|
|
193
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
try:
|
|
195
|
+
os.chmod(state_file.parent, 0o700)
|
|
196
|
+
except OSError:
|
|
197
|
+
pass
|
|
198
|
+
lock_path = state_file.with_suffix(state_file.suffix + ".lock")
|
|
199
|
+
with exclusive_file_lock(lock_path):
|
|
200
|
+
if state_file.exists():
|
|
201
|
+
return read_vault_salt(state_file), "local-state"
|
|
202
|
+
salt = secrets.token_hex(32)
|
|
203
|
+
tmp_path = state_file.with_name(f".{state_file.name}.{os.getpid()}.tmp")
|
|
204
|
+
with open(tmp_path, "w", encoding="utf-8", newline="\n") as handle:
|
|
205
|
+
handle.write(salt + "\n")
|
|
206
|
+
handle.flush()
|
|
207
|
+
os.fsync(handle.fileno())
|
|
208
|
+
try:
|
|
209
|
+
os.chmod(tmp_path, 0o600)
|
|
210
|
+
except OSError:
|
|
211
|
+
pass
|
|
212
|
+
os.replace(tmp_path, state_file)
|
|
213
|
+
fsync_directory(state_file.parent)
|
|
214
|
+
return read_vault_salt(state_file), "local-state"
|
|
215
|
+
except SystemExit:
|
|
216
|
+
raise
|
|
217
|
+
except OSError as exc:
|
|
218
|
+
raise SystemExit("Could not initialize redacted-context vault salt.") from exc
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def vault_salt_path(root: Path) -> Path:
|
|
222
|
+
import hashlib
|
|
223
|
+
|
|
224
|
+
base = os.environ.get(STATE_DIR_ENV, "").strip()
|
|
225
|
+
if base:
|
|
226
|
+
state_root = Path(base).expanduser()
|
|
227
|
+
elif os.name == "nt" and os.environ.get("LOCALAPPDATA"):
|
|
228
|
+
state_root = Path(os.environ["LOCALAPPDATA"]) / "redacted-context-mcp"
|
|
229
|
+
else:
|
|
230
|
+
state_root = Path.home() / ".local" / "state" / "redacted-context-mcp"
|
|
231
|
+
vault_id = hashlib.sha256(str(root.resolve()).encode("utf-8")).hexdigest()
|
|
232
|
+
return state_root / "vaults" / f"{vault_id}.salt"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def ensure_state_outside_root(root: Path, state_file: Path) -> None:
|
|
236
|
+
root_raw = root.expanduser()
|
|
237
|
+
root_resolved = root.resolve()
|
|
238
|
+
root_candidates = (root_raw, root_resolved)
|
|
239
|
+
candidates = [state_file, *state_file.parents]
|
|
240
|
+
for candidate in candidates:
|
|
241
|
+
raw = candidate.expanduser()
|
|
242
|
+
if any(raw == root_candidate or is_relative_to(raw, root_candidate) for root_candidate in root_candidates):
|
|
243
|
+
raise SystemExit("The redacted-context state directory must be outside the served context root.")
|
|
244
|
+
resolved = raw.resolve(strict=False)
|
|
245
|
+
if any(
|
|
246
|
+
resolved == root_candidate or is_relative_to(resolved, root_candidate)
|
|
247
|
+
for root_candidate in root_candidates
|
|
248
|
+
):
|
|
249
|
+
raise SystemExit("The redacted-context state directory must be outside the served context root.")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def fsync_directory(path: Path) -> None:
|
|
253
|
+
if os.name == "nt":
|
|
254
|
+
return
|
|
255
|
+
try:
|
|
256
|
+
fd = os.open(path, os.O_RDONLY)
|
|
257
|
+
except OSError:
|
|
258
|
+
return
|
|
259
|
+
try:
|
|
260
|
+
os.fsync(fd)
|
|
261
|
+
except OSError:
|
|
262
|
+
pass
|
|
263
|
+
finally:
|
|
264
|
+
os.close(fd)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def is_relative_to(path: Path, root: Path) -> bool:
|
|
268
|
+
try:
|
|
269
|
+
path.relative_to(root)
|
|
270
|
+
except ValueError:
|
|
271
|
+
return False
|
|
272
|
+
return True
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def read_vault_salt(path: Path) -> str:
|
|
276
|
+
try:
|
|
277
|
+
value = path.read_text(encoding="utf-8").strip()
|
|
278
|
+
except OSError as exc:
|
|
279
|
+
raise SystemExit("Could not read redacted-context vault salt.") from exc
|
|
280
|
+
if not re.fullmatch(r"[0-9a-f]{64}", value):
|
|
281
|
+
raise SystemExit(
|
|
282
|
+
"The redacted-context vault salt is malformed. Restore a backup or delete it intentionally to rotate aliases."
|
|
283
|
+
)
|
|
284
|
+
return value
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@contextmanager
|
|
288
|
+
def exclusive_file_lock(path: Path) -> Iterable[BinaryIO]:
|
|
289
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
290
|
+
with open(path, "a+b") as handle:
|
|
291
|
+
if os.name == "nt":
|
|
292
|
+
import msvcrt
|
|
293
|
+
|
|
294
|
+
handle.seek(0)
|
|
295
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
296
|
+
try:
|
|
297
|
+
yield handle
|
|
298
|
+
finally:
|
|
299
|
+
handle.seek(0)
|
|
300
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
301
|
+
else:
|
|
302
|
+
import fcntl
|
|
303
|
+
|
|
304
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
305
|
+
try:
|
|
306
|
+
yield handle
|
|
307
|
+
finally:
|
|
308
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def dedupe(values: Iterable[str]) -> tuple[str, ...]:
|
|
312
|
+
seen: set[str] = set()
|
|
313
|
+
result: list[str] = []
|
|
314
|
+
for value in values:
|
|
315
|
+
cleaned = re.sub(r"\s+", " ", str(value).strip())
|
|
316
|
+
key = cleaned.casefold()
|
|
317
|
+
if cleaned and key not in seen:
|
|
318
|
+
result.append(cleaned)
|
|
319
|
+
seen.add(key)
|
|
320
|
+
return tuple(result)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def derive_root_terms(root: Path) -> list[str]:
|
|
324
|
+
name = root.name.strip()
|
|
325
|
+
if not name:
|
|
326
|
+
return []
|
|
327
|
+
terms = {name, name.replace("-", " "), name.replace("_", " ")}
|
|
328
|
+
parts = [part for part in re.split(r"[-_\s]+", name) if len(part) >= 3]
|
|
329
|
+
terms.update(parts)
|
|
330
|
+
if len(parts) >= 2:
|
|
331
|
+
acronym = "".join(part[0] for part in parts)
|
|
332
|
+
if len(acronym) >= 2:
|
|
333
|
+
terms.add(acronym)
|
|
334
|
+
terms.add(acronym.upper())
|
|
335
|
+
first_part_plus_initial = f"{parts[0]}{parts[1][0]}"
|
|
336
|
+
terms.add(first_part_plus_initial)
|
|
337
|
+
terms.add(first_part_plus_initial.upper())
|
|
338
|
+
return sorted(terms, key=len, reverse=True)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def expand_person_terms(people: Iterable[str]) -> list[str]:
|
|
342
|
+
expanded: list[str] = []
|
|
343
|
+
for person in people:
|
|
344
|
+
expanded.append(person)
|
|
345
|
+
parts = [part for part in re.split(r"[\s_-]+", person) if len(part) >= 3]
|
|
346
|
+
expanded.extend(parts)
|
|
347
|
+
return expanded
|