bezoar 1.0.0a2__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.
Files changed (106) hide show
  1. bezoar/__init__.py +24 -0
  2. bezoar/__main__.py +7 -0
  3. bezoar/adapters/__init__.py +15 -0
  4. bezoar/adapters/base.py +505 -0
  5. bezoar/adapters/claude_code.py +365 -0
  6. bezoar/adapters/codex.py +824 -0
  7. bezoar/adapters/copilot.py +552 -0
  8. bezoar/adapters/cursor.py +291 -0
  9. bezoar/adapters/gemini_cli.py +710 -0
  10. bezoar/adapters/generic.py +1732 -0
  11. bezoar/cache.py +568 -0
  12. bezoar/cli.py +492 -0
  13. bezoar/data/NOTICE +39 -0
  14. bezoar/data/harness/claude_code.yml +120 -0
  15. bezoar/data/harness/codex.yml +78 -0
  16. bezoar/data/harness/common.yml +53 -0
  17. bezoar/data/harness/copilot.yml +41 -0
  18. bezoar/data/harness/cursor.yml +65 -0
  19. bezoar/data/harness/gemini_cli.yml +66 -0
  20. bezoar/data/harness/grok.yml +77 -0
  21. bezoar/data/policy/default.yml +66 -0
  22. bezoar/data/rules/MANIFEST.json +573 -0
  23. bezoar/data/rules/exfil_sinks.yml +103 -0
  24. bezoar/data/rules/external_instructions.yml +79 -0
  25. bezoar/data/rules/prompt_injection.yml +328 -0
  26. bezoar/data/rules/secrets.yml +690 -0
  27. bezoar/data/schemas/policy.v1.schema.json +233 -0
  28. bezoar/detectors/__init__.py +19 -0
  29. bezoar/detectors/data_exfiltration.py +198 -0
  30. bezoar/detectors/external_instructions.py +156 -0
  31. bezoar/detectors/harness_forgery/__init__.py +12 -0
  32. bezoar/detectors/harness_forgery/context.py +251 -0
  33. bezoar/detectors/harness_forgery/detector.py +579 -0
  34. bezoar/detectors/harness_forgery/tags.py +537 -0
  35. bezoar/detectors/harness_forgery/vocab.py +344 -0
  36. bezoar/detectors/hidden_behavior.py +358 -0
  37. bezoar/detectors/permissions.py +556 -0
  38. bezoar/detectors/prompt_injection.py +6 -0
  39. bezoar/detectors/secrets.py +6 -0
  40. bezoar/detectors/supply_chain.py +722 -0
  41. bezoar/engine/__init__.py +61 -0
  42. bezoar/engine/dedupe.py +147 -0
  43. bezoar/engine/edges.py +195 -0
  44. bezoar/engine/pipeline.py +828 -0
  45. bezoar/engine/session.py +409 -0
  46. bezoar/engine/verdict.py +359 -0
  47. bezoar/errors.py +105 -0
  48. bezoar/incremental.py +184 -0
  49. bezoar/judge/__init__.py +48 -0
  50. bezoar/judge/advisory.py +417 -0
  51. bezoar/judge/prompts/__init__.py +45 -0
  52. bezoar/judge/prompts/v1.py +35 -0
  53. bezoar/judge/protocol.py +333 -0
  54. bezoar/judge/redact.py +125 -0
  55. bezoar/mcp/__init__.py +27 -0
  56. bezoar/mcp/probe.py +1032 -0
  57. bezoar/mcp/tool_poisoning.py +578 -0
  58. bezoar/models.py +674 -0
  59. bezoar/normalize.py +520 -0
  60. bezoar/parallel.py +231 -0
  61. bezoar/parsing/__init__.py +36 -0
  62. bezoar/parsing/commands.py +124 -0
  63. bezoar/parsing/frontmatter.py +173 -0
  64. bezoar/parsing/hooks.py +32 -0
  65. bezoar/parsing/loaders.py +142 -0
  66. bezoar/parsing/mcp_config.py +265 -0
  67. bezoar/parsing/text.py +292 -0
  68. bezoar/policy/__init__.py +38 -0
  69. bezoar/policy/apply.py +583 -0
  70. bezoar/policy/discovery.py +189 -0
  71. bezoar/policy/schema.py +789 -0
  72. bezoar/redact.py +319 -0
  73. bezoar/registry.py +434 -0
  74. bezoar/reporters/__init__.py +15 -0
  75. bezoar/reporting/__init__.py +51 -0
  76. bezoar/reporting/baseline.py +267 -0
  77. bezoar/reporting/context.py +63 -0
  78. bezoar/reporting/discover.py +79 -0
  79. bezoar/reporting/exitcodes.py +49 -0
  80. bezoar/reporting/json.py +37 -0
  81. bezoar/reporting/paths.py +85 -0
  82. bezoar/reporting/sarif.py +216 -0
  83. bezoar/reporting/serialize.py +407 -0
  84. bezoar/reporting/text.py +100 -0
  85. bezoar/rules/__init__.py +21 -0
  86. bezoar/rules/compile.py +992 -0
  87. bezoar/rules/detector.py +350 -0
  88. bezoar/rules/lint.py +186 -0
  89. bezoar/rules/loader.py +213 -0
  90. bezoar/rules/schema.py +476 -0
  91. bezoar/serve/__init__.py +6 -0
  92. bezoar/serve/__main__.py +9 -0
  93. bezoar/serve/config.py +133 -0
  94. bezoar/serve/core.py +869 -0
  95. bezoar/serve/handler.py +698 -0
  96. bezoar/serve/main.py +218 -0
  97. bezoar/serve/problems.py +87 -0
  98. bezoar/serve/scans.py +142 -0
  99. bezoar/serve/security.py +306 -0
  100. bezoar/serve/store.py +548 -0
  101. bezoar-1.0.0a2.dist-info/METADATA +201 -0
  102. bezoar-1.0.0a2.dist-info/RECORD +106 -0
  103. bezoar-1.0.0a2.dist-info/WHEEL +4 -0
  104. bezoar-1.0.0a2.dist-info/entry_points.txt +5 -0
  105. bezoar-1.0.0a2.dist-info/licenses/LICENSE +202 -0
  106. bezoar-1.0.0a2.dist-info/licenses/NOTICE +9 -0
bezoar/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """bezoar — offline scanner for the AI-agent supply chain."""
2
+ from __future__ import annotations
3
+
4
+ from importlib.metadata import PackageNotFoundError, version
5
+ from pathlib import Path
6
+
7
+ from .models import CONTRACT_VERSION, Verdict
8
+
9
+ try:
10
+ __version__ = version("bezoar")
11
+ except PackageNotFoundError: # pragma: no cover
12
+ __version__ = "0.1.0"
13
+
14
+
15
+ def scan(root: Path) -> Verdict:
16
+ """P0 facade: ``scan(Path) -> Verdict``. Delegates to ``engine.scan`` until WS-F."""
17
+ import importlib
18
+
19
+ engine = importlib.import_module("bezoar.engine")
20
+ result: Verdict = engine.scan(root)
21
+ return result
22
+
23
+
24
+ __all__ = ["CONTRACT_VERSION", "Verdict", "__version__", "scan"]
bezoar/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m bezoar`` entry point — delegates to :func:`bezoar.cli.main`."""
2
+ from __future__ import annotations
3
+
4
+ from bezoar.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
@@ -0,0 +1,15 @@
1
+ """Adapter auto-discovery stub. Never hand-edited (draft-claude §6.3 / §13.1)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+
7
+ def __getattr__(name: str) -> Any:
8
+ if name == "ALL_ADAPTERS":
9
+ from bezoar.registry import load_adapters
10
+
11
+ return load_adapters()
12
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
13
+
14
+
15
+ __all__ = ["ALL_ADAPTERS"]
@@ -0,0 +1,505 @@
1
+ """Shared adapter helpers (draft-claude §6.2)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from collections.abc import Iterator, Sequence
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from bezoar.errors import ParseError
12
+ from bezoar.models import (
13
+ Capability,
14
+ Component,
15
+ ComponentFile,
16
+ ComponentKind,
17
+ Dependency,
18
+ Location,
19
+ Platform,
20
+ Provenance,
21
+ Scope,
22
+ )
23
+ from bezoar.parsing.frontmatter import FrontmatterResult
24
+ from bezoar.parsing.frontmatter import parse as parse_frontmatter
25
+ from bezoar.parsing.mcp_config import McpServerRecord
26
+ from bezoar.parsing.text import (
27
+ DEFAULT_MAX_FILE_BYTES,
28
+ language_from_path,
29
+ load_text,
30
+ )
31
+
32
+ DEFAULT_MAX_DEPTH = 12
33
+
34
+ DEFAULT_EXCLUDE: tuple[str, ...] = (
35
+ "**/.git/**",
36
+ "**/node_modules/**",
37
+ "**/.venv/**",
38
+ "**/venv/**",
39
+ "**/dist/**",
40
+ "**/__pycache__/**",
41
+ "**/.tox/**",
42
+ "**/.mypy_cache/**",
43
+ "**/.ruff_cache/**",
44
+ "**/.pytest_cache/**",
45
+ )
46
+
47
+ _HEAVY_DIRS = frozenset({
48
+ ".git",
49
+ "node_modules",
50
+ ".venv",
51
+ "venv",
52
+ "dist",
53
+ "__pycache__",
54
+ ".tox",
55
+ ".mypy_cache",
56
+ ".ruff_cache",
57
+ ".pytest_cache",
58
+ })
59
+
60
+ _PINNED_SPEC = re.compile(
61
+ r"@\d|==|@sha256:|@[0-9a-fA-F]{7,}|==\s*\d|@v?\d+\.\d+"
62
+ )
63
+
64
+
65
+ def _glob_to_re(pattern: str) -> re.Pattern[str]:
66
+ pattern = pattern.replace("\\", "/")
67
+ parts: list[str] = []
68
+ i = 0
69
+ while i < len(pattern):
70
+ if pattern.startswith("**/", i):
71
+ parts.append("(?:.*/)?")
72
+ i += 3
73
+ elif pattern.startswith("**", i):
74
+ parts.append(".*")
75
+ i += 2
76
+ elif pattern[i] == "*":
77
+ parts.append("[^/]*")
78
+ i += 1
79
+ elif pattern[i] == "?":
80
+ parts.append("[^/]")
81
+ i += 1
82
+ else:
83
+ parts.append(re.escape(pattern[i]))
84
+ i += 1
85
+ return re.compile("^" + "".join(parts) + "$")
86
+
87
+
88
+ def _compile_globs(patterns: Sequence[str]) -> list[re.Pattern[str]]:
89
+ return [_glob_to_re(p) for p in patterns]
90
+
91
+
92
+ def _posix_rel(root: Path, path: Path) -> str:
93
+ try:
94
+ return path.relative_to(root).as_posix()
95
+ except ValueError:
96
+ return path.name
97
+
98
+
99
+ def _matches(rel: str, patterns: Sequence[re.Pattern[str]]) -> bool:
100
+ posix = rel.replace("\\", "/")
101
+ return any(p.match(posix) or p.match(posix + "/") for p in patterns)
102
+
103
+
104
+ def walk(
105
+ root: Path,
106
+ *,
107
+ include: Sequence[str],
108
+ exclude: Sequence[str],
109
+ max_depth: int,
110
+ ) -> Iterator[Path]:
111
+ """Yield files under ``root`` without following symlinks.
112
+
113
+ Empty ``include`` means every file. Heavy VCS/vendor directories are always
114
+ skipped in addition to ``exclude`` globs.
115
+ """
116
+ root = root.resolve()
117
+ include_re = _compile_globs(include) if include else []
118
+ exclude_re = _compile_globs(exclude if exclude else DEFAULT_EXCLUDE)
119
+
120
+ def _allowed(rel: str) -> bool:
121
+ if _matches(rel, exclude_re):
122
+ return False
123
+ return not include_re or _matches(rel, include_re)
124
+
125
+ if root.is_file():
126
+ rel = root.name
127
+ if _allowed(rel):
128
+ yield root
129
+ return
130
+ if not root.is_dir():
131
+ return
132
+
133
+ stack: list[tuple[Path, int]] = [(root, 0)]
134
+ while stack:
135
+ current, depth = stack.pop()
136
+ if depth > max_depth:
137
+ continue
138
+ try:
139
+ with os.scandir(current) as entries:
140
+ children = sorted(entries, key=lambda e: e.name)
141
+ except OSError:
142
+ continue
143
+ for entry in children:
144
+ try:
145
+ is_link = entry.is_symlink()
146
+ except OSError:
147
+ continue
148
+ if is_link:
149
+ continue
150
+ name = entry.name
151
+ child = Path(entry.path)
152
+ rel = _posix_rel(root, child)
153
+ if entry.is_dir(follow_symlinks=False):
154
+ if name in _HEAVY_DIRS:
155
+ continue
156
+ if _matches(rel + "/", exclude_re) or _matches(rel + "/**", exclude_re):
157
+ continue
158
+ if _matches("**/" + name + "/**", exclude_re):
159
+ continue
160
+ stack.append((child, depth + 1))
161
+ continue
162
+ if entry.is_file(follow_symlinks=False) and _allowed(rel):
163
+ yield child
164
+
165
+
166
+ @dataclass(slots=True)
167
+ class ReadComponentFile(ComponentFile):
168
+ """ComponentFile that carries read-time truncation / I/O diagnostics."""
169
+
170
+ diagnostics: list[dict[str, Any]] = field(default_factory=list)
171
+
172
+
173
+ def file_read_diagnostics(file: ComponentFile) -> list[dict[str, Any]]:
174
+ extra = getattr(file, "diagnostics", None)
175
+ if not isinstance(extra, list):
176
+ return []
177
+ return [dict(item) for item in extra if isinstance(item, dict)]
178
+
179
+
180
+ def read_component_file(
181
+ path: Path,
182
+ *,
183
+ relpath: str,
184
+ max_file_bytes: int,
185
+ ) -> ReadComponentFile:
186
+ """Single-file bounded read with truncation / unreadable diagnostics."""
187
+ loaded = load_text(path, max_file_bytes=max_file_bytes, relpath=relpath)
188
+ return ReadComponentFile(
189
+ path=path,
190
+ relpath=relpath,
191
+ text=loaded.text,
192
+ sha256=loaded.sha256,
193
+ size=loaded.size,
194
+ is_binary=loaded.is_binary,
195
+ language=language_from_path(path),
196
+ diagnostics=loaded.diagnostics_list(),
197
+ )
198
+
199
+
200
+ def read_component_files(
201
+ root: Path,
202
+ *,
203
+ max_file_bytes: int,
204
+ exclude: Sequence[str],
205
+ diagnostics: list[dict[str, Any]] | None = None,
206
+ ) -> list[ComponentFile]:
207
+ files: list[ComponentFile] = []
208
+ root = root.resolve()
209
+ paths = walk(root, include=(), exclude=exclude, max_depth=DEFAULT_MAX_DEPTH)
210
+ for path in sorted(paths, key=lambda p: p.as_posix()):
211
+ rel = (_posix_rel(root, path) if root.is_dir() else path.name).replace("\\", "/")
212
+ file = read_component_file(path, relpath=rel, max_file_bytes=max_file_bytes)
213
+ if diagnostics is not None:
214
+ diagnostics.extend(file_read_diagnostics(file))
215
+ files.append(file)
216
+ return files
217
+
218
+
219
+ def _tools_from_frontmatter(
220
+ data: dict[str, Any],
221
+ line_map: dict[str, int],
222
+ relpath: str,
223
+ ) -> list[Capability]:
224
+ raw = data.get("allowed-tools", data.get("allowed_tools", data.get("tools")))
225
+ key = "allowed-tools" if "allowed-tools" in data else (
226
+ "allowed_tools" if "allowed_tools" in data else "tools"
227
+ )
228
+ line = line_map.get(key, line_map.get("allowed-tools", 1))
229
+ source = Location(relpath=relpath, line=line)
230
+ caps: list[Capability] = []
231
+ values: list[str] = []
232
+ if isinstance(raw, str):
233
+ values = [part.strip() for part in re.split(r"[\s,]+", raw) if part.strip()]
234
+ elif isinstance(raw, (list, tuple)):
235
+ values = [str(v) for v in raw]
236
+ for value in values:
237
+ lowered = value.lower()
238
+ cap_kind: Any = "tool"
239
+ if "bypass" in lowered:
240
+ cap_kind = "bypass"
241
+ elif "bash" in lowered:
242
+ cap_kind = "exec"
243
+ caps.append(Capability(kind=cap_kind, value=value, source=source))
244
+ mode = data.get("permissionMode") or data.get("permission_mode")
245
+ if isinstance(mode, str) and "bypass" in mode.lower():
246
+ caps.append(
247
+ Capability(
248
+ kind="bypass",
249
+ value=mode,
250
+ source=Location(relpath=relpath, line=line_map.get("permissionMode", line)),
251
+ )
252
+ )
253
+ return caps
254
+
255
+
256
+ def _frontmatter_diag(relpath: str, message: str, line: int) -> dict[str, Any]:
257
+ return {
258
+ "code": "adapter/parse-error",
259
+ "message": message,
260
+ "location": {"relpath": relpath, "line": line, "column": 1},
261
+ }
262
+
263
+
264
+ def _safe_frontmatter(text: str, *, relpath: str) -> tuple[FrontmatterResult, list[dict[str, Any]]]:
265
+ """Parse frontmatter; recoverable YAML shape never raises."""
266
+ diags: list[dict[str, Any]] = []
267
+ try:
268
+ fm = parse_frontmatter(text, relpath=relpath)
269
+ except ParseError as exc:
270
+ fm = FrontmatterResult(data={}, line_map={}, body=text, body_start_line=1)
271
+ diags.append(_frontmatter_diag(relpath, str(exc), exc.line or 1))
272
+ return fm, diags
273
+ if fm.recovered:
274
+ diags.append(
275
+ _frontmatter_diag(
276
+ relpath,
277
+ fm.warning or "invalid YAML frontmatter recovered as strings",
278
+ fm.recover_line or 1,
279
+ )
280
+ )
281
+ return fm, diags
282
+
283
+
284
+ def _frontmatter_component(
285
+ path: Path,
286
+ *,
287
+ kind: ComponentKind,
288
+ platform: Platform,
289
+ scope: Scope,
290
+ max_file_bytes: int,
291
+ entrypoint: str | None,
292
+ ) -> Component:
293
+ relpath = path.name
294
+ file = read_component_file(path, relpath=relpath, max_file_bytes=max_file_bytes)
295
+ fm, diags = _safe_frontmatter(file.text, relpath=relpath)
296
+ diags.extend(file_read_diagnostics(file))
297
+ name = str(fm.data.get("name") or path.stem)
298
+ description = fm.data.get("description")
299
+ metadata: dict[str, Any] = {
300
+ "frontmatter_line_map": fm.line_map,
301
+ "body_start_line": fm.body_start_line,
302
+ }
303
+ if diags:
304
+ metadata["diagnostics"] = diags
305
+ return Component(
306
+ kind=kind,
307
+ root=path if path.is_file() else path.parent,
308
+ name=name,
309
+ provenance=Provenance(platform=platform, scope=scope, origin="local"),
310
+ manifest=dict(fm.data),
311
+ files=[file],
312
+ version=str(fm.data["version"]) if fm.data.get("version") is not None else None,
313
+ description=str(description) if description is not None else None,
314
+ entrypoint=entrypoint,
315
+ capabilities=_tools_from_frontmatter(fm.data, fm.line_map, relpath),
316
+ metadata=metadata,
317
+ )
318
+
319
+
320
+ def skill_from_dir(
321
+ root: Path,
322
+ platform: Platform,
323
+ scope: Scope,
324
+ *,
325
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
326
+ exclude: Sequence[str] = DEFAULT_EXCLUDE,
327
+ ) -> Component:
328
+ """SKILL.md + frontmatter → Component."""
329
+ root = root.resolve()
330
+ read_diags: list[dict[str, Any]] = []
331
+ files = read_component_files(
332
+ root, max_file_bytes=max_file_bytes, exclude=exclude, diagnostics=read_diags
333
+ )
334
+ skill = next((f for f in files if Path(f.relpath).name == "SKILL.md"), None)
335
+ fm, diags = _safe_frontmatter(skill.text if skill else "", relpath="SKILL.md")
336
+ diags.extend(read_diags)
337
+ name = str(fm.data.get("name") or root.name)
338
+ description = fm.data.get("description")
339
+ caps = _tools_from_frontmatter(fm.data, fm.line_map, "SKILL.md")
340
+ metadata: dict[str, Any] = {"frontmatter_line_map": fm.line_map}
341
+ if diags:
342
+ metadata["diagnostics"] = diags
343
+ return Component(
344
+ kind=ComponentKind.SKILL,
345
+ root=root,
346
+ name=name,
347
+ provenance=Provenance(platform=platform, scope=scope, origin="local"),
348
+ manifest=dict(fm.data),
349
+ files=files,
350
+ version=str(fm.data["version"]) if fm.data.get("version") is not None else None,
351
+ description=str(description) if description is not None else None,
352
+ entrypoint="SKILL.md",
353
+ capabilities=caps,
354
+ metadata=metadata,
355
+ )
356
+
357
+
358
+ def subagent_from_file(
359
+ path: Path,
360
+ platform: Platform,
361
+ scope: Scope,
362
+ *,
363
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
364
+ ) -> Component:
365
+ return _frontmatter_component(
366
+ path,
367
+ kind=ComponentKind.SUBAGENT,
368
+ platform=platform,
369
+ scope=scope,
370
+ max_file_bytes=max_file_bytes,
371
+ entrypoint=path.name,
372
+ )
373
+
374
+
375
+ def rules_from_file(
376
+ path: Path,
377
+ platform: Platform,
378
+ scope: Scope,
379
+ *,
380
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
381
+ ) -> Component:
382
+ return _frontmatter_component(
383
+ path,
384
+ kind=ComponentKind.RULES,
385
+ platform=platform,
386
+ scope=scope,
387
+ max_file_bytes=max_file_bytes,
388
+ entrypoint=path.name,
389
+ )
390
+
391
+
392
+ def _dep_from_command(command: str, args: Sequence[str], source: Location) -> Dependency:
393
+ tokens = [command, *args]
394
+ joined = " ".join(tokens)
395
+ kind: Any = "binary"
396
+ spec = joined
397
+ if command in {"npx", "npm", "pnpm", "yarn"} or command.endswith("npx"):
398
+ kind = "npm"
399
+ spec = next((a for a in args if a not in {"-y", "--yes", "-p", "--package"}), command)
400
+ elif command in {"uvx", "uv", "pip", "pipx", "python", "python3"}:
401
+ kind = "pypi"
402
+ spec = next((a for a in args if not a.startswith("-")), command)
403
+ elif command in {"docker"}:
404
+ kind = "docker"
405
+ spec = joined
406
+ pinned = _PINNED_SPEC.search(spec) is not None
407
+ return Dependency(kind=kind, spec=spec, pinned=pinned, source=source)
408
+
409
+
410
+ def mcp_from_record(
411
+ rec: McpServerRecord,
412
+ platform: Platform,
413
+ scope: Scope,
414
+ *,
415
+ config_path: Path | None = None,
416
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
417
+ ) -> Component:
418
+ source = rec.source
419
+ caps: list[Capability] = []
420
+ deps: list[Dependency] = []
421
+ if rec.command:
422
+ caps.append(Capability(kind="exec", value=rec.command, source=source))
423
+ deps.append(_dep_from_command(rec.command, rec.args, source))
424
+ if rec.url:
425
+ caps.append(Capability(kind="network", value=rec.url, source=source))
426
+ for key in sorted(rec.env):
427
+ caps.append(Capability(kind="env", value=key, source=source))
428
+ files: list[ComponentFile] = []
429
+ root = config_path if config_path is not None else Path(rec.source.relpath)
430
+ extra_diags: list[dict[str, Any]] = []
431
+ if config_path is not None and config_path.is_file():
432
+ file = read_component_file(
433
+ config_path,
434
+ relpath=config_path.name,
435
+ max_file_bytes=max_file_bytes,
436
+ )
437
+ files.append(file)
438
+ extra_diags.extend(file_read_diagnostics(file))
439
+ manifest: dict[str, Any] = {
440
+ "name": rec.name,
441
+ "transport": rec.transport,
442
+ "command": rec.command,
443
+ "args": list(rec.args),
444
+ "env": dict(rec.env),
445
+ "url": rec.url,
446
+ "headers": dict(rec.headers),
447
+ "raw": rec.raw,
448
+ }
449
+ metadata: dict[str, Any] = {}
450
+ if extra_diags:
451
+ metadata["diagnostics"] = extra_diags
452
+ return Component(
453
+ kind=ComponentKind.MCP,
454
+ root=root,
455
+ name=rec.name,
456
+ provenance=Provenance(
457
+ platform=platform,
458
+ scope=scope,
459
+ origin="config",
460
+ declared_in=source,
461
+ ),
462
+ manifest=manifest,
463
+ files=files,
464
+ entrypoint=rec.command or rec.url,
465
+ capabilities=caps,
466
+ dependencies=deps,
467
+ metadata=metadata,
468
+ )
469
+
470
+
471
+ def _xdg_config_home() -> Path:
472
+ raw = os.environ.get("XDG_CONFIG_HOME")
473
+ if raw:
474
+ return Path(raw)
475
+ return Path.home() / ".config"
476
+
477
+
478
+ def home_roots(platform: Platform) -> list[Path]:
479
+ """User-scope roots, honouring ``$CLAUDE_CONFIG_DIR``, ``$CODEX_HOME``, ``$XDG_*``."""
480
+ home = Path.home()
481
+ xdg = _xdg_config_home()
482
+ roots: list[Path] = []
483
+ if platform is Platform.CLAUDE_CODE:
484
+ env = os.environ.get("CLAUDE_CONFIG_DIR")
485
+ roots.append(Path(env) if env else home / ".claude")
486
+ roots.append(xdg / "claude")
487
+ elif platform is Platform.CURSOR:
488
+ roots.extend([home / ".cursor", xdg / "cursor"])
489
+ elif platform is Platform.CODEX:
490
+ env = os.environ.get("CODEX_HOME")
491
+ roots.append(Path(env) if env else home / ".codex")
492
+ roots.append(xdg / "codex")
493
+ elif platform is Platform.GEMINI_CLI:
494
+ roots.extend([home / ".gemini", xdg / "gemini"])
495
+ elif platform is Platform.COPILOT:
496
+ roots.extend([home / ".copilot", xdg / "github-copilot"])
497
+ seen: set[Path] = set()
498
+ out: list[Path] = []
499
+ for root in roots:
500
+ resolved = root.expanduser()
501
+ if resolved in seen:
502
+ continue
503
+ seen.add(resolved)
504
+ out.append(resolved)
505
+ return out