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.
- mcp_blast_radius-0.2.1/LICENSE +21 -0
- mcp_blast_radius-0.2.1/PKG-INFO +105 -0
- mcp_blast_radius-0.2.1/README.md +76 -0
- mcp_blast_radius-0.2.1/aos_validator_mcp/__init__.py +3 -0
- mcp_blast_radius-0.2.1/aos_validator_mcp/_validate_pure.py +463 -0
- mcp_blast_radius-0.2.1/aos_validator_mcp/blast_radius.py +536 -0
- mcp_blast_radius-0.2.1/aos_validator_mcp/blast_radius_patterns.json +44 -0
- mcp_blast_radius-0.2.1/aos_validator_mcp/call_logger.py +41 -0
- mcp_blast_radius-0.2.1/aos_validator_mcp/gate_cli.py +141 -0
- mcp_blast_radius-0.2.1/aos_validator_mcp/server.py +140 -0
- mcp_blast_radius-0.2.1/aos_validator_mcp/validator.py +19 -0
- mcp_blast_radius-0.2.1/mcp_blast_radius.egg-info/PKG-INFO +105 -0
- mcp_blast_radius-0.2.1/mcp_blast_radius.egg-info/SOURCES.txt +17 -0
- mcp_blast_radius-0.2.1/mcp_blast_radius.egg-info/dependency_links.txt +1 -0
- mcp_blast_radius-0.2.1/mcp_blast_radius.egg-info/entry_points.txt +3 -0
- mcp_blast_radius-0.2.1/mcp_blast_radius.egg-info/requires.txt +1 -0
- mcp_blast_radius-0.2.1/mcp_blast_radius.egg-info/top_level.txt +1 -0
- mcp_blast_radius-0.2.1/pyproject.toml +54 -0
- mcp_blast_radius-0.2.1/setup.cfg +4 -0
|
@@ -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,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)
|