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.
Files changed (131) hide show
  1. agentmetry/__init__.py +12 -0
  2. agentmetry/api/__init__.py +0 -0
  3. agentmetry/api/main.py +232 -0
  4. agentmetry/api/routes/__init__.py +0 -0
  5. agentmetry/api/routes/audit.py +396 -0
  6. agentmetry/api/websocket.py +53 -0
  7. agentmetry/api/ws_bridge.py +34 -0
  8. agentmetry/cli/__init__.py +941 -0
  9. agentmetry/cli/__main__.py +5 -0
  10. agentmetry/core/__init__.py +0 -0
  11. agentmetry/core/audit/__init__.py +1 -0
  12. agentmetry/core/audit/adapters/__init__.py +0 -0
  13. agentmetry/core/audit/adapters/agt.py +304 -0
  14. agentmetry/core/audit/adapters/cloudevents.py +159 -0
  15. agentmetry/core/audit/adapters/ecs.py +103 -0
  16. agentmetry/core/audit/adapters/splunk.py +40 -0
  17. agentmetry/core/audit/alerts.py +56 -0
  18. agentmetry/core/audit/canonical.py +150 -0
  19. agentmetry/core/audit/compliance_digest.py +299 -0
  20. agentmetry/core/audit/detection/__init__.py +9 -0
  21. agentmetry/core/audit/detection/benchmark.py +194 -0
  22. agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +3 -0
  23. agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +3 -0
  24. agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +3 -0
  25. agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +2 -0
  26. agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +2 -0
  27. agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +6 -0
  28. agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +5 -0
  29. agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +2 -0
  30. agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +1 -0
  31. agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +3 -0
  32. agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +2 -0
  33. agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +2 -0
  34. agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +2 -0
  35. agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +2 -0
  36. agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +1 -0
  37. agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +2 -0
  38. agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +42 -0
  39. agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +2 -0
  40. agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +3 -0
  41. agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +6 -0
  42. agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +2 -0
  43. agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +3 -0
  44. agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +3 -0
  45. agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +4 -0
  46. agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +5 -0
  47. agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +3 -0
  48. agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +5 -0
  49. agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +5 -0
  50. agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +4 -0
  51. agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +3 -0
  52. agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +3 -0
  53. agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +3 -0
  54. agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +6 -0
  55. agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +6 -0
  56. agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +5 -0
  57. agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +30 -0
  58. agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +2 -0
  59. agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +3 -0
  60. agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +5 -0
  61. agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +2 -0
  62. agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +5 -0
  63. agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +4 -0
  64. agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +5 -0
  65. agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +2 -0
  66. agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +6 -0
  67. agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +6 -0
  68. agentmetry/core/audit/detection/corpus/corpus.yaml +443 -0
  69. agentmetry/core/audit/detection/disposition.py +651 -0
  70. agentmetry/core/audit/detection/engine.py +78 -0
  71. agentmetry/core/audit/detection/live.py +127 -0
  72. agentmetry/core/audit/detection/live_store.py +355 -0
  73. agentmetry/core/audit/detection/models.py +53 -0
  74. agentmetry/core/audit/detection/rules.py +1314 -0
  75. agentmetry/core/audit/detection/traits.py +648 -0
  76. agentmetry/core/audit/detection/yaml_config.py +91 -0
  77. agentmetry/core/audit/detection/yaml_rules.py +83 -0
  78. agentmetry/core/audit/dlp/__init__.py +4 -0
  79. agentmetry/core/audit/dlp/loader.py +29 -0
  80. agentmetry/core/audit/dlp/models.py +29 -0
  81. agentmetry/core/audit/dlp/scanner.py +96 -0
  82. agentmetry/core/audit/dogfood.py +398 -0
  83. agentmetry/core/audit/evidence_pack.py +500 -0
  84. agentmetry/core/audit/external.py +213 -0
  85. agentmetry/core/audit/hashing.py +21 -0
  86. agentmetry/core/audit/hook_bootstrap.py +451 -0
  87. agentmetry/core/audit/identity.py +39 -0
  88. agentmetry/core/audit/ingest.py +242 -0
  89. agentmetry/core/audit/migrate.py +73 -0
  90. agentmetry/core/audit/mitre.py +244 -0
  91. agentmetry/core/audit/policy.py +99 -0
  92. agentmetry/core/audit/redaction.py +50 -0
  93. agentmetry/core/audit/replay.py +54 -0
  94. agentmetry/core/audit/run_context.py +129 -0
  95. agentmetry/core/audit/sinks.py +235 -0
  96. agentmetry/core/audit/spool.py +394 -0
  97. agentmetry/core/audit/tool_policy/__init__.py +4 -0
  98. agentmetry/core/audit/tool_policy/evaluator.py +198 -0
  99. agentmetry/core/audit/tool_policy/loader.py +44 -0
  100. agentmetry/core/audit/tool_policy/models.py +25 -0
  101. agentmetry/core/audit/trail_chain.py +300 -0
  102. agentmetry/core/audit/trail_db.py +491 -0
  103. agentmetry/core/audit/trail_merkle.py +332 -0
  104. agentmetry/core/auth.py +54 -0
  105. agentmetry/core/bus/__init__.py +5 -0
  106. agentmetry/core/bus/audit_exporter.py +107 -0
  107. agentmetry/core/bus/bridges.py +26 -0
  108. agentmetry/core/bus/bus.py +102 -0
  109. agentmetry/core/bus/events.py +50 -0
  110. agentmetry/core/bus/outbox.py +124 -0
  111. agentmetry/core/config.py +177 -0
  112. agentmetry/core/diagnostics/__init__.py +0 -0
  113. agentmetry/core/diagnostics/autostart.py +563 -0
  114. agentmetry/core/diagnostics/doctor.py +535 -0
  115. agentmetry/core/diagnostics/driver_paths.py +156 -0
  116. agentmetry/core/diagnostics/env_file.py +45 -0
  117. agentmetry/core/drivers/__init__.py +4 -0
  118. agentmetry/core/drivers/host.py +263 -0
  119. agentmetry/core/drivers/permissions.py +37 -0
  120. agentmetry/core/drivers/spec.py +118 -0
  121. agentmetry/core/extensions.py +107 -0
  122. agentmetry/core/health.py +26 -0
  123. agentmetry/core/version.py +13 -0
  124. agentmetry/policies/detection/manifest.yaml +43 -0
  125. agentmetry/policies/dlp/manifest.yaml +161 -0
  126. agentmetry/policies/opa/agent_rules.rego +33 -0
  127. agentmetry/policies/tool/manifest.yaml +117 -0
  128. agentmetry-0.4.0.dist-info/METADATA +86 -0
  129. agentmetry-0.4.0.dist-info/RECORD +131 -0
  130. agentmetry-0.4.0.dist-info/WHEEL +4 -0
  131. 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,4 @@
1
+ """Driver subsystem — MCP tool servers mounted from vault/.system/drivers.json.
2
+
3
+ Used by the MCP audit proxy demo and optional background driver mount at startup.
4
+ """
@@ -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:"