@0dai-dev/cli 4.3.6 → 4.3.8
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.
- package/README.md +12 -11
- package/bin/0dai.js +133 -33
- package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
- package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
- package/lib/ai/registry/mcp-catalog.json +98 -0
- package/lib/commands/auth.js +2 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/doctor.js +707 -12
- package/lib/commands/experience.js +40 -5
- package/lib/commands/feedback.js +157 -15
- package/lib/commands/gh.js +26 -0
- package/lib/commands/graph.js +9 -4
- package/lib/commands/heatmap.js +1 -1
- package/lib/commands/init.js +298 -27
- package/lib/commands/mcp.js +111 -33
- package/lib/commands/models.js +138 -41
- package/lib/commands/play.js +20 -4
- package/lib/commands/provider.js +30 -59
- package/lib/commands/quota.js +1 -1
- package/lib/commands/receipt.js +1 -1
- package/lib/commands/run.js +14 -6
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +176 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +1 -1
- package/lib/commands/update.js +184 -38
- package/lib/commands/usage.js +1 -1
- package/lib/commands/validate.js +32 -3
- package/lib/commands/vault.js +43 -8
- package/lib/python/__init__.py +0 -0
- package/lib/python/agent_quotas.py +525 -0
- package/lib/python/anomaly_alert.py +397 -0
- package/lib/python/anti_pattern_detector.py +799 -0
- package/lib/python/auth.py +443 -0
- package/lib/python/capi_profile_guard.py +477 -0
- package/lib/python/compliance_report.py +581 -0
- package/lib/python/drift_detector.py +388 -0
- package/lib/python/experience_pipeline.py +1130 -0
- package/lib/python/graph.py +19 -0
- package/lib/python/graph_core.py +293 -0
- package/lib/python/graph_io.py +179 -0
- package/lib/python/graph_legacy.py +2052 -0
- package/lib/python/graph_legacy_helpers.py +221 -0
- package/lib/python/graph_outcomes_core.py +85 -0
- package/lib/python/graph_queries.py +171 -0
- package/lib/python/graph_slice.py +198 -0
- package/lib/python/graph_slicer.py +576 -0
- package/lib/python/graph_slicer_cli.py +60 -0
- package/lib/python/graph_validation.py +64 -0
- package/lib/python/heatmap.py +943 -0
- package/lib/python/json_utils.py +193 -0
- package/lib/python/mcp_exposure_check.py +247 -0
- package/lib/python/model_router.py +1434 -0
- package/lib/python/project_manager.py +621 -0
- package/lib/python/provider_profiles.py +1618 -0
- package/lib/python/provider_registry.py +1211 -0
- package/lib/python/provider_registry_cli.py +125 -0
- package/lib/python/receipt_png.py +727 -0
- package/lib/python/structural_memory.py +325 -0
- package/lib/python/swarm_cost.py +177 -0
- package/lib/python/usage_ledger.py +569 -0
- package/lib/scripts/mcp_tier_config.py +240 -0
- package/lib/shared.js +96 -12
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +1 -4
- package/lib/utils/constants.js +7 -1
- package/lib/utils/identity.js +184 -0
- package/lib/utils/mcp-auth.js +81 -15
- package/lib/utils/plan.js +1 -1
- package/lib/vault/index.js +19 -3
- package/lib/vault/storage.js +21 -2
- package/lib/wizard.js +5 -2
- package/package.json +9 -3
- package/scripts/build-python-bundle.js +106 -0
- package/scripts/build-tui.js +14 -1
- package/scripts/harvest_experience.py +523 -0
- package/scripts/postinstall.js +15 -9
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Shared JSON/JSONL file utilities for 0dai scripts.
|
|
2
|
+
|
|
3
|
+
Consolidates duplicated _load_json/_save_json/_load_jsonl patterns.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import fcntl
|
|
8
|
+
import gzip
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import shutil
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("0dai.json_utils")
|
|
20
|
+
|
|
21
|
+
_locks: dict[pathlib.Path, threading.Lock] = {}
|
|
22
|
+
_locks_mutex = threading.Lock()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_lock(path: pathlib.Path) -> threading.Lock:
|
|
26
|
+
"""Get or create a threading.Lock for a specific file path."""
|
|
27
|
+
abs_path = path.resolve()
|
|
28
|
+
with _locks_mutex:
|
|
29
|
+
if abs_path not in _locks:
|
|
30
|
+
_locks[abs_path] = threading.Lock()
|
|
31
|
+
return _locks[abs_path]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_json(path: pathlib.Path) -> dict:
|
|
35
|
+
"""Load JSON file, return empty dict on missing.
|
|
36
|
+
Backs up corrupted files before returning {} on invalid JSON.
|
|
37
|
+
"""
|
|
38
|
+
if not path.is_file():
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(path.read_text("utf-8"))
|
|
43
|
+
except json.JSONDecodeError as e:
|
|
44
|
+
ts = int(time.time())
|
|
45
|
+
backup = path.with_suffix(f".corrupt-{ts}.json")
|
|
46
|
+
try:
|
|
47
|
+
shutil.copy2(path, backup)
|
|
48
|
+
log.error("load_json: corrupted %s backed up to %s: %s", path, backup, e)
|
|
49
|
+
except OSError as backup_err:
|
|
50
|
+
log.critical("load_json: %s corrupted AND backup failed: %s + %s", path, e, backup_err)
|
|
51
|
+
raise
|
|
52
|
+
return {}
|
|
53
|
+
except OSError as e:
|
|
54
|
+
log.warning("load_json: cannot read %s: %s", path, e)
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_json(path: pathlib.Path, data: dict) -> None:
|
|
59
|
+
"""Atomic JSON write via temp file rename."""
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
tmp = path.with_suffix(".tmp")
|
|
62
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", "utf-8")
|
|
63
|
+
tmp.rename(path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def update_json(path: pathlib.Path, mutator_fn: Callable[[dict[str, Any]], None]) -> dict[str, Any]:
|
|
67
|
+
"""Atomically load, mutate, and save a JSON file under a path-based lock.
|
|
68
|
+
|
|
69
|
+
The mutator_fn receives the loaded dict and should modify it in-place.
|
|
70
|
+
Returns the updated dictionary.
|
|
71
|
+
"""
|
|
72
|
+
lock = _get_lock(path)
|
|
73
|
+
with lock:
|
|
74
|
+
data = load_json(path)
|
|
75
|
+
mutator_fn(data)
|
|
76
|
+
save_json(path, data)
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_jsonl(path: pathlib.Path) -> list[dict]:
|
|
81
|
+
"""Load JSONL file, skip invalid lines."""
|
|
82
|
+
if not path.is_file():
|
|
83
|
+
return []
|
|
84
|
+
entries = []
|
|
85
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
86
|
+
line = line.strip()
|
|
87
|
+
if line:
|
|
88
|
+
try:
|
|
89
|
+
entries.append(json.loads(line))
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
pass
|
|
92
|
+
return entries
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _loads_jsonl_line(raw: bytes) -> dict | None:
|
|
96
|
+
line = raw.strip()
|
|
97
|
+
if not line:
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
value = json.loads(line.decode("utf-8", errors="replace"))
|
|
101
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
102
|
+
return None
|
|
103
|
+
return value if isinstance(value, dict) else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def iter_jsonl_reverse(path: pathlib.Path, *, chunk_size: int = 64 * 1024) -> Iterator[dict]:
|
|
107
|
+
"""Yield valid JSONL dicts from newest to oldest without loading the full file.
|
|
108
|
+
|
|
109
|
+
Large operational logs such as swarm ledgers grow continuously. Status and
|
|
110
|
+
health probes usually need recent rows only; this keeps those hot paths
|
|
111
|
+
bounded by tail size instead of total file size.
|
|
112
|
+
"""
|
|
113
|
+
if chunk_size <= 0:
|
|
114
|
+
raise ValueError("chunk_size must be positive")
|
|
115
|
+
if not path.is_file():
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
with path.open("rb") as fh:
|
|
119
|
+
fh.seek(0, os.SEEK_END)
|
|
120
|
+
pos = fh.tell()
|
|
121
|
+
pending = b""
|
|
122
|
+
while pos > 0:
|
|
123
|
+
read_size = min(chunk_size, pos)
|
|
124
|
+
pos -= read_size
|
|
125
|
+
fh.seek(pos)
|
|
126
|
+
chunk = fh.read(read_size)
|
|
127
|
+
parts = (chunk + pending).split(b"\n")
|
|
128
|
+
pending = parts[0]
|
|
129
|
+
for raw in reversed(parts[1:]):
|
|
130
|
+
parsed = _loads_jsonl_line(raw)
|
|
131
|
+
if parsed is not None:
|
|
132
|
+
yield parsed
|
|
133
|
+
parsed = _loads_jsonl_line(pending)
|
|
134
|
+
if parsed is not None:
|
|
135
|
+
yield parsed
|
|
136
|
+
except (OSError, PermissionError):
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def tail_jsonl(path: pathlib.Path, max_lines: int) -> list[dict]:
|
|
141
|
+
"""Return up to max_lines valid JSONL dicts in chronological order."""
|
|
142
|
+
if max_lines <= 0:
|
|
143
|
+
return []
|
|
144
|
+
rows: list[dict] = []
|
|
145
|
+
for row in iter_jsonl_reverse(path):
|
|
146
|
+
rows.append(row)
|
|
147
|
+
if len(rows) >= max_lines:
|
|
148
|
+
break
|
|
149
|
+
return list(reversed(rows))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def append_jsonl(path: pathlib.Path, entry: dict) -> None:
|
|
153
|
+
"""Append one JSON line to a JSONL file (flock-safe for concurrent agents)."""
|
|
154
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
line = json.dumps(entry, ensure_ascii=False) + "\n"
|
|
156
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
157
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
158
|
+
f.write(line)
|
|
159
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def rotate_jsonl(path: pathlib.Path, max_lines: int = 10_000) -> pathlib.Path | None:
|
|
163
|
+
"""Rotate JSONL file when it exceeds max_lines.
|
|
164
|
+
|
|
165
|
+
Archives old content to {stem}-YYYY-MM.jsonl.gz and keeps only
|
|
166
|
+
the most recent max_lines // 2 entries in the original file.
|
|
167
|
+
Returns the archive path if rotated, None otherwise.
|
|
168
|
+
"""
|
|
169
|
+
if not path.is_file():
|
|
170
|
+
return None
|
|
171
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
172
|
+
if len(lines) <= max_lines:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
# Archive all but the most recent half
|
|
176
|
+
keep = max_lines // 2
|
|
177
|
+
archive_lines = lines[:-keep]
|
|
178
|
+
keep_lines = lines[-keep:]
|
|
179
|
+
|
|
180
|
+
month = time.strftime("%Y-%m", time.gmtime())
|
|
181
|
+
archive_name = f"{path.stem}-{month}.jsonl.gz"
|
|
182
|
+
archive_path = path.parent / archive_name
|
|
183
|
+
|
|
184
|
+
# Append to existing archive (monthly) or create new
|
|
185
|
+
with gzip.open(archive_path, "at", encoding="utf-8") as gz:
|
|
186
|
+
gz.write("\n".join(archive_lines) + "\n")
|
|
187
|
+
|
|
188
|
+
# Rewrite current file with recent entries only
|
|
189
|
+
tmp = path.with_suffix(".tmp")
|
|
190
|
+
tmp.write_text("\n".join(keep_lines) + "\n", "utf-8")
|
|
191
|
+
tmp.rename(path)
|
|
192
|
+
|
|
193
|
+
return archive_path
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check whether an agent runtime exposes the expected 0dai MCP tools."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
REPO_ROOT = pathlib.Path(
|
|
15
|
+
os.environ.get("ODAI_REPO_ROOT", str(pathlib.Path(__file__).resolve().parent.parent))
|
|
16
|
+
)
|
|
17
|
+
CONTRACT_PATH = pathlib.Path(
|
|
18
|
+
os.environ.get(
|
|
19
|
+
"ODAI_MCP_EXPOSURE_CONTRACT",
|
|
20
|
+
str(REPO_ROOT / "ai" / "manifest" / "mcp-exposure-contract.json"),
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_json(path: pathlib.Path, default: Any) -> Any:
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
28
|
+
except (json.JSONDecodeError, OSError):
|
|
29
|
+
return default
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_contract(path: pathlib.Path | None = None) -> dict[str, Any]:
|
|
33
|
+
data = _read_json(path or CONTRACT_PATH, {})
|
|
34
|
+
return data if isinstance(data, dict) else {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def canonical_tool_name(name: str) -> str:
|
|
38
|
+
"""Normalize tool names across MCP namespace formats."""
|
|
39
|
+
name = name.strip()
|
|
40
|
+
if not name:
|
|
41
|
+
return ""
|
|
42
|
+
if "." in name:
|
|
43
|
+
name = name.rsplit(".", 1)[-1]
|
|
44
|
+
if name.startswith("mcp__"):
|
|
45
|
+
parts = [part for part in name.split("__") if part]
|
|
46
|
+
if parts:
|
|
47
|
+
name = parts[-1]
|
|
48
|
+
return name.lstrip("_")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _as_list(value: Any) -> list[str]:
|
|
52
|
+
if isinstance(value, str):
|
|
53
|
+
return [value]
|
|
54
|
+
if isinstance(value, list):
|
|
55
|
+
return [str(item) for item in value if str(item)]
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _merge_agent_contract(contract: dict[str, Any], agent: str) -> dict[str, list[str]]:
|
|
60
|
+
agents = contract.get("agents", {})
|
|
61
|
+
if not isinstance(agents, dict):
|
|
62
|
+
return {"required": [], "recommended": []}
|
|
63
|
+
current = agents.get(agent) if isinstance(agents.get(agent), dict) else None
|
|
64
|
+
if current is None:
|
|
65
|
+
current = agents.get("default") if isinstance(agents.get("default"), dict) else {}
|
|
66
|
+
parent: dict[str, Any] = {}
|
|
67
|
+
inherits = current.get("inherits") if isinstance(current, dict) else ""
|
|
68
|
+
if isinstance(inherits, str) and isinstance(agents.get(inherits), dict):
|
|
69
|
+
parent = agents[inherits]
|
|
70
|
+
required = set(_as_list(parent.get("required"))) | set(_as_list(current.get("required")))
|
|
71
|
+
recommended = set(_as_list(parent.get("recommended"))) | set(_as_list(current.get("recommended")))
|
|
72
|
+
return {
|
|
73
|
+
"required": sorted(canonical_tool_name(item) for item in required if item),
|
|
74
|
+
"recommended": sorted(canonical_tool_name(item) for item in recommended if item),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load_tier_tool_count() -> int | None:
|
|
79
|
+
scripts_dir = REPO_ROOT / "scripts"
|
|
80
|
+
config_path = scripts_dir / "mcp_tier_config.py"
|
|
81
|
+
if not config_path.is_file():
|
|
82
|
+
return None
|
|
83
|
+
module_name = f"_odai_mcp_exposure_tier_config_{abs(hash(config_path))}"
|
|
84
|
+
spec = importlib.util.spec_from_file_location(module_name, config_path)
|
|
85
|
+
if spec is None or spec.loader is None:
|
|
86
|
+
return None
|
|
87
|
+
old_path = list(sys.path)
|
|
88
|
+
sys.path = [str(scripts_dir), *[item for item in old_path if item != str(scripts_dir)]]
|
|
89
|
+
try:
|
|
90
|
+
module = importlib.util.module_from_spec(spec)
|
|
91
|
+
spec.loader.exec_module(module)
|
|
92
|
+
except Exception: # noqa: BLE001 — tier config load failed
|
|
93
|
+
return None
|
|
94
|
+
finally:
|
|
95
|
+
sys.path = old_path
|
|
96
|
+
return len(set(module.FREE_TOOLS) | set(module.PRO_TOOLS))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_observed_tools(values: list[str] | None = None, observed_file: pathlib.Path | None = None) -> set[str]:
|
|
100
|
+
raw: list[str] = []
|
|
101
|
+
if values:
|
|
102
|
+
for value in values:
|
|
103
|
+
raw.extend([item for item in re.split(r"[\s,]+", value) if item])
|
|
104
|
+
env_value = os.environ.get("ODAI_EXPOSED_MCP_TOOLS", "")
|
|
105
|
+
if env_value:
|
|
106
|
+
raw.extend([item for item in re.split(r"[\s,]+", env_value) if item])
|
|
107
|
+
if observed_file:
|
|
108
|
+
data = _read_json(observed_file, [])
|
|
109
|
+
if isinstance(data, list):
|
|
110
|
+
raw.extend(str(item) for item in data)
|
|
111
|
+
elif isinstance(data, dict):
|
|
112
|
+
for key in ("tools", "tool_names", "observed_tools"):
|
|
113
|
+
raw.extend(_as_list(data.get(key)))
|
|
114
|
+
return {name for name in (canonical_tool_name(item) for item in raw) if name}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def evaluate_exposure(
|
|
118
|
+
*,
|
|
119
|
+
agent: str = "default",
|
|
120
|
+
observed_tools: set[str] | None = None,
|
|
121
|
+
contract: dict[str, Any] | None = None,
|
|
122
|
+
) -> dict[str, Any]:
|
|
123
|
+
if contract is None:
|
|
124
|
+
contract = load_contract()
|
|
125
|
+
expected = _merge_agent_contract(contract, agent)
|
|
126
|
+
observed = observed_tools or set()
|
|
127
|
+
required = set(expected["required"])
|
|
128
|
+
recommended = set(expected["recommended"])
|
|
129
|
+
agents = contract.get("agents")
|
|
130
|
+
contract_available = isinstance(agents, dict) and bool(agents)
|
|
131
|
+
contract_tool_count = int((contract.get("project_server") or {}).get("expected_tool_count") or 0)
|
|
132
|
+
expected_count = contract_tool_count
|
|
133
|
+
tier_count = _load_tier_tool_count()
|
|
134
|
+
if tier_count:
|
|
135
|
+
expected_count = tier_count
|
|
136
|
+
|
|
137
|
+
if not observed:
|
|
138
|
+
status = "unknown"
|
|
139
|
+
anomaly_type = ""
|
|
140
|
+
blocking = False
|
|
141
|
+
elif not contract_available:
|
|
142
|
+
status = "red"
|
|
143
|
+
anomaly_type = "tooling.mcp_contract_missing"
|
|
144
|
+
blocking = True
|
|
145
|
+
else:
|
|
146
|
+
missing_required = sorted(required - observed)
|
|
147
|
+
missing_recommended = sorted(recommended - observed)
|
|
148
|
+
if missing_required:
|
|
149
|
+
status = "red"
|
|
150
|
+
anomaly_type = "tooling.mcp_required_missing"
|
|
151
|
+
blocking = True
|
|
152
|
+
elif missing_recommended:
|
|
153
|
+
status = "yellow"
|
|
154
|
+
anomaly_type = "tooling.mcp_partial_exposure"
|
|
155
|
+
blocking = False
|
|
156
|
+
elif expected_count <= 0:
|
|
157
|
+
status = "yellow"
|
|
158
|
+
anomaly_type = "tooling.mcp_contract_count_missing"
|
|
159
|
+
blocking = False
|
|
160
|
+
else:
|
|
161
|
+
status = "green"
|
|
162
|
+
anomaly_type = ""
|
|
163
|
+
blocking = False
|
|
164
|
+
|
|
165
|
+
contract_tier_count_match = True
|
|
166
|
+
if tier_count is not None:
|
|
167
|
+
contract_tier_count_match = contract_tool_count == tier_count
|
|
168
|
+
if not contract_tier_count_match and status != "red":
|
|
169
|
+
status = "yellow"
|
|
170
|
+
anomaly_type = "tooling.mcp_contract_tier_drift"
|
|
171
|
+
blocking = False
|
|
172
|
+
|
|
173
|
+
result = {
|
|
174
|
+
"status": status,
|
|
175
|
+
"agent": agent,
|
|
176
|
+
"blocking": blocking,
|
|
177
|
+
"anomaly_type": anomaly_type,
|
|
178
|
+
"required": sorted(required),
|
|
179
|
+
"recommended": sorted(recommended),
|
|
180
|
+
"observed_count": len(observed),
|
|
181
|
+
"expected_tool_count": expected_count,
|
|
182
|
+
"missing_required": sorted(required - observed) if observed else [],
|
|
183
|
+
"missing_recommended": sorted(recommended - observed) if observed else [],
|
|
184
|
+
"observed_required": sorted(required & observed),
|
|
185
|
+
"observed_recommended": sorted(recommended & observed),
|
|
186
|
+
"contract_tool_count": contract_tool_count,
|
|
187
|
+
"contract_available": contract_available,
|
|
188
|
+
}
|
|
189
|
+
if tier_count is not None:
|
|
190
|
+
result["tier_config_tool_count"] = tier_count
|
|
191
|
+
result["contract_tier_count_match"] = contract_tier_count_match
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def evaluate_from_env(agent: str = "") -> dict[str, Any]:
|
|
196
|
+
return evaluate_exposure(
|
|
197
|
+
agent=agent or os.environ.get("ODAI_AGENT_KIND") or os.environ.get("ODAI_AGENT") or "default",
|
|
198
|
+
observed_tools=parse_observed_tools(),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _emit_anomaly(result: dict[str, Any], *, dry_run: bool) -> dict[str, Any] | None:
|
|
203
|
+
anomaly_type = result.get("anomaly_type")
|
|
204
|
+
if not anomaly_type:
|
|
205
|
+
return None
|
|
206
|
+
try:
|
|
207
|
+
import anomaly_alert # noqa: PLC0415
|
|
208
|
+
except ImportError:
|
|
209
|
+
return {"status": "failed", "reason": "anomaly_alert_import_failed"}
|
|
210
|
+
return anomaly_alert.emit_anomaly(
|
|
211
|
+
str(anomaly_type),
|
|
212
|
+
f"MCP exposure check {result['status']} for {result['agent']}",
|
|
213
|
+
source="mcp_exposure_check",
|
|
214
|
+
context={
|
|
215
|
+
"agent": result["agent"],
|
|
216
|
+
"missing_required": result.get("missing_required", []),
|
|
217
|
+
"missing_recommended": result.get("missing_recommended", []),
|
|
218
|
+
"observed_count": result.get("observed_count", 0),
|
|
219
|
+
"expected_tool_count": result.get("expected_tool_count", 0),
|
|
220
|
+
},
|
|
221
|
+
dry_run=dry_run,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
226
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
227
|
+
parser.add_argument("--agent", default="default")
|
|
228
|
+
parser.add_argument("--observed-tool", action="append", default=[])
|
|
229
|
+
parser.add_argument("--observed-file", type=pathlib.Path)
|
|
230
|
+
parser.add_argument("--emit-anomaly", action="store_true")
|
|
231
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
232
|
+
parser.add_argument("--fail-red", action="store_true")
|
|
233
|
+
return parser
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main(argv: list[str] | None = None) -> int:
|
|
237
|
+
args = _build_parser().parse_args(argv)
|
|
238
|
+
observed = parse_observed_tools(args.observed_tool, args.observed_file)
|
|
239
|
+
result = evaluate_exposure(agent=args.agent, observed_tools=observed)
|
|
240
|
+
if args.emit_anomaly:
|
|
241
|
+
result["emitted_anomaly"] = _emit_anomaly(result, dry_run=args.dry_run)
|
|
242
|
+
sys.stdout.write(json.dumps(result, ensure_ascii=True, sort_keys=True) + "\n")
|
|
243
|
+
return 2 if args.fail_red and result["status"] == "red" else 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
raise SystemExit(main())
|