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.
- bezoar/__init__.py +24 -0
- bezoar/__main__.py +7 -0
- bezoar/adapters/__init__.py +15 -0
- bezoar/adapters/base.py +505 -0
- bezoar/adapters/claude_code.py +365 -0
- bezoar/adapters/codex.py +824 -0
- bezoar/adapters/copilot.py +552 -0
- bezoar/adapters/cursor.py +291 -0
- bezoar/adapters/gemini_cli.py +710 -0
- bezoar/adapters/generic.py +1732 -0
- bezoar/cache.py +568 -0
- bezoar/cli.py +492 -0
- bezoar/data/NOTICE +39 -0
- bezoar/data/harness/claude_code.yml +120 -0
- bezoar/data/harness/codex.yml +78 -0
- bezoar/data/harness/common.yml +53 -0
- bezoar/data/harness/copilot.yml +41 -0
- bezoar/data/harness/cursor.yml +65 -0
- bezoar/data/harness/gemini_cli.yml +66 -0
- bezoar/data/harness/grok.yml +77 -0
- bezoar/data/policy/default.yml +66 -0
- bezoar/data/rules/MANIFEST.json +573 -0
- bezoar/data/rules/exfil_sinks.yml +103 -0
- bezoar/data/rules/external_instructions.yml +79 -0
- bezoar/data/rules/prompt_injection.yml +328 -0
- bezoar/data/rules/secrets.yml +690 -0
- bezoar/data/schemas/policy.v1.schema.json +233 -0
- bezoar/detectors/__init__.py +19 -0
- bezoar/detectors/data_exfiltration.py +198 -0
- bezoar/detectors/external_instructions.py +156 -0
- bezoar/detectors/harness_forgery/__init__.py +12 -0
- bezoar/detectors/harness_forgery/context.py +251 -0
- bezoar/detectors/harness_forgery/detector.py +579 -0
- bezoar/detectors/harness_forgery/tags.py +537 -0
- bezoar/detectors/harness_forgery/vocab.py +344 -0
- bezoar/detectors/hidden_behavior.py +358 -0
- bezoar/detectors/permissions.py +556 -0
- bezoar/detectors/prompt_injection.py +6 -0
- bezoar/detectors/secrets.py +6 -0
- bezoar/detectors/supply_chain.py +722 -0
- bezoar/engine/__init__.py +61 -0
- bezoar/engine/dedupe.py +147 -0
- bezoar/engine/edges.py +195 -0
- bezoar/engine/pipeline.py +828 -0
- bezoar/engine/session.py +409 -0
- bezoar/engine/verdict.py +359 -0
- bezoar/errors.py +105 -0
- bezoar/incremental.py +184 -0
- bezoar/judge/__init__.py +48 -0
- bezoar/judge/advisory.py +417 -0
- bezoar/judge/prompts/__init__.py +45 -0
- bezoar/judge/prompts/v1.py +35 -0
- bezoar/judge/protocol.py +333 -0
- bezoar/judge/redact.py +125 -0
- bezoar/mcp/__init__.py +27 -0
- bezoar/mcp/probe.py +1032 -0
- bezoar/mcp/tool_poisoning.py +578 -0
- bezoar/models.py +674 -0
- bezoar/normalize.py +520 -0
- bezoar/parallel.py +231 -0
- bezoar/parsing/__init__.py +36 -0
- bezoar/parsing/commands.py +124 -0
- bezoar/parsing/frontmatter.py +173 -0
- bezoar/parsing/hooks.py +32 -0
- bezoar/parsing/loaders.py +142 -0
- bezoar/parsing/mcp_config.py +265 -0
- bezoar/parsing/text.py +292 -0
- bezoar/policy/__init__.py +38 -0
- bezoar/policy/apply.py +583 -0
- bezoar/policy/discovery.py +189 -0
- bezoar/policy/schema.py +789 -0
- bezoar/redact.py +319 -0
- bezoar/registry.py +434 -0
- bezoar/reporters/__init__.py +15 -0
- bezoar/reporting/__init__.py +51 -0
- bezoar/reporting/baseline.py +267 -0
- bezoar/reporting/context.py +63 -0
- bezoar/reporting/discover.py +79 -0
- bezoar/reporting/exitcodes.py +49 -0
- bezoar/reporting/json.py +37 -0
- bezoar/reporting/paths.py +85 -0
- bezoar/reporting/sarif.py +216 -0
- bezoar/reporting/serialize.py +407 -0
- bezoar/reporting/text.py +100 -0
- bezoar/rules/__init__.py +21 -0
- bezoar/rules/compile.py +992 -0
- bezoar/rules/detector.py +350 -0
- bezoar/rules/lint.py +186 -0
- bezoar/rules/loader.py +213 -0
- bezoar/rules/schema.py +476 -0
- bezoar/serve/__init__.py +6 -0
- bezoar/serve/__main__.py +9 -0
- bezoar/serve/config.py +133 -0
- bezoar/serve/core.py +869 -0
- bezoar/serve/handler.py +698 -0
- bezoar/serve/main.py +218 -0
- bezoar/serve/problems.py +87 -0
- bezoar/serve/scans.py +142 -0
- bezoar/serve/security.py +306 -0
- bezoar/serve/store.py +548 -0
- bezoar-1.0.0a2.dist-info/METADATA +201 -0
- bezoar-1.0.0a2.dist-info/RECORD +106 -0
- bezoar-1.0.0a2.dist-info/WHEEL +4 -0
- bezoar-1.0.0a2.dist-info/entry_points.txt +5 -0
- bezoar-1.0.0a2.dist-info/licenses/LICENSE +202 -0
- 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,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"]
|
bezoar/adapters/base.py
ADDED
|
@@ -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
|