agentmetry 0.4.0__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.
- agentmetry/__init__.py +12 -0
- agentmetry/api/__init__.py +0 -0
- agentmetry/api/main.py +232 -0
- agentmetry/api/routes/__init__.py +0 -0
- agentmetry/api/routes/audit.py +396 -0
- agentmetry/api/websocket.py +53 -0
- agentmetry/api/ws_bridge.py +34 -0
- agentmetry/cli/__init__.py +941 -0
- agentmetry/cli/__main__.py +5 -0
- agentmetry/core/__init__.py +0 -0
- agentmetry/core/audit/__init__.py +1 -0
- agentmetry/core/audit/adapters/__init__.py +0 -0
- agentmetry/core/audit/adapters/agt.py +304 -0
- agentmetry/core/audit/adapters/cloudevents.py +159 -0
- agentmetry/core/audit/adapters/ecs.py +103 -0
- agentmetry/core/audit/adapters/splunk.py +40 -0
- agentmetry/core/audit/alerts.py +56 -0
- agentmetry/core/audit/canonical.py +150 -0
- agentmetry/core/audit/compliance_digest.py +299 -0
- agentmetry/core/audit/detection/__init__.py +9 -0
- agentmetry/core/audit/detection/benchmark.py +194 -0
- agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +1 -0
- agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +1 -0
- agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +42 -0
- agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +30 -0
- agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/corpus.yaml +443 -0
- agentmetry/core/audit/detection/disposition.py +651 -0
- agentmetry/core/audit/detection/engine.py +78 -0
- agentmetry/core/audit/detection/live.py +127 -0
- agentmetry/core/audit/detection/live_store.py +355 -0
- agentmetry/core/audit/detection/models.py +53 -0
- agentmetry/core/audit/detection/rules.py +1314 -0
- agentmetry/core/audit/detection/traits.py +648 -0
- agentmetry/core/audit/detection/yaml_config.py +91 -0
- agentmetry/core/audit/detection/yaml_rules.py +83 -0
- agentmetry/core/audit/dlp/__init__.py +4 -0
- agentmetry/core/audit/dlp/loader.py +29 -0
- agentmetry/core/audit/dlp/models.py +29 -0
- agentmetry/core/audit/dlp/scanner.py +96 -0
- agentmetry/core/audit/dogfood.py +398 -0
- agentmetry/core/audit/evidence_pack.py +500 -0
- agentmetry/core/audit/external.py +213 -0
- agentmetry/core/audit/hashing.py +21 -0
- agentmetry/core/audit/hook_bootstrap.py +451 -0
- agentmetry/core/audit/identity.py +39 -0
- agentmetry/core/audit/ingest.py +242 -0
- agentmetry/core/audit/migrate.py +73 -0
- agentmetry/core/audit/mitre.py +244 -0
- agentmetry/core/audit/policy.py +99 -0
- agentmetry/core/audit/redaction.py +50 -0
- agentmetry/core/audit/replay.py +54 -0
- agentmetry/core/audit/run_context.py +129 -0
- agentmetry/core/audit/sinks.py +235 -0
- agentmetry/core/audit/spool.py +394 -0
- agentmetry/core/audit/tool_policy/__init__.py +4 -0
- agentmetry/core/audit/tool_policy/evaluator.py +198 -0
- agentmetry/core/audit/tool_policy/loader.py +44 -0
- agentmetry/core/audit/tool_policy/models.py +25 -0
- agentmetry/core/audit/trail_chain.py +300 -0
- agentmetry/core/audit/trail_db.py +491 -0
- agentmetry/core/audit/trail_merkle.py +332 -0
- agentmetry/core/auth.py +54 -0
- agentmetry/core/bus/__init__.py +5 -0
- agentmetry/core/bus/audit_exporter.py +107 -0
- agentmetry/core/bus/bridges.py +26 -0
- agentmetry/core/bus/bus.py +102 -0
- agentmetry/core/bus/events.py +50 -0
- agentmetry/core/bus/outbox.py +124 -0
- agentmetry/core/config.py +177 -0
- agentmetry/core/diagnostics/__init__.py +0 -0
- agentmetry/core/diagnostics/autostart.py +563 -0
- agentmetry/core/diagnostics/doctor.py +535 -0
- agentmetry/core/diagnostics/driver_paths.py +156 -0
- agentmetry/core/diagnostics/env_file.py +45 -0
- agentmetry/core/drivers/__init__.py +4 -0
- agentmetry/core/drivers/host.py +263 -0
- agentmetry/core/drivers/permissions.py +37 -0
- agentmetry/core/drivers/spec.py +118 -0
- agentmetry/core/extensions.py +107 -0
- agentmetry/core/health.py +26 -0
- agentmetry/core/version.py +13 -0
- agentmetry/policies/detection/manifest.yaml +43 -0
- agentmetry/policies/dlp/manifest.yaml +161 -0
- agentmetry/policies/opa/agent_rules.rego +33 -0
- agentmetry/policies/tool/manifest.yaml +117 -0
- agentmetry-0.4.0.dist-info/METADATA +86 -0
- agentmetry-0.4.0.dist-info/RECORD +131 -0
- agentmetry-0.4.0.dist-info/WHEEL +4 -0
- agentmetry-0.4.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Read/write orchestrator .env keys without pulling in dotenv."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_env_key(path: Path, key: str) -> str:
|
|
9
|
+
if not path.is_file():
|
|
10
|
+
return ""
|
|
11
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
12
|
+
line = line.strip()
|
|
13
|
+
if not line or line.startswith("#"):
|
|
14
|
+
continue
|
|
15
|
+
if "=" in line:
|
|
16
|
+
k, _, v = line.partition("=")
|
|
17
|
+
if k.strip() == key:
|
|
18
|
+
return v.strip().strip('"').strip("'")
|
|
19
|
+
return ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def upsert_env_key(path: Path, key: str, value: str) -> None:
|
|
23
|
+
"""Set or append key=value in a .env file."""
|
|
24
|
+
lines: list[str] = []
|
|
25
|
+
if path.is_file():
|
|
26
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
27
|
+
out: list[str] = []
|
|
28
|
+
found = False
|
|
29
|
+
for line in lines:
|
|
30
|
+
stripped = line.strip()
|
|
31
|
+
if stripped.startswith("#") or "=" not in stripped:
|
|
32
|
+
out.append(line)
|
|
33
|
+
continue
|
|
34
|
+
k, _, _ = stripped.partition("=")
|
|
35
|
+
if k.strip() == key:
|
|
36
|
+
out.append(f"{key}={value}")
|
|
37
|
+
found = True
|
|
38
|
+
else:
|
|
39
|
+
out.append(line)
|
|
40
|
+
if not found:
|
|
41
|
+
if out and out[-1].strip():
|
|
42
|
+
out.append("")
|
|
43
|
+
out.append(f"{key}={value}")
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
path.write_text("\n".join(out) + "\n", encoding="utf-8")
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""MCP host — mounts driver servers and routes governed tool calls.
|
|
2
|
+
|
|
3
|
+
Each driver runs inside its own owner task, which enters the stdio/SSE
|
|
4
|
+
context, serves calls, and exits it on shutdown. anyio cancel scopes must be
|
|
5
|
+
entered and exited by the same task, so contexts never cross task boundaries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from agentmetry.core.audit.canonical import normalize_arguments_for_audit
|
|
17
|
+
from agentmetry.core.audit.run_context import audit_payload, record_tool_call
|
|
18
|
+
from agentmetry.core.bus.bus import bus
|
|
19
|
+
from agentmetry.core.bus.events import DRIVER_FAILED, DRIVER_MOUNTED, TOOL_CALLED, TOOL_DENIED
|
|
20
|
+
from agentmetry.core.config import settings
|
|
21
|
+
from agentmetry.core.drivers.permissions import (
|
|
22
|
+
ToolExecApprovalRequired,
|
|
23
|
+
ToolPermissionError,
|
|
24
|
+
check_tool_allowed,
|
|
25
|
+
)
|
|
26
|
+
from agentmetry.core.diagnostics.driver_paths import load_resolved_driver_specs
|
|
27
|
+
from agentmetry.core.drivers.spec import DriverSpec
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_MOUNT_TIMEOUT_S = 45 # npx may download a package on first mount
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ToolMeta:
|
|
36
|
+
driver: str
|
|
37
|
+
name: str
|
|
38
|
+
qualified: str
|
|
39
|
+
description: str
|
|
40
|
+
tags: list[str] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class _Driver:
|
|
45
|
+
spec: DriverSpec
|
|
46
|
+
session: Any = None
|
|
47
|
+
task: asyncio.Task | None = None
|
|
48
|
+
shutdown: asyncio.Event = field(default_factory=asyncio.Event)
|
|
49
|
+
state: str = "mounting" # mounting | mounted | failed | stopped
|
|
50
|
+
error: str = ""
|
|
51
|
+
tools: list[ToolMeta] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_config_path() -> Path:
|
|
55
|
+
return Path(settings.vault_path) / ".system" / "drivers.json"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MCPHost:
|
|
59
|
+
def __init__(self):
|
|
60
|
+
self._drivers: dict[str, _Driver] = {}
|
|
61
|
+
self._tools: dict[str, ToolMeta] = {}
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------ lifecycle
|
|
64
|
+
|
|
65
|
+
async def mount_all(self, config_path: Path | None = None) -> None:
|
|
66
|
+
for spec in load_resolved_driver_specs(config_path or default_config_path()):
|
|
67
|
+
await self.mount(spec)
|
|
68
|
+
|
|
69
|
+
async def mount(self, spec: DriverSpec) -> bool:
|
|
70
|
+
"""Start a driver's owner task; returns True once it is serving."""
|
|
71
|
+
driver = _Driver(spec=spec)
|
|
72
|
+
self._drivers[spec.name] = driver
|
|
73
|
+
ready: asyncio.Future[bool] = asyncio.get_running_loop().create_future()
|
|
74
|
+
driver.task = asyncio.create_task(
|
|
75
|
+
self._driver_task(driver, ready), name=f"driver-{spec.name}"
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
return await asyncio.wait_for(ready, timeout=_MOUNT_TIMEOUT_S)
|
|
79
|
+
except asyncio.TimeoutError:
|
|
80
|
+
driver.state = "failed"
|
|
81
|
+
driver.error = f"mount timed out after {_MOUNT_TIMEOUT_S}s"
|
|
82
|
+
driver.shutdown.set()
|
|
83
|
+
logger.warning("Driver %s: %s", spec.name, driver.error)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
async def _driver_task(self, driver: _Driver, ready: asyncio.Future) -> None:
|
|
87
|
+
spec = driver.spec
|
|
88
|
+
try:
|
|
89
|
+
if spec.transport == "stdio":
|
|
90
|
+
from mcp import ClientSession, StdioServerParameters
|
|
91
|
+
from mcp.client.stdio import stdio_client
|
|
92
|
+
|
|
93
|
+
params = StdioServerParameters(
|
|
94
|
+
command=spec.command, args=spec.args, env=spec.build_env()
|
|
95
|
+
)
|
|
96
|
+
async with stdio_client(params) as (read, write):
|
|
97
|
+
async with ClientSession(read, write) as session:
|
|
98
|
+
await self._serve(driver, session, ready)
|
|
99
|
+
elif spec.transport == "sse":
|
|
100
|
+
from mcp import ClientSession
|
|
101
|
+
from mcp.client.sse import sse_client
|
|
102
|
+
|
|
103
|
+
async with sse_client(spec.url) as (read, write):
|
|
104
|
+
async with ClientSession(read, write) as session:
|
|
105
|
+
await self._serve(driver, session, ready)
|
|
106
|
+
else:
|
|
107
|
+
raise ValueError(f"Unknown transport '{spec.transport}'")
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
driver.state = "failed"
|
|
110
|
+
driver.error = str(exc)[:200]
|
|
111
|
+
logger.warning("Driver %s failed: %s", spec.name, driver.error)
|
|
112
|
+
bus.publish(DRIVER_FAILED, {
|
|
113
|
+
"type": "driver_failed",
|
|
114
|
+
"driver": spec.name,
|
|
115
|
+
"error": driver.error,
|
|
116
|
+
})
|
|
117
|
+
finally:
|
|
118
|
+
self._deregister(spec.name)
|
|
119
|
+
driver.session = None
|
|
120
|
+
if driver.state != "failed":
|
|
121
|
+
driver.state = "stopped"
|
|
122
|
+
if not ready.done():
|
|
123
|
+
ready.set_result(False)
|
|
124
|
+
|
|
125
|
+
async def _serve(self, driver: _Driver, session: Any, ready: asyncio.Future) -> None:
|
|
126
|
+
spec = driver.spec
|
|
127
|
+
await session.initialize()
|
|
128
|
+
listed = await session.list_tools()
|
|
129
|
+
driver.tools = [
|
|
130
|
+
ToolMeta(
|
|
131
|
+
driver=spec.name,
|
|
132
|
+
name=tool.name,
|
|
133
|
+
qualified=f"{spec.name}.{tool.name}",
|
|
134
|
+
description=tool.description or "",
|
|
135
|
+
tags=list(spec.tags),
|
|
136
|
+
)
|
|
137
|
+
for tool in listed.tools
|
|
138
|
+
]
|
|
139
|
+
for meta in driver.tools:
|
|
140
|
+
self._tools[meta.qualified] = meta
|
|
141
|
+
driver.session = session
|
|
142
|
+
driver.state = "mounted"
|
|
143
|
+
logger.info("Driver %s mounted: %d tool(s)", spec.name, len(driver.tools))
|
|
144
|
+
bus.publish(DRIVER_MOUNTED, {
|
|
145
|
+
"type": "driver_mounted",
|
|
146
|
+
"driver": spec.name,
|
|
147
|
+
"tools": [m.qualified for m in driver.tools],
|
|
148
|
+
})
|
|
149
|
+
if not ready.done():
|
|
150
|
+
ready.set_result(True)
|
|
151
|
+
await driver.shutdown.wait()
|
|
152
|
+
|
|
153
|
+
async def unmount_all(self) -> None:
|
|
154
|
+
for driver in self._drivers.values():
|
|
155
|
+
driver.shutdown.set()
|
|
156
|
+
for driver in self._drivers.values():
|
|
157
|
+
if driver.task:
|
|
158
|
+
try:
|
|
159
|
+
await asyncio.wait_for(driver.task, timeout=10)
|
|
160
|
+
except (asyncio.TimeoutError, asyncio.CancelledError):
|
|
161
|
+
driver.task.cancel()
|
|
162
|
+
self._drivers.clear()
|
|
163
|
+
self._tools.clear()
|
|
164
|
+
|
|
165
|
+
def _deregister(self, driver_name: str) -> None:
|
|
166
|
+
self._tools = {
|
|
167
|
+
q: m for q, m in self._tools.items() if m.driver != driver_name
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------ inspection
|
|
171
|
+
|
|
172
|
+
def snapshot(self) -> dict[str, Any]:
|
|
173
|
+
return {
|
|
174
|
+
name: {
|
|
175
|
+
"state": d.state,
|
|
176
|
+
"transport": d.spec.transport,
|
|
177
|
+
"tags": d.spec.tags,
|
|
178
|
+
"tools": len(d.tools),
|
|
179
|
+
**({"error": d.error} if d.error else {}),
|
|
180
|
+
}
|
|
181
|
+
for name, d in self._drivers.items()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
def list_tools(self) -> list[dict[str, Any]]:
|
|
185
|
+
return [
|
|
186
|
+
{
|
|
187
|
+
"qualified": m.qualified,
|
|
188
|
+
"driver": m.driver,
|
|
189
|
+
"description": m.description,
|
|
190
|
+
"tags": m.tags,
|
|
191
|
+
}
|
|
192
|
+
for m in self._tools.values()
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------ execution
|
|
196
|
+
|
|
197
|
+
async def call_tool(
|
|
198
|
+
self,
|
|
199
|
+
qualified: str,
|
|
200
|
+
arguments: dict[str, Any],
|
|
201
|
+
*,
|
|
202
|
+
skill_config: dict[str, Any],
|
|
203
|
+
session_id: str = "",
|
|
204
|
+
thread_id: str = "",
|
|
205
|
+
) -> Any:
|
|
206
|
+
"""Governed tool call: allowlist + Tier 0 exec gate + audit events."""
|
|
207
|
+
meta = self._tools.get(qualified)
|
|
208
|
+
if meta is None:
|
|
209
|
+
raise ToolPermissionError(f"No such tool mounted: '{qualified}'")
|
|
210
|
+
|
|
211
|
+
skill_name = skill_config.get("name", "?")
|
|
212
|
+
try:
|
|
213
|
+
check_tool_allowed(skill_config, qualified, tags=meta.tags)
|
|
214
|
+
except ToolExecApprovalRequired:
|
|
215
|
+
|
|
216
|
+
bus.publish(TOOL_DENIED, {
|
|
217
|
+
"type": "tool_denied",
|
|
218
|
+
"tool": qualified,
|
|
219
|
+
"skill": skill_name,
|
|
220
|
+
"reason": "tool_exec_approval",
|
|
221
|
+
"arguments_sha256": normalize_arguments_for_audit(arguments),
|
|
222
|
+
**audit_payload(thread_id),
|
|
223
|
+
}, session_id=session_id, thread_id=thread_id)
|
|
224
|
+
raise
|
|
225
|
+
except Exception:
|
|
226
|
+
bus.publish(TOOL_DENIED, {
|
|
227
|
+
"type": "tool_denied",
|
|
228
|
+
"tool": qualified,
|
|
229
|
+
"skill": skill_name,
|
|
230
|
+
"reason": "not_allowed",
|
|
231
|
+
"arguments_sha256": normalize_arguments_for_audit(arguments),
|
|
232
|
+
**audit_payload(thread_id),
|
|
233
|
+
}, session_id=session_id, thread_id=thread_id)
|
|
234
|
+
raise
|
|
235
|
+
|
|
236
|
+
driver = self._drivers.get(meta.driver)
|
|
237
|
+
if driver is None or driver.state != "mounted" or driver.session is None:
|
|
238
|
+
raise RuntimeError(f"Driver '{meta.driver}' is not mounted")
|
|
239
|
+
|
|
240
|
+
result = await driver.session.call_tool(meta.name, arguments)
|
|
241
|
+
args_hash = normalize_arguments_for_audit(arguments)
|
|
242
|
+
record_tool_call(thread_id, qualified, args_hash)
|
|
243
|
+
payload: dict[str, Any] = {
|
|
244
|
+
"type": "tool_called",
|
|
245
|
+
"tool": qualified,
|
|
246
|
+
"skill": skill_name,
|
|
247
|
+
"arguments_sha256": args_hash,
|
|
248
|
+
**audit_payload(thread_id),
|
|
249
|
+
}
|
|
250
|
+
if qualified == "gmail.send_draft":
|
|
251
|
+
payload["draft_id"] = arguments.get("draft_id")
|
|
252
|
+
bus.publish(TOOL_CALLED, payload, session_id=session_id, thread_id=thread_id)
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
_host: MCPHost | None = None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def get_mcp_host() -> MCPHost:
|
|
260
|
+
global _host
|
|
261
|
+
if _host is None:
|
|
262
|
+
_host = MCPHost()
|
|
263
|
+
return _host
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Tool permissions — closed by default, per-skill allowlists, Tier 0 exec gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fnmatch import fnmatch
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ToolPermissionError(Exception):
|
|
10
|
+
"""The skill's YAML does not allow this tool."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ToolExecApprovalRequired(ToolPermissionError):
|
|
14
|
+
"""Tier 0 sandbox policy: exec-tagged tools are denied until a sandbox
|
|
15
|
+
tier exists; each denial is recorded as a TOOL_EXEC_APPROVAL interrupt."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_tool_allowed(
|
|
19
|
+
skill_config: dict[str, Any],
|
|
20
|
+
qualified: str,
|
|
21
|
+
*,
|
|
22
|
+
tags: list[str],
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Raise unless the skill's `tools:` allowlist covers this qualified name.
|
|
25
|
+
|
|
26
|
+
Closed by default: a skill without a `tools:` key can call nothing.
|
|
27
|
+
Patterns use fnmatch, e.g. ["fs.read_file", "search.*"].
|
|
28
|
+
"""
|
|
29
|
+
allow = skill_config.get("tools") or []
|
|
30
|
+
if not any(fnmatch(qualified, pattern) for pattern in allow):
|
|
31
|
+
raise ToolPermissionError(
|
|
32
|
+
f"Skill '{skill_config.get('name', '?')}' does not allow tool '{qualified}'"
|
|
33
|
+
)
|
|
34
|
+
if "exec" in tags:
|
|
35
|
+
raise ToolExecApprovalRequired(
|
|
36
|
+
f"Tool '{qualified}' is exec-tagged — denied by Tier 0 sandbox policy"
|
|
37
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Driver specs — operator-owned config, resilient loading, scrubbed env."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Minimum a Windows subprocess needs to start node/python at all. Secrets like
|
|
15
|
+
# GEMINI_API_KEY never cross into a driver unless env_allow names them.
|
|
16
|
+
_BASE_ENV_NAMES = (
|
|
17
|
+
"SYSTEMROOT",
|
|
18
|
+
"PATH",
|
|
19
|
+
"PATHEXT",
|
|
20
|
+
"COMSPEC",
|
|
21
|
+
"TEMP",
|
|
22
|
+
"TMP",
|
|
23
|
+
"APPDATA",
|
|
24
|
+
"LOCALAPPDATA",
|
|
25
|
+
"USERPROFILE",
|
|
26
|
+
"PROGRAMFILES",
|
|
27
|
+
"HOME",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _orchestrator_env_file() -> Path:
|
|
32
|
+
return Path(__file__).resolve().parents[3] / ".env"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _dotenv_values() -> dict[str, str]:
|
|
36
|
+
"""Minimal KEY=VALUE parse of the orchestrator .env (gitignored secrets).
|
|
37
|
+
|
|
38
|
+
pydantic-settings reads .env into Settings fields only — it never exports
|
|
39
|
+
to os.environ — so env_allow names documented as ".env keys" (SERPER,
|
|
40
|
+
GMAIL client creds, ...) would otherwise silently never reach a driver.
|
|
41
|
+
"""
|
|
42
|
+
path = _orchestrator_env_file()
|
|
43
|
+
if not path.exists():
|
|
44
|
+
return {}
|
|
45
|
+
values: dict[str, str] = {}
|
|
46
|
+
try:
|
|
47
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
48
|
+
line = line.strip()
|
|
49
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
50
|
+
continue
|
|
51
|
+
key, _, value = line.partition("=")
|
|
52
|
+
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
logger.warning("Could not read %s for driver env: %s", path, exc)
|
|
55
|
+
return values
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def base_subprocess_env(extra_names: list[str] | None = None) -> dict[str, str]:
|
|
59
|
+
"""Allowlist-only environment for any sandboxed/driver subprocess.
|
|
60
|
+
|
|
61
|
+
Allowlisted names resolve from the live process env first, then fall back
|
|
62
|
+
to the orchestrator's .env file. Base names never fall back — they are
|
|
63
|
+
OS plumbing, not secrets.
|
|
64
|
+
"""
|
|
65
|
+
result: dict[str, str] = {}
|
|
66
|
+
for name in _BASE_ENV_NAMES:
|
|
67
|
+
value = os.environ.get(name)
|
|
68
|
+
if value is not None:
|
|
69
|
+
result[name] = value
|
|
70
|
+
if extra_names:
|
|
71
|
+
dotenv = _dotenv_values()
|
|
72
|
+
for name in extra_names:
|
|
73
|
+
value = os.environ.get(name, dotenv.get(name))
|
|
74
|
+
if value is not None:
|
|
75
|
+
result[name] = value
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class DriverSpec(BaseModel):
|
|
80
|
+
name: str = Field(pattern=r"^[a-z0-9_-]+$")
|
|
81
|
+
transport: str = "stdio" # stdio | sse
|
|
82
|
+
command: str = ""
|
|
83
|
+
args: list[str] = Field(default_factory=list)
|
|
84
|
+
url: str = ""
|
|
85
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
86
|
+
env_allow: list[str] = Field(default_factory=list)
|
|
87
|
+
tags: list[str] = Field(default_factory=list)
|
|
88
|
+
enabled: bool = True
|
|
89
|
+
|
|
90
|
+
def build_env(self) -> dict[str, str]:
|
|
91
|
+
"""Allowlist-only environment for the driver subprocess."""
|
|
92
|
+
result = base_subprocess_env(self.env_allow)
|
|
93
|
+
result.update(self.env)
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_driver_specs(config_path: Path) -> list[DriverSpec]:
|
|
98
|
+
"""Read drivers.json; a malformed entry never blocks the others."""
|
|
99
|
+
if not config_path.exists():
|
|
100
|
+
return []
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
103
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
104
|
+
logger.warning("drivers.json unreadable (%s) — no drivers mounted", exc)
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
specs: list[DriverSpec] = []
|
|
108
|
+
for entry in data.get("drivers", []):
|
|
109
|
+
try:
|
|
110
|
+
spec = DriverSpec.model_validate(entry)
|
|
111
|
+
except ValidationError as exc:
|
|
112
|
+
logger.warning("Skipping invalid driver entry %r: %s", entry.get("name"), exc)
|
|
113
|
+
continue
|
|
114
|
+
if not spec.enabled:
|
|
115
|
+
logger.info("Driver %s is disabled — skipped", spec.name)
|
|
116
|
+
continue
|
|
117
|
+
specs.append(spec)
|
|
118
|
+
return specs
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Optional extension loader for enterprise add-ons.
|
|
2
|
+
|
|
3
|
+
Third-party packages register via setuptools entry points in the group
|
|
4
|
+
``agentmetry.extensions``::
|
|
5
|
+
|
|
6
|
+
[project.entry-points."agentmetry.extensions"]
|
|
7
|
+
enterprise = "agentmetry_enterprise.register:register"
|
|
8
|
+
|
|
9
|
+
The orchestrator discovers installed extensions at startup and calls
|
|
10
|
+
``register(app, settings=...)``. When no enterprise package is installed,
|
|
11
|
+
this module is a no-op and the OSS quickstart is unchanged.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from importlib.metadata import entry_points, version
|
|
19
|
+
from typing import Any, Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
EXTENSION_GROUP = "agentmetry.extensions"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@runtime_checkable
|
|
29
|
+
class AgentmetryExtension(Protocol):
|
|
30
|
+
"""Contract for ``agentmetry.extensions`` entry points."""
|
|
31
|
+
|
|
32
|
+
def register(self, app: FastAPI, *, settings: Any) -> None: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ExtensionInfo:
|
|
37
|
+
name: str
|
|
38
|
+
value: str
|
|
39
|
+
distribution: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ExtensionRegistry:
|
|
44
|
+
loaded: list[ExtensionInfo] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def summary(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"count": len(self.loaded),
|
|
49
|
+
"extensions": [
|
|
50
|
+
{
|
|
51
|
+
"name": item.name,
|
|
52
|
+
"entry": item.value,
|
|
53
|
+
"distribution": item.distribution,
|
|
54
|
+
}
|
|
55
|
+
for item in self.loaded
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_registry = ExtensionRegistry()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_extension_registry() -> ExtensionRegistry:
|
|
64
|
+
return _registry
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _iter_extension_entry_points():
|
|
68
|
+
try:
|
|
69
|
+
return entry_points(group=EXTENSION_GROUP)
|
|
70
|
+
except TypeError:
|
|
71
|
+
return entry_points().select(group=EXTENSION_GROUP)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _distribution_for_module(module_name: str) -> str | None:
|
|
75
|
+
root = module_name.split(".", 1)[0]
|
|
76
|
+
try:
|
|
77
|
+
return version(root)
|
|
78
|
+
except Exception:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_extensions(app: FastAPI, *, settings: Any) -> ExtensionRegistry:
|
|
83
|
+
"""Discover and invoke all ``agentmetry.extensions`` entry points."""
|
|
84
|
+
_registry.loaded.clear()
|
|
85
|
+
|
|
86
|
+
for ep in sorted(_iter_extension_entry_points(), key=lambda e: e.name):
|
|
87
|
+
try:
|
|
88
|
+
target = ep.load()
|
|
89
|
+
if not callable(target):
|
|
90
|
+
raise TypeError(f"entry point {ep.value!r} is not callable")
|
|
91
|
+
target(app, settings=settings)
|
|
92
|
+
module = ep.module or ep.value.split(":", 1)[0]
|
|
93
|
+
_registry.loaded.append(
|
|
94
|
+
ExtensionInfo(
|
|
95
|
+
name=ep.name,
|
|
96
|
+
value=ep.value,
|
|
97
|
+
distribution=_distribution_for_module(module),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
logger.info("Loaded Agentmetry extension %r (%s)", ep.name, ep.value)
|
|
101
|
+
except Exception:
|
|
102
|
+
logger.exception("Failed to load Agentmetry extension %r (%s)", ep.name, ep.value)
|
|
103
|
+
|
|
104
|
+
if not _registry.loaded:
|
|
105
|
+
logger.debug("No Agentmetry extensions installed (group=%s)", EXTENSION_GROUP)
|
|
106
|
+
|
|
107
|
+
return _registry
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Service health for Agentmetry SIEM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agentmetry.core.config import settings
|
|
8
|
+
from agentmetry.core.extensions import get_extension_registry
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def get_system_health() -> dict[str, Any]:
|
|
13
|
+
"""Return health of the SIEM components."""
|
|
14
|
+
path = Path(settings.audit_export_path)
|
|
15
|
+
extensions = get_extension_registry().summary()
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
"status": "up",
|
|
19
|
+
"mode": "siem",
|
|
20
|
+
"audit_export": {
|
|
21
|
+
"enabled": settings.audit_export_enabled,
|
|
22
|
+
"path": str(path),
|
|
23
|
+
"accessible": path.parent.exists() if settings.audit_export_enabled else None,
|
|
24
|
+
},
|
|
25
|
+
"extensions": extensions,
|
|
26
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Single source of truth for the Agentmetry version.
|
|
2
|
+
|
|
3
|
+
`pyproject.toml` reads this file (hatchling `[tool.hatch.version]`), the API
|
|
4
|
+
advertises it, and `tests/test_version.py` asserts it matches the newest
|
|
5
|
+
released CHANGELOG entry. Evidence packs record the producing version, so a
|
|
6
|
+
version that disagrees with the tag is a provenance defect, not cosmetics.
|
|
7
|
+
|
|
8
|
+
Bump this here and add the matching CHANGELOG section in the same commit.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
version: "1.0"
|
|
2
|
+
|
|
3
|
+
# Tunable thresholds for built-in sequence rules (hot-reload on orchestrator restart).
|
|
4
|
+
thresholds:
|
|
5
|
+
discovery_burst: 3
|
|
6
|
+
delete_burst: 5
|
|
7
|
+
subagent_burst: 5
|
|
8
|
+
session_tool_burst: 40
|
|
9
|
+
host_subagent_burst: 8
|
|
10
|
+
|
|
11
|
+
# Burst rules need a clock as well as a count: N events must land inside the
|
|
12
|
+
# window to fire. Without one, 40 tool calls is just a long coding session and
|
|
13
|
+
# 8 subagent starts is two quiet weeks — both used to alert as "autonomous
|
|
14
|
+
# campaign". Set a window to 0 to match on the raw count instead.
|
|
15
|
+
subagent_burst_window_minutes: 15
|
|
16
|
+
session_tool_burst_window_minutes: 10
|
|
17
|
+
host_subagent_burst_window_minutes: 60
|
|
18
|
+
|
|
19
|
+
# Analyst-authored count rules — no Python PR required. Fires when ≥ min_count
|
|
20
|
+
# events in one session match all filters in `match`.
|
|
21
|
+
count_rules:
|
|
22
|
+
- id: rapid-tool-denials
|
|
23
|
+
title: Rapid tool denials in one session
|
|
24
|
+
severity: medium
|
|
25
|
+
summary: "{count} denied tool calls — possible policy probing or injection retries"
|
|
26
|
+
tactic_ids: ["TA0005"]
|
|
27
|
+
technique_ids: ["T1562"]
|
|
28
|
+
min_count: 5
|
|
29
|
+
match:
|
|
30
|
+
action_type: tool_called
|
|
31
|
+
outcome: denied
|
|
32
|
+
|
|
33
|
+
- id: rapid-dlp-blocks
|
|
34
|
+
title: Repeated DLP blocks in one session
|
|
35
|
+
severity: high
|
|
36
|
+
summary: "{count} DLP-related denials — secrets may be present in agent context"
|
|
37
|
+
tactic_ids: ["TA0010"]
|
|
38
|
+
technique_ids: ["T1552"]
|
|
39
|
+
min_count: 3
|
|
40
|
+
match:
|
|
41
|
+
action_type: tool_called
|
|
42
|
+
outcome: denied
|
|
43
|
+
reason_prefix: "dlp:"
|