mcp-blast-radius 0.2.1__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AOS Standard Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-blast-radius
3
+ Version: 0.2.1
4
+ Summary: See what any MCP server can touch — static blast-radius report for every target; manifest optional for divergence detection.
5
+ Author: AOS Standard Contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://pypi.org/project/mcp-blast-radius/
8
+ Project-URL: Documentation, https://github.com/aos-standard/AOS-spec#compliance-validation-official
9
+ Project-URL: Repository, https://github.com/aos-standard/AOS-spec
10
+ Project-URL: Issues, https://github.com/aos-standard/AOS-spec/issues
11
+ Keywords: mcp,blast-radius,static-analysis,supply-chain,agent-security,ci-gate,mcp-server
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Quality Assurance
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: mcp>=1.0.0
28
+ Dynamic: license-file
29
+
30
+ # MCP Blast-Radius Auditor
31
+
32
+ > **See what any MCP server can actually touch — before you add it to your agent.**
33
+
34
+ No manifest? You still get the full blast-radius report. Add a manifest to also catch divergences.
35
+
36
+ > Also, if the server declares a manifest: **Catch an MCP server that touches files it said it wouldn't — and block the merge in CI.**
37
+
38
+ Statically extract what a third-party MCP server can reach (files, network, subprocess, env) via surface-level analysis. Compare against declared boundaries when a manifest is present.
39
+
40
+ ## 30-second scan
41
+
42
+ ```bash
43
+ pipx run mcp-blast-radius # MCP server
44
+ pip install . && mcp-blast-radius-gate --gate-mode blocking --target-dir /path/to/mcp-server
45
+ ```
46
+
47
+ - **Red (blocking):** divergence detected — code touches paths or capabilities not declared in manifest.
48
+ - **Green:** no divergences (or no manifest — blast radius report only, advisory pass).
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ python3 -m venv .venv
54
+ source .venv/bin/activate
55
+ pip install .
56
+ ```
57
+
58
+ ## CLI entry
59
+
60
+ ```bash
61
+ mcp-blast-radius # MCP stdio server
62
+ mcp-blast-radius-gate # CI gate (default blocking, exit 1 on fail)
63
+ ```
64
+
65
+ ### CI blocking gate
66
+
67
+ ```bash
68
+ mcp-blast-radius-gate --gate-mode blocking --target-dir .
69
+ # no divergences → exit 0 / divergences or declaration violations → exit 1
70
+ ```
71
+
72
+ ## MCP tools
73
+
74
+ - `aos_compliance_validate` — scan one MCP server directory (`target_dir` required; `tool_id` optional label)
75
+ - `aos_compliance_self_test` — wiring smoke test
76
+
77
+ Default `gate_mode=advisory`. Use `gate_mode=blocking` in CI to fail on divergences.
78
+
79
+ ## What is extracted
80
+
81
+ | Layer | Scope | Confidence |
82
+ |-------|-------|------------|
83
+ | Dependencies | `requirements.txt`, `pyproject.toml`, `package.json` | `declared` |
84
+ | Python AST | imports, file I/O, network, env, subprocess; MCP tool attribution | `observed-static` / `cannot-determine` |
85
+ | Divergence | manifest `permitted_output_paths` / `oracle_paths` vs observed access | blocking when mismatch |
86
+
87
+ **Limitations:** Static analysis only. Dynamic imports, `getattr`/`eval`, obfuscation, and native extensions may hide capabilities. We do not claim complete coverage — every finding includes a `confidence` label.
88
+
89
+ ## Environment
90
+
91
+ | Variable | Purpose |
92
+ |----------|---------|
93
+ | `AOS_VALIDATOR_TARGET_DIR` | Default scan root when `target_dir` is omitted |
94
+ | `AOS_VALIDATOR_MCP_LOG` | JSONL path for local tool call log (never sent externally) |
95
+ | `AOS_VALIDATOR_CALLER` | Caller label (`ci`, `smoke_self_call`, etc.) |
96
+
97
+ ## Example
98
+
99
+ ```bash
100
+ aos_compliance_validate target_dir=/path/to/my-mcp-server gate_mode=blocking
101
+ ```
102
+
103
+ ## License
104
+
105
+ MIT
@@ -0,0 +1,76 @@
1
+ # MCP Blast-Radius Auditor
2
+
3
+ > **See what any MCP server can actually touch — before you add it to your agent.**
4
+
5
+ No manifest? You still get the full blast-radius report. Add a manifest to also catch divergences.
6
+
7
+ > Also, if the server declares a manifest: **Catch an MCP server that touches files it said it wouldn't — and block the merge in CI.**
8
+
9
+ Statically extract what a third-party MCP server can reach (files, network, subprocess, env) via surface-level analysis. Compare against declared boundaries when a manifest is present.
10
+
11
+ ## 30-second scan
12
+
13
+ ```bash
14
+ pipx run mcp-blast-radius # MCP server
15
+ pip install . && mcp-blast-radius-gate --gate-mode blocking --target-dir /path/to/mcp-server
16
+ ```
17
+
18
+ - **Red (blocking):** divergence detected — code touches paths or capabilities not declared in manifest.
19
+ - **Green:** no divergences (or no manifest — blast radius report only, advisory pass).
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ python3 -m venv .venv
25
+ source .venv/bin/activate
26
+ pip install .
27
+ ```
28
+
29
+ ## CLI entry
30
+
31
+ ```bash
32
+ mcp-blast-radius # MCP stdio server
33
+ mcp-blast-radius-gate # CI gate (default blocking, exit 1 on fail)
34
+ ```
35
+
36
+ ### CI blocking gate
37
+
38
+ ```bash
39
+ mcp-blast-radius-gate --gate-mode blocking --target-dir .
40
+ # no divergences → exit 0 / divergences or declaration violations → exit 1
41
+ ```
42
+
43
+ ## MCP tools
44
+
45
+ - `aos_compliance_validate` — scan one MCP server directory (`target_dir` required; `tool_id` optional label)
46
+ - `aos_compliance_self_test` — wiring smoke test
47
+
48
+ Default `gate_mode=advisory`. Use `gate_mode=blocking` in CI to fail on divergences.
49
+
50
+ ## What is extracted
51
+
52
+ | Layer | Scope | Confidence |
53
+ |-------|-------|------------|
54
+ | Dependencies | `requirements.txt`, `pyproject.toml`, `package.json` | `declared` |
55
+ | Python AST | imports, file I/O, network, env, subprocess; MCP tool attribution | `observed-static` / `cannot-determine` |
56
+ | Divergence | manifest `permitted_output_paths` / `oracle_paths` vs observed access | blocking when mismatch |
57
+
58
+ **Limitations:** Static analysis only. Dynamic imports, `getattr`/`eval`, obfuscation, and native extensions may hide capabilities. We do not claim complete coverage — every finding includes a `confidence` label.
59
+
60
+ ## Environment
61
+
62
+ | Variable | Purpose |
63
+ |----------|---------|
64
+ | `AOS_VALIDATOR_TARGET_DIR` | Default scan root when `target_dir` is omitted |
65
+ | `AOS_VALIDATOR_MCP_LOG` | JSONL path for local tool call log (never sent externally) |
66
+ | `AOS_VALIDATOR_CALLER` | Caller label (`ci`, `smoke_self_call`, etc.) |
67
+
68
+ ## Example
69
+
70
+ ```bash
71
+ aos_compliance_validate target_dir=/path/to/my-mcp-server gate_mode=blocking
72
+ ```
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,3 @@
1
+ """MCP Blast-Radius Auditor — external MCP distribution package."""
2
+
3
+ __version__ = "0.2.1"
@@ -0,0 +1,463 @@
1
+ """
2
+ AOS Compliance Validator — pure validation functions (distribution bundle).
3
+
4
+ Synced from upstream canonical source. Do not edit this file directly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from dataclasses import asdict, dataclass, field
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Literal
15
+
16
+ try:
17
+ from core.blast_radius import build_blast_radius, detect_divergences
18
+ except ImportError:
19
+ from aos_validator_mcp.blast_radius import build_blast_radius, detect_divergences
20
+
21
+ GateMode = Literal["advisory", "blocking"]
22
+
23
+ _DEFAULT_ORACLE_PATHS = ("evals/", "config/")
24
+ _MOCK_VIOLATION_SCORE_SIM = 80.0
25
+
26
+
27
+ @dataclass
28
+ class ValidationResult:
29
+ tool_id: str
30
+ scanned_at: str
31
+ aos_score: float
32
+ sections: dict[str, float]
33
+ blocking_reasons: list[str]
34
+ gate_pass: bool
35
+ gate_mode: str
36
+ blocks_next_step: bool
37
+ target_path: str | None = None
38
+ source: str = "external"
39
+ aos_version: str | None = None
40
+ oracle_violations: list[str] = field(default_factory=list)
41
+ permitted_violations: list[str] = field(default_factory=list)
42
+ remediation: list[str] = field(default_factory=list)
43
+ blast_radius: dict[str, Any] = field(default_factory=dict)
44
+ divergences: list[str] = field(default_factory=list)
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ return asdict(self)
48
+
49
+
50
+ _AOS_SPEC_URL = "https://github.com/aos-standard/AOS-spec"
51
+
52
+
53
+ def _build_remediation(
54
+ blocking: list[str],
55
+ oracle_v: list[str],
56
+ permitted_v: list[str],
57
+ *,
58
+ gate_pass: bool,
59
+ divergences: list[str] | None = None,
60
+ ) -> list[str]:
61
+ if gate_pass:
62
+ return []
63
+ blob = "\n".join(blocking + oracle_v + permitted_v + (divergences or []))
64
+ hints: list[str] = []
65
+ seen: set[str] = set()
66
+
67
+ def add(msg: str) -> None:
68
+ if msg not in seen:
69
+ seen.add(msg)
70
+ hints.append(msg)
71
+
72
+ if divergences:
73
+ add(
74
+ "Blast-radius divergences detected: code touches capabilities or paths not "
75
+ "covered by manifest declarations. Review divergences[] and align code or "
76
+ "update permitted_output_paths / oracle_paths."
77
+ )
78
+ if "TARGET_NOT_FOUND" in blob:
79
+ add(
80
+ "To declare AOS-v0.1 compliance, add a manifest.json (or agent_card.json) at the "
81
+ "repo root with: {\"aos_compliant\": \"v0.1\", \"permitted_output_paths\": "
82
+ "[\"<dirs you write to>\"], \"oracle_paths\": [\"<read-only dirs>\"]}. "
83
+ f"See {_AOS_SPEC_URL}"
84
+ )
85
+ if "AOS_UNDECLARED" in blob:
86
+ add(
87
+ "manifest.json found but not AOS-declared. Add \"aos_compliant\": \"v0.1\" plus "
88
+ f"permitted_output_paths / oracle_paths. See {_AOS_SPEC_URL}"
89
+ )
90
+ if "AOS_VERSION_MISMATCH" in blob:
91
+ add(f"Set aos_compliant to \"v0.1\" in manifest.json. See {_AOS_SPEC_URL}")
92
+ if "PERMITTED_PATHS_MISSING" in blob or "PERMITTED_PATH_INVALID" in blob:
93
+ add("Add permitted_output_paths: [\"<paths your agent may write>\"] to manifest.json.")
94
+ if "ORACLE_PATH_INVALID" in blob:
95
+ add("Fix oracle_paths entries (safe relative paths, e.g. evals/, config/).")
96
+ if "MANIFEST_INVALID" in blob:
97
+ add("Fix manifest.json syntax (valid JSON required).")
98
+ return hints
99
+
100
+
101
+ def _load_manifest_data(agent_dir: Path) -> tuple[dict[str, Any] | None, str | None]:
102
+ for name in ("manifest.json", "agent_card.json"):
103
+ path = agent_dir / name
104
+ if not path.is_file():
105
+ continue
106
+ try:
107
+ return json.loads(path.read_text(encoding="utf-8")), name
108
+ except (json.JSONDecodeError, OSError):
109
+ return None, name
110
+ return None, None
111
+
112
+
113
+ def resolve_agent_dir(target_dir: Path, tool_id: str | None = None) -> Path | None:
114
+ """Resolve agent directory by manifest.json or agent_card.json presence."""
115
+ root = target_dir.expanduser().resolve()
116
+ if not root.is_dir():
117
+ return None
118
+
119
+ if (root / "manifest.json").is_file() or (root / "agent_card.json").is_file():
120
+ return root
121
+
122
+ if tool_id:
123
+ tid = tool_id.strip().zfill(4)
124
+ for manifest in sorted(root.rglob("manifest.json")):
125
+ tool_path = manifest.parent
126
+ if tool_path.name.startswith(f"{tid}_"):
127
+ return tool_path
128
+
129
+ for manifest in sorted(root.rglob("manifest.json")):
130
+ return manifest.parent
131
+ for card in sorted(root.rglob("agent_card.json")):
132
+ return card.parent
133
+ return None
134
+
135
+
136
+ def _path_entry_safe(raw: Any) -> bool:
137
+ if not isinstance(raw, str) or not raw.strip():
138
+ return False
139
+ if raw.startswith(("/", "\\")):
140
+ return False
141
+ return ".." not in Path(raw).parts
142
+
143
+
144
+ def aos_boundary_blocking_reasons(
145
+ agent_dir: Path,
146
+ ) -> tuple[list[str], list[str], list[str], str | None]:
147
+ """Return blocking_reasons, oracle_violations, permitted_violations, aos_version."""
148
+ name = agent_dir.name
149
+ blocking: list[str] = []
150
+ oracle_v: list[str] = []
151
+ permitted_v: list[str] = []
152
+ aos_version: str | None = None
153
+
154
+ data, source_name = _load_manifest_data(agent_dir)
155
+ if source_name and data is None:
156
+ blocking.append(f"[{name}] MANIFEST_INVALID: cannot parse {source_name}")
157
+ return blocking, oracle_v, permitted_v, aos_version
158
+ if data is None:
159
+ return blocking, oracle_v, permitted_v, aos_version
160
+
161
+ aos_val = data.get("aos_compliant") or data.get("aos_compliance")
162
+ if not aos_val:
163
+ blocking.append(
164
+ f"[{name}] AOS_UNDECLARED: manifest has no aos_compliant field"
165
+ )
166
+ elif str(aos_val) != "v0.1":
167
+ blocking.append(
168
+ f"[{name}] AOS_VERSION_MISMATCH: expected v0.1, got {aos_val!r}"
169
+ )
170
+ else:
171
+ aos_version = "v0.1"
172
+
173
+ permitted = data.get("permitted_output_paths")
174
+ if not isinstance(permitted, list) or len(permitted) == 0:
175
+ msg = "PERMITTED_PATHS_MISSING: no permitted_output_paths declared"
176
+ blocking.append(f"[{name}] {msg}")
177
+ permitted_v.append(msg)
178
+ else:
179
+ for entry in permitted:
180
+ if not _path_entry_safe(entry):
181
+ msg = f"PERMITTED_PATH_INVALID: {entry!r}"
182
+ blocking.append(f"[{name}] {msg}")
183
+ permitted_v.append(msg)
184
+
185
+ oracle = data.get("oracle_paths")
186
+ if not isinstance(oracle, list) or len(oracle) == 0:
187
+ oracle_v.append("ORACLE_PATHS_DEFAULTED: defaults evals/, config/")
188
+ else:
189
+ for entry in oracle:
190
+ if not _path_entry_safe(entry):
191
+ msg = f"ORACLE_PATH_INVALID: {entry!r}"
192
+ blocking.append(f"[{name}] {msg}")
193
+ oracle_v.append(msg)
194
+
195
+ return blocking, oracle_v, permitted_v, aos_version
196
+
197
+
198
+ def score_manifest_declared(agent_dir: Path) -> float:
199
+ data, _ = _load_manifest_data(agent_dir)
200
+ if not data:
201
+ return 0.0
202
+ if data.get("aos_compliant") or data.get("aos_compliance"):
203
+ return 25.0
204
+ return 0.0
205
+
206
+
207
+ def score_oracle_declared(agent_dir: Path) -> float:
208
+ data, _ = _load_manifest_data(agent_dir)
209
+ if not data:
210
+ return 0.0
211
+ oracle = data.get("oracle_paths")
212
+ if isinstance(oracle, list) and len(oracle) > 0:
213
+ return 25.0
214
+ return 12.5
215
+
216
+
217
+ def score_permitted_declared(agent_dir: Path) -> float:
218
+ data, _ = _load_manifest_data(agent_dir)
219
+ if not data:
220
+ return 0.0
221
+ permitted = data.get("permitted_output_paths")
222
+ if isinstance(permitted, list) and len(permitted) > 0:
223
+ return 25.0
224
+ return 0.0
225
+
226
+
227
+ def score_path_safety(agent_dir: Path) -> float:
228
+ _, oracle_v, permitted_v, _ = aos_boundary_blocking_reasons(agent_dir)
229
+ path_errors = [v for v in oracle_v + permitted_v if "INVALID" in v or "MISSING" in v]
230
+ if path_errors:
231
+ return 0.0
232
+ return 25.0
233
+
234
+
235
+ def score_sections(agent_dir: Path) -> dict[str, float]:
236
+ return {
237
+ "manifest_declared": score_manifest_declared(agent_dir),
238
+ "oracle_declared": score_oracle_declared(agent_dir),
239
+ "permitted_declared": score_permitted_declared(agent_dir),
240
+ "path_safety": score_path_safety(agent_dir),
241
+ }
242
+
243
+
244
+ def _finalize_validation(
245
+ *,
246
+ tool_id: str,
247
+ scan_root: Path,
248
+ agent_path: Path | None,
249
+ gate_mode: GateMode,
250
+ source: str,
251
+ ) -> ValidationResult:
252
+ """Shared validation path: blast radius always; manifest checks when present."""
253
+ scanned_at = datetime.now(timezone.utc).isoformat()
254
+ blast = build_blast_radius(scan_root)
255
+
256
+ manifest_data: dict[str, Any] | None = None
257
+ aos_version: str | None = None
258
+ blocking: list[str] = []
259
+ oracle_v: list[str] = []
260
+ permitted_v: list[str] = []
261
+
262
+ if agent_path is not None:
263
+ manifest_data, _ = _load_manifest_data(agent_path)
264
+ if manifest_data is not None:
265
+ blocking, oracle_v, permitted_v, aos_version = aos_boundary_blocking_reasons(
266
+ agent_path
267
+ )
268
+ elif (_load_manifest_data(agent_path)[1]) is not None:
269
+ name = agent_path.name
270
+ blocking.append(f"[{name}] MANIFEST_INVALID: cannot parse manifest")
271
+
272
+ agent_name = agent_path.name if agent_path else scan_root.name
273
+ divergences = detect_divergences(blast, manifest_data, agent_name=agent_name)
274
+
275
+ for div in divergences:
276
+ blocking.append(f"DIVERGENCE: {div}")
277
+
278
+ sections = score_sections(agent_path) if agent_path and manifest_data else {}
279
+ aos_score = round(sum(sections.values()), 1) if sections else 0.0
280
+
281
+ gate_pass = len(blocking) == 0
282
+ remediation = _build_remediation(
283
+ blocking, oracle_v, permitted_v, gate_pass=gate_pass, divergences=divergences
284
+ )
285
+
286
+ return ValidationResult(
287
+ tool_id=tool_id,
288
+ scanned_at=scanned_at,
289
+ aos_score=aos_score,
290
+ sections=sections,
291
+ blocking_reasons=blocking,
292
+ gate_pass=gate_pass,
293
+ gate_mode=gate_mode,
294
+ blocks_next_step=(gate_mode == "blocking" and not gate_pass),
295
+ target_path=str(agent_path or scan_root),
296
+ source=source,
297
+ aos_version=aos_version,
298
+ oracle_violations=oracle_v,
299
+ permitted_violations=permitted_v,
300
+ remediation=remediation,
301
+ blast_radius=blast,
302
+ divergences=divergences,
303
+ )
304
+
305
+
306
+ def _mock_validation(tool_id: str, gate_mode: GateMode, source: str) -> ValidationResult:
307
+ digest = hashlib.sha256(tool_id.encode()).hexdigest()
308
+ h = int(digest[:8], 16)
309
+ aos_score = float(h % 101)
310
+ mock_has_violation = aos_score < _MOCK_VIOLATION_SCORE_SIM
311
+ reasons = (
312
+ ["MOCK_VIOLATION: simulated AOS compliance gap"] if mock_has_violation else []
313
+ )
314
+ gate_pass = len(reasons) == 0
315
+ remediation = _build_remediation(reasons, [], [], gate_pass=gate_pass)
316
+ return ValidationResult(
317
+ tool_id=tool_id.strip().zfill(4) if tool_id.isdigit() else tool_id,
318
+ scanned_at="2026-06-16T00:00:00+00:00",
319
+ aos_score=aos_score,
320
+ sections={"mock": aos_score},
321
+ blocking_reasons=reasons,
322
+ gate_pass=gate_pass,
323
+ gate_mode=gate_mode,
324
+ blocks_next_step=(gate_mode == "blocking" and not gate_pass),
325
+ target_path=None,
326
+ source=source,
327
+ remediation=remediation,
328
+ )
329
+
330
+
331
+ def validate_agent(
332
+ target_or_tool_id: Path | str,
333
+ *,
334
+ target_dir: Path | None = None,
335
+ gate_mode: GateMode = "advisory",
336
+ mock: bool = False,
337
+ source: str = "external",
338
+ ) -> ValidationResult:
339
+ """
340
+ Validate one agent directory (external) or locate by tool_id under target_dir (monorepo).
341
+ """
342
+ if target_dir is not None:
343
+ tool_id = str(target_or_tool_id).strip().zfill(4)
344
+ root = target_dir.expanduser().resolve()
345
+ if mock:
346
+ return _mock_validation(tool_id, gate_mode, source)
347
+ agent_path = resolve_agent_dir(root, tool_id=tool_id)
348
+ if agent_path is None:
349
+ scanned_at = datetime.now(timezone.utc).isoformat()
350
+ blocking = [f"AGENT_NOT_FOUND: {tool_id} under {root}"]
351
+ gate_pass = False
352
+ remediation = _build_remediation(blocking, [], [], gate_pass=gate_pass)
353
+ blast = build_blast_radius(root)
354
+ return ValidationResult(
355
+ tool_id=tool_id,
356
+ scanned_at=scanned_at,
357
+ aos_score=0.0,
358
+ sections={},
359
+ blocking_reasons=blocking,
360
+ gate_pass=gate_pass,
361
+ gate_mode=gate_mode,
362
+ blocks_next_step=(gate_mode == "blocking"),
363
+ target_path=str(root),
364
+ source=source,
365
+ remediation=remediation,
366
+ blast_radius=blast,
367
+ divergences=[],
368
+ )
369
+ return _finalize_validation(
370
+ tool_id=tool_id,
371
+ scan_root=root,
372
+ agent_path=agent_path,
373
+ gate_mode=gate_mode,
374
+ source=source,
375
+ )
376
+ else:
377
+ root = Path(target_or_tool_id).expanduser().resolve()
378
+ tool_id = root.name or "agent"
379
+ if mock:
380
+ return _mock_validation(tool_id, gate_mode, source)
381
+ agent_path = resolve_agent_dir(root)
382
+ return _finalize_validation(
383
+ tool_id=tool_id,
384
+ scan_root=root,
385
+ agent_path=agent_path,
386
+ gate_mode=gate_mode,
387
+ source=source,
388
+ )
389
+
390
+
391
+ def emit_validation_jsonl(
392
+ result: ValidationResult,
393
+ *,
394
+ log_path: Path,
395
+ target: str | None = None,
396
+ ) -> Path:
397
+ log_path = log_path.expanduser()
398
+ log_path.parent.mkdir(parents=True, exist_ok=True)
399
+ entry = {
400
+ "ts": result.scanned_at,
401
+ "target": target or result.target_path or "",
402
+ "aos_version": result.aos_version or "v0.1",
403
+ "oracle_violations": result.oracle_violations,
404
+ "permitted_violations": result.permitted_violations,
405
+ "gate_pass": result.gate_pass,
406
+ "gate_mode": result.gate_mode,
407
+ "source": result.source,
408
+ }
409
+ with log_path.open("a", encoding="utf-8") as handle:
410
+ handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
411
+ return log_path
412
+
413
+
414
+ def format_json_result(result: ValidationResult) -> str:
415
+ return json.dumps(result.to_dict(), ensure_ascii=False, indent=2)
416
+
417
+
418
+ def format_text_result(result: ValidationResult) -> str:
419
+ lines = [
420
+ f"gate_pass: {result.gate_pass}",
421
+ f"gate_mode: {result.gate_mode}",
422
+ f"blocks_next_step: {result.blocks_next_step}",
423
+ f"tool_id: {result.tool_id}",
424
+ f"aos_score: {result.aos_score}",
425
+ ]
426
+ if result.target_path:
427
+ lines.append(f"target_path: {result.target_path}")
428
+ if result.blocking_reasons:
429
+ lines.append("blocking_reasons:")
430
+ for reason in result.blocking_reasons:
431
+ lines.append(f" - {reason}")
432
+ if result.oracle_violations:
433
+ lines.append("oracle_violations:")
434
+ for item in result.oracle_violations:
435
+ lines.append(f" - {item}")
436
+ if result.permitted_violations:
437
+ lines.append("permitted_violations:")
438
+ for item in result.permitted_violations:
439
+ lines.append(f" - {item}")
440
+ if result.divergences:
441
+ lines.append("divergences:")
442
+ for item in result.divergences:
443
+ lines.append(f" - {item}")
444
+ if result.blast_radius:
445
+ br = result.blast_radius
446
+ lines.append(f"blast_radius: python_files={br.get('python_files', 0)} "
447
+ f"scanned_files={br.get('scanned_files', 0)}")
448
+ caps = br.get("capabilities") or {}
449
+ if caps:
450
+ lines.append("capabilities:")
451
+ for cap, items in sorted(caps.items()):
452
+ lines.append(f" {cap}: {len(items)} finding(s)")
453
+ for finding in items[:3]:
454
+ conf = finding.get("confidence", "?")
455
+ detail = finding.get("detail", "")
456
+ lines.append(f" - [{conf}] {detail}")
457
+ if len(items) > 3:
458
+ lines.append(f" ... and {len(items) - 3} more")
459
+ if not result.gate_pass and result.remediation:
460
+ lines.append("Next steps:")
461
+ for hint in result.remediation:
462
+ lines.append(f" - {hint}")
463
+ return "\n".join(lines)