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