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,213 @@
|
|
|
1
|
+
"""Canonical events from external agent adapters (Tier B — Cursor, Claude, etc.)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from agentmetry.core.audit.canonical import SCHEMA_VERSION
|
|
10
|
+
from agentmetry.core.audit.identity import identity_fields
|
|
11
|
+
from agentmetry.core.audit.hashing import arguments_sha256
|
|
12
|
+
from agentmetry.core.audit.redaction import scrub_arg_values, scrub_secrets
|
|
13
|
+
from agentmetry.core.audit.run_context import actor_from_initiator, build_initiator
|
|
14
|
+
from agentmetry.core.config import settings
|
|
15
|
+
|
|
16
|
+
ExternalApp = Literal[
|
|
17
|
+
"cursor", "claude", "antigravity", "codex", "mcp_proxy",
|
|
18
|
+
"qwen", "kimi", "qoder", "codebuddy", "trae", "crewai", "opensre", "agentmetry",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
KNOWN_EXTERNAL_APPS = frozenset({
|
|
22
|
+
"cursor", "claude", "antigravity", "codex", "mcp_proxy",
|
|
23
|
+
"qwen", "kimi", "qoder", "codebuddy", "trae", "crewai", "opensre",
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
_ACTION_MAP = {
|
|
27
|
+
"session_start": ("session_start", "success"),
|
|
28
|
+
"session_end": ("session_end", "success"),
|
|
29
|
+
"tool_called": ("tool_called", "success"),
|
|
30
|
+
"tool_denied": ("tool_called", "denied"),
|
|
31
|
+
"tool_failed": ("tool_called", "error"),
|
|
32
|
+
"approval_request": ("approval_request", "pending"),
|
|
33
|
+
"approval_response": ("approval_response", "success"),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _operator_id() -> str:
|
|
38
|
+
return settings.operator_id.strip() or "local"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _utc_now() -> str:
|
|
42
|
+
return datetime.now(timezone.utc).isoformat()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _split_tool(qualified: str) -> tuple[str, str]:
|
|
46
|
+
if "." in qualified:
|
|
47
|
+
driver, name = qualified.split(".", 1)
|
|
48
|
+
return driver, name
|
|
49
|
+
return "", qualified
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_external_canonical(payload: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
"""Normalize an adapter payload into canonical v1.1 JSON."""
|
|
54
|
+
source_app = str(payload.get("source_app") or "cursor").lower()
|
|
55
|
+
if source_app not in KNOWN_EXTERNAL_APPS and source_app != "agentmetry":
|
|
56
|
+
source_app = "cursor"
|
|
57
|
+
|
|
58
|
+
event_type = str(payload.get("event_type") or "tool_called")
|
|
59
|
+
action_type, default_outcome = _ACTION_MAP.get(event_type, ("tool_called", "success"))
|
|
60
|
+
outcome = str(payload.get("outcome") or default_outcome)
|
|
61
|
+
reason = str(payload.get("reason") or "")
|
|
62
|
+
|
|
63
|
+
correlation_id = str(payload.get("correlation_id") or payload.get("thread_id") or "")
|
|
64
|
+
session_id = str(payload.get("session_id") or "")
|
|
65
|
+
|
|
66
|
+
initiator_raw = payload.get("initiator")
|
|
67
|
+
if isinstance(initiator_raw, dict) and initiator_raw.get("actor_type"):
|
|
68
|
+
initiator = {
|
|
69
|
+
"actor_type": str(initiator_raw.get("actor_type") or "human"),
|
|
70
|
+
"trigger": str(initiator_raw.get("trigger") or "manual"),
|
|
71
|
+
"operator_id": str(initiator_raw.get("operator_id") or _operator_id()),
|
|
72
|
+
}
|
|
73
|
+
else:
|
|
74
|
+
trigger = str(payload.get("triggered_by") or "manual")
|
|
75
|
+
initiator = build_initiator(trigger)
|
|
76
|
+
|
|
77
|
+
tool_block = payload.get("tool") if isinstance(payload.get("tool"), dict) else {}
|
|
78
|
+
tool_qualified = str(
|
|
79
|
+
tool_block.get("qualified")
|
|
80
|
+
or payload.get("tool_qualified")
|
|
81
|
+
or payload.get("tool_name")
|
|
82
|
+
or ""
|
|
83
|
+
)
|
|
84
|
+
driver_name, tool_name = _split_tool(tool_qualified)
|
|
85
|
+
|
|
86
|
+
args = tool_block.get("arguments")
|
|
87
|
+
if isinstance(args, dict):
|
|
88
|
+
input_hash = str(tool_block.get("input_hash") or arguments_sha256(args))
|
|
89
|
+
else:
|
|
90
|
+
input_hash = str(tool_block.get("input_hash") or payload.get("input_hash") or "")
|
|
91
|
+
|
|
92
|
+
skill_id = str(payload.get("skill_id") or payload.get("skill") or "")
|
|
93
|
+
|
|
94
|
+
model_raw = payload.get("model") if isinstance(payload.get("model"), dict) else {}
|
|
95
|
+
model_provider = str(model_raw.get("provider") or source_app)
|
|
96
|
+
model_id = str(model_raw.get("id") or payload.get("model_id") or source_app)
|
|
97
|
+
|
|
98
|
+
adapter = str(payload.get("adapter") or f"{source_app}_hook")
|
|
99
|
+
|
|
100
|
+
event: dict[str, Any] = {
|
|
101
|
+
"schema_version": SCHEMA_VERSION,
|
|
102
|
+
"event_id": str(uuid.uuid4()),
|
|
103
|
+
"session_id": session_id,
|
|
104
|
+
"correlation_id": correlation_id,
|
|
105
|
+
"timestamp_utc": str(payload.get("timestamp_utc") or _utc_now()),
|
|
106
|
+
**identity_fields(),
|
|
107
|
+
"source_topic": f"external/{source_app}/{event_type}",
|
|
108
|
+
"source": {
|
|
109
|
+
"tier": "external",
|
|
110
|
+
"app": source_app,
|
|
111
|
+
"adapter": adapter,
|
|
112
|
+
},
|
|
113
|
+
"initiator": initiator,
|
|
114
|
+
"actor": actor_from_initiator(initiator),
|
|
115
|
+
"action": {
|
|
116
|
+
"type": action_type,
|
|
117
|
+
"outcome": outcome,
|
|
118
|
+
"reason": reason,
|
|
119
|
+
},
|
|
120
|
+
"agent": {
|
|
121
|
+
"name": source_app,
|
|
122
|
+
"skill_id": skill_id,
|
|
123
|
+
},
|
|
124
|
+
"model": {"id": model_id, "provider": model_provider},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if tool_qualified:
|
|
128
|
+
server = str(tool_block.get("server") or driver_name or source_app)
|
|
129
|
+
command = str(tool_block.get("command") or "").strip()
|
|
130
|
+
redaction = "hash+command" if command else "hash"
|
|
131
|
+
event["tool"] = {
|
|
132
|
+
"name": tool_name or tool_qualified,
|
|
133
|
+
"qualified": tool_qualified,
|
|
134
|
+
"server": server,
|
|
135
|
+
"input_redaction": redaction,
|
|
136
|
+
"input_hash": input_hash,
|
|
137
|
+
"parameters_redacted": not bool(command) and not isinstance(tool_block.get("arguments"), dict),
|
|
138
|
+
}
|
|
139
|
+
if command:
|
|
140
|
+
event["tool"]["command"] = scrub_secrets(command)[:4096]
|
|
141
|
+
if isinstance(tool_block.get("arguments"), dict):
|
|
142
|
+
event["tool"]["arguments"] = scrub_arg_values(tool_block.get("arguments"))
|
|
143
|
+
|
|
144
|
+
# Hook-side command classification labels. In the default hashed-only
|
|
145
|
+
# config these are the ONLY detection signal the command leaves behind.
|
|
146
|
+
traits_raw = tool_block.get("traits")
|
|
147
|
+
if isinstance(traits_raw, list):
|
|
148
|
+
traits = [str(t) for t in traits_raw if t]
|
|
149
|
+
if traits:
|
|
150
|
+
event["tool"]["traits"] = traits
|
|
151
|
+
|
|
152
|
+
# Prefer the hook's MITRE mapping: it saw the plaintext command before
|
|
153
|
+
# hashing, so its content upgrade (e.g. a key-file read → T1552) is
|
|
154
|
+
# strictly better informed than a name-only recompute here.
|
|
155
|
+
adapter_mitre = tool_block.get("mitre")
|
|
156
|
+
if isinstance(adapter_mitre, dict) and adapter_mitre.get("tactic_id"):
|
|
157
|
+
event["tool"]["mitre"] = {
|
|
158
|
+
key: str(adapter_mitre.get(key) or "")
|
|
159
|
+
for key in ("tactic_id", "tactic", "technique_id", "technique")
|
|
160
|
+
}
|
|
161
|
+
else:
|
|
162
|
+
from agentmetry.core.audit.canonical import get_mitre_mapping
|
|
163
|
+
# Pass command/args as evidence so a read of a key/secret upgrades to
|
|
164
|
+
# Credential Access (T1552) instead of generic Collection.
|
|
165
|
+
evidence = command or tool_block.get("arguments")
|
|
166
|
+
mitre = get_mitre_mapping(tool_qualified, evidence)
|
|
167
|
+
if mitre:
|
|
168
|
+
event["tool"]["mitre"] = mitre
|
|
169
|
+
|
|
170
|
+
gated = payload.get("gated_action")
|
|
171
|
+
if isinstance(gated, dict) and gated.get("tool"):
|
|
172
|
+
event["gated_action"] = {
|
|
173
|
+
"tool": str(gated.get("tool") or ""),
|
|
174
|
+
"server": str(gated.get("server") or ""),
|
|
175
|
+
"input_hash": str(gated.get("input_hash") or ""),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# DLP verdict — rule metadata only, never the matched value. The hook scans
|
|
179
|
+
# plaintext before hashing; if we drop the verdict here, a `log`-mode match
|
|
180
|
+
# (the default mode) leaves no trace anywhere and the DLP engine is a no-op.
|
|
181
|
+
dlp = payload.get("dlp")
|
|
182
|
+
if isinstance(dlp, dict) and dlp.get("rule_id"):
|
|
183
|
+
event["dlp"] = {
|
|
184
|
+
"rule_id": str(dlp.get("rule_id") or ""),
|
|
185
|
+
"mode": str(dlp.get("mode") or ""),
|
|
186
|
+
"pattern_type": str(dlp.get("pattern_type") or "regex"),
|
|
187
|
+
"category": str(dlp.get("category") or ""),
|
|
188
|
+
"severity": str(dlp.get("severity") or ""),
|
|
189
|
+
"rule_ids": [str(r) for r in (dlp.get("rule_ids") or []) if r],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
tool_policy = payload.get("tool_policy")
|
|
193
|
+
if isinstance(tool_policy, dict) and tool_policy.get("rule_id"):
|
|
194
|
+
event["tool_policy"] = {
|
|
195
|
+
"rule_id": str(tool_policy.get("rule_id") or ""),
|
|
196
|
+
"action": str(tool_policy.get("action") or ""),
|
|
197
|
+
"mode": str(tool_policy.get("mode") or ""),
|
|
198
|
+
"blocked": bool(tool_policy.get("blocked")),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if payload.get("seq") is not None:
|
|
202
|
+
event["seq"] = payload["seq"]
|
|
203
|
+
|
|
204
|
+
# Policy verdicts annotate; they never rewrite what happened. This event has
|
|
205
|
+
# already executed on the agent's machine, so claiming "denied" here would
|
|
206
|
+
# put a lie in the trail and hide the event from every detection rule that
|
|
207
|
+
# keys on outcome == "success". See core/audit/policy.py.
|
|
208
|
+
if settings.policy_enabled and event["action"]["type"] in ("tool_called", "approval_request"):
|
|
209
|
+
from agentmetry.core.audit.policy import annotate as annotate_policy
|
|
210
|
+
|
|
211
|
+
annotate_policy(event)
|
|
212
|
+
|
|
213
|
+
return event
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Canonical hashing for tool arguments and audit payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def canonical_json(data: Any) -> str:
|
|
11
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), default=str)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sha256_hex(data: str | bytes) -> str:
|
|
15
|
+
if isinstance(data, str):
|
|
16
|
+
data = data.encode("utf-8")
|
|
17
|
+
return hashlib.sha256(data).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def arguments_sha256(arguments: dict[str, Any]) -> str:
|
|
21
|
+
return sha256_hex(canonical_json(arguments))
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Install global Tier B hooks on orchestrator boot (Cursor, etc.)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Hook subprocesses read these from orchestrator .env (Claude also gets them in settings env).
|
|
16
|
+
HOOK_ENV_KEYS = (
|
|
17
|
+
"AGENTMETRY_TOOL_POLICY_MODE",
|
|
18
|
+
"AGENTMETRY_DLP_MODE",
|
|
19
|
+
"AGENTMETRY_URL",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
CURSOR_HOOK_EVENTS = (
|
|
23
|
+
"sessionStart",
|
|
24
|
+
"sessionEnd",
|
|
25
|
+
"stop",
|
|
26
|
+
"beforeShellExecution",
|
|
27
|
+
"afterShellExecution",
|
|
28
|
+
"beforeMCPExecution",
|
|
29
|
+
"afterMCPExecution",
|
|
30
|
+
"preToolUse",
|
|
31
|
+
"postToolUse",
|
|
32
|
+
"postToolUseFailure",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _repo_root() -> Path:
|
|
37
|
+
return Path(__file__).resolve().parents[5]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ingest_script(repo_root: Path | None = None) -> Path:
|
|
41
|
+
root = repo_root or _repo_root()
|
|
42
|
+
return root / "scripts" / "agentmetry_ingest.py"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cursor_hooks_payload(*, python: str, ingest: Path) -> dict[str, Any]:
|
|
46
|
+
hook_cmd = f'"{python}" "{ingest}" cursor hook'
|
|
47
|
+
return {
|
|
48
|
+
"version": 1,
|
|
49
|
+
"hooks": {
|
|
50
|
+
event: [{"command": f"{hook_cmd} {event}"}]
|
|
51
|
+
for event in CURSOR_HOOK_EVENTS
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def install_cursor_global_hooks(
|
|
57
|
+
*,
|
|
58
|
+
repo_root: Path | None = None,
|
|
59
|
+
python: str | None = None,
|
|
60
|
+
remove_project_hooks: bool = True,
|
|
61
|
+
) -> Path | None:
|
|
62
|
+
"""Write ~/.cursor/hooks.json so every Cursor workspace is audited."""
|
|
63
|
+
root = repo_root or _repo_root()
|
|
64
|
+
ingest = _ingest_script(root)
|
|
65
|
+
if not ingest.is_file():
|
|
66
|
+
logger.warning("Cursor hook bootstrap skipped: missing %s", ingest)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
py = python or sys.executable
|
|
70
|
+
hooks_dir = Path.home() / ".cursor"
|
|
71
|
+
hooks_path = hooks_dir / "hooks.json"
|
|
72
|
+
payload = cursor_hooks_payload(python=py, ingest=ingest)
|
|
73
|
+
|
|
74
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
hooks_path.write_text(
|
|
76
|
+
json.dumps(payload, indent=2) + "\n",
|
|
77
|
+
encoding="utf-8",
|
|
78
|
+
)
|
|
79
|
+
logger.info("Installed global Cursor audit hooks -> %s", hooks_path)
|
|
80
|
+
|
|
81
|
+
if remove_project_hooks:
|
|
82
|
+
project_hooks = root / ".cursor" / "hooks.json"
|
|
83
|
+
if project_hooks.is_file():
|
|
84
|
+
project_hooks.unlink()
|
|
85
|
+
logger.info("Removed project Cursor hooks (global covers all workspaces): %s", project_hooks)
|
|
86
|
+
|
|
87
|
+
return hooks_path
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- Claude Code (global ~/.claude/settings.json) -------------------------------
|
|
91
|
+
# Claude's settings.json is USER-OWNED (theme, permissions, MCP servers, env).
|
|
92
|
+
# We MERGE only the hooks we own — never overwrite the file. Uses the nested
|
|
93
|
+
# Claude Code hook schema: event -> [ { "hooks": [ { type, command } ] } ].
|
|
94
|
+
|
|
95
|
+
CLAUDE_HOOK_EVENTS = (
|
|
96
|
+
"SessionStart",
|
|
97
|
+
"PreToolUse",
|
|
98
|
+
"PostToolUse",
|
|
99
|
+
"Notification",
|
|
100
|
+
"Stop",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Qwen Code and Kimi Code — Claude-compatible wire protocol (JSON stdin).
|
|
104
|
+
FAMILY_HOOK_EVENTS = (
|
|
105
|
+
"SessionStart",
|
|
106
|
+
"SessionEnd",
|
|
107
|
+
"PreToolUse",
|
|
108
|
+
"PostToolUse",
|
|
109
|
+
"PostToolUseFailure",
|
|
110
|
+
"SubagentStart",
|
|
111
|
+
"SubagentStop",
|
|
112
|
+
"Stop",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
KIMI_HOOKS_BEGIN = "# agentmetry hooks begin"
|
|
116
|
+
KIMI_HOOKS_END = "# agentmetry hooks end"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _orchestrator_env_path(repo_root: Path | None = None) -> Path:
|
|
120
|
+
root = repo_root or _repo_root()
|
|
121
|
+
return root / "apps" / "orchestrator" / ".env"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def merge_claude_hook_env(
|
|
125
|
+
settings: dict[str, Any], *, repo_root: Path | None = None
|
|
126
|
+
) -> dict[str, Any]:
|
|
127
|
+
"""Mirror hook-relevant keys from orchestrator .env into Claude settings env."""
|
|
128
|
+
from agentmetry.core.diagnostics.env_file import read_env_key
|
|
129
|
+
|
|
130
|
+
env_path = _orchestrator_env_path(repo_root)
|
|
131
|
+
env = settings.get("env")
|
|
132
|
+
if not isinstance(env, dict):
|
|
133
|
+
env = {}
|
|
134
|
+
for key in HOOK_ENV_KEYS:
|
|
135
|
+
val = read_env_key(env_path, key)
|
|
136
|
+
if val:
|
|
137
|
+
env[key] = val
|
|
138
|
+
if env:
|
|
139
|
+
settings["env"] = env
|
|
140
|
+
return settings
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _claude_command(event: str, *, python: str, ingest: Path) -> str:
|
|
144
|
+
return f'"{python}" "{ingest}" claude hook {event}'
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _family_command(source_app: str, event: str, *, python: str, ingest: Path) -> str:
|
|
148
|
+
return f'"{python}" "{ingest}" {source_app} hook {event}'
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _is_our_hook_group(group: Any, *, source_app: str | None = None) -> bool:
|
|
152
|
+
"""True if a hook group is one Agentmetry installed (idempotency marker)."""
|
|
153
|
+
if not isinstance(group, dict):
|
|
154
|
+
return False
|
|
155
|
+
for inner in group.get("hooks", []) or []:
|
|
156
|
+
cmd = str(inner.get("command", ""))
|
|
157
|
+
if "agentmetry_ingest.py" in cmd or "agentaudit_ingest.py" in cmd:
|
|
158
|
+
if source_app is None or f"{source_app} hook" in cmd:
|
|
159
|
+
return True
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _is_our_claude_group(group: Any) -> bool:
|
|
164
|
+
return _is_our_hook_group(group, source_app="claude")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def merge_family_hooks(
|
|
168
|
+
settings: dict[str, Any],
|
|
169
|
+
*,
|
|
170
|
+
events: tuple[str, ...],
|
|
171
|
+
source_app: str,
|
|
172
|
+
python: str,
|
|
173
|
+
ingest: Path,
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
"""Deep-merge Agentmetry hooks into a Claude-family settings dict."""
|
|
176
|
+
hooks = settings.get("hooks")
|
|
177
|
+
if not isinstance(hooks, dict):
|
|
178
|
+
hooks = {}
|
|
179
|
+
settings["hooks"] = hooks
|
|
180
|
+
|
|
181
|
+
for event in events:
|
|
182
|
+
existing = hooks.get(event)
|
|
183
|
+
groups = existing if isinstance(existing, list) else []
|
|
184
|
+
kept = [g for g in groups if not _is_our_hook_group(g, source_app=source_app)]
|
|
185
|
+
kept.append({
|
|
186
|
+
"hooks": [{
|
|
187
|
+
"type": "command",
|
|
188
|
+
"command": _family_command(source_app, event, python=python, ingest=ingest),
|
|
189
|
+
"timeout": 10,
|
|
190
|
+
}],
|
|
191
|
+
})
|
|
192
|
+
hooks[event] = kept
|
|
193
|
+
|
|
194
|
+
return settings
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def merge_claude_hooks(
|
|
198
|
+
settings: dict[str, Any], *, python: str, ingest: Path
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
"""Deep-merge Agentmetry hooks into a Claude settings dict, non-destructively.
|
|
201
|
+
|
|
202
|
+
Preserves every other key (theme, permissions, mcpServers, env) and any
|
|
203
|
+
user hooks; replaces only our own previously-installed entries (idempotent).
|
|
204
|
+
"""
|
|
205
|
+
hooks = settings.get("hooks")
|
|
206
|
+
if not isinstance(hooks, dict):
|
|
207
|
+
hooks = {}
|
|
208
|
+
settings["hooks"] = hooks
|
|
209
|
+
|
|
210
|
+
for event in CLAUDE_HOOK_EVENTS:
|
|
211
|
+
existing = hooks.get(event)
|
|
212
|
+
groups = existing if isinstance(existing, list) else []
|
|
213
|
+
# Drop our prior entries so a re-run doesn't duplicate them.
|
|
214
|
+
kept = [g for g in groups if not _is_our_claude_group(g)]
|
|
215
|
+
kept.append({
|
|
216
|
+
"hooks": [{
|
|
217
|
+
"type": "command",
|
|
218
|
+
"command": _claude_command(event, python=python, ingest=ingest),
|
|
219
|
+
"timeout": 10,
|
|
220
|
+
}],
|
|
221
|
+
})
|
|
222
|
+
hooks[event] = kept
|
|
223
|
+
|
|
224
|
+
return settings
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def install_claude_global_hooks(
|
|
228
|
+
*, repo_root: Path | None = None, python: str | None = None
|
|
229
|
+
) -> Path | None:
|
|
230
|
+
"""Merge Agentmetry hooks into ~/.claude/settings.json for every Claude project."""
|
|
231
|
+
root = repo_root or _repo_root()
|
|
232
|
+
ingest = _ingest_script(root)
|
|
233
|
+
if not ingest.is_file():
|
|
234
|
+
logger.warning("Claude hook bootstrap skipped: missing %s", ingest)
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
py = python or sys.executable
|
|
238
|
+
settings_dir = Path.home() / ".claude"
|
|
239
|
+
settings_path = settings_dir / "settings.json"
|
|
240
|
+
|
|
241
|
+
settings: dict[str, Any] = {}
|
|
242
|
+
if settings_path.is_file():
|
|
243
|
+
try:
|
|
244
|
+
loaded = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
245
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
246
|
+
# Never clobber a config we can't parse.
|
|
247
|
+
logger.warning("Claude settings.json unreadable (%s); skipping install", exc)
|
|
248
|
+
return None
|
|
249
|
+
if not isinstance(loaded, dict):
|
|
250
|
+
logger.warning("Claude settings.json is not an object; skipping install")
|
|
251
|
+
return None
|
|
252
|
+
settings = loaded
|
|
253
|
+
|
|
254
|
+
merge_claude_hooks(settings, python=py, ingest=ingest)
|
|
255
|
+
merge_claude_hook_env(settings, repo_root=root)
|
|
256
|
+
|
|
257
|
+
settings_dir.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
|
259
|
+
logger.info("Installed global Claude audit hooks -> %s", settings_path)
|
|
260
|
+
return settings_path
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _qwen_settings_dir() -> Path:
|
|
264
|
+
home = os.environ.get("QWEN_HOME", "").strip()
|
|
265
|
+
if home:
|
|
266
|
+
return Path(home).expanduser()
|
|
267
|
+
return Path.home() / ".qwen"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def install_qwen_global_hooks(
|
|
271
|
+
*, repo_root: Path | None = None, python: str | None = None
|
|
272
|
+
) -> Path | None:
|
|
273
|
+
"""Merge Agentmetry hooks into ~/.qwen/settings.json for every Qwen Code project."""
|
|
274
|
+
return _install_family_settings_hooks(
|
|
275
|
+
"qwen", _qwen_settings_dir(), repo_root=repo_root, python=python
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _qoder_settings_dir() -> Path:
|
|
280
|
+
return Path.home() / ".qoder"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def install_qoder_global_hooks(
|
|
284
|
+
*, repo_root: Path | None = None, python: str | None = None
|
|
285
|
+
) -> Path | None:
|
|
286
|
+
"""Merge Agentmetry hooks into ~/.qoder/settings.json (Qoder / 通义灵码)."""
|
|
287
|
+
return _install_family_settings_hooks(
|
|
288
|
+
"qoder", _qoder_settings_dir(), repo_root=repo_root, python=python
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _codebuddy_settings_dir() -> Path:
|
|
293
|
+
return Path.home() / ".codebuddy"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def install_codebuddy_global_hooks(
|
|
297
|
+
*, repo_root: Path | None = None, python: str | None = None
|
|
298
|
+
) -> Path | None:
|
|
299
|
+
"""Merge Agentmetry hooks into ~/.codebuddy/settings.json (Tencent CodeBuddy)."""
|
|
300
|
+
return _install_family_settings_hooks(
|
|
301
|
+
"codebuddy", _codebuddy_settings_dir(), repo_root=repo_root, python=python
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _install_family_settings_hooks(
|
|
306
|
+
source_app: str,
|
|
307
|
+
settings_dir: Path,
|
|
308
|
+
*,
|
|
309
|
+
repo_root: Path | None = None,
|
|
310
|
+
python: str | None = None,
|
|
311
|
+
) -> Path | None:
|
|
312
|
+
root = repo_root or _repo_root()
|
|
313
|
+
ingest = _ingest_script(root)
|
|
314
|
+
if not ingest.is_file():
|
|
315
|
+
logger.warning("%s hook bootstrap skipped: missing %s", source_app, ingest)
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
py = python or sys.executable
|
|
319
|
+
settings_path = settings_dir / "settings.json"
|
|
320
|
+
|
|
321
|
+
settings: dict[str, Any] = {}
|
|
322
|
+
if settings_path.is_file():
|
|
323
|
+
try:
|
|
324
|
+
loaded = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
325
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
326
|
+
logger.warning("%s settings.json unreadable (%s); skipping install", source_app, exc)
|
|
327
|
+
return None
|
|
328
|
+
if not isinstance(loaded, dict):
|
|
329
|
+
logger.warning("%s settings.json is not an object; skipping install", source_app)
|
|
330
|
+
return None
|
|
331
|
+
settings = loaded
|
|
332
|
+
|
|
333
|
+
merge_family_hooks(
|
|
334
|
+
settings,
|
|
335
|
+
events=FAMILY_HOOK_EVENTS,
|
|
336
|
+
source_app=source_app,
|
|
337
|
+
python=py,
|
|
338
|
+
ingest=ingest,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
settings_dir.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
|
343
|
+
logger.info("Installed global %s audit hooks -> %s", source_app, settings_path)
|
|
344
|
+
return settings_path
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _kimi_config_dir() -> Path:
|
|
348
|
+
home = os.environ.get("KIMI_CODE_HOME", "").strip()
|
|
349
|
+
if home:
|
|
350
|
+
return Path(home).expanduser()
|
|
351
|
+
return Path.home() / ".kimi-code"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def kimi_hooks_toml_block(*, python: str, ingest: Path) -> str:
|
|
355
|
+
lines = [
|
|
356
|
+
KIMI_HOOKS_BEGIN,
|
|
357
|
+
"# Managed by Agentmetry — re-run scripts/install_kimi_hooks.ps1 to update",
|
|
358
|
+
]
|
|
359
|
+
for event in FAMILY_HOOK_EVENTS:
|
|
360
|
+
cmd = _family_command("kimi", event, python=python, ingest=ingest)
|
|
361
|
+
lines.extend([
|
|
362
|
+
"[[hooks]]",
|
|
363
|
+
f'event = "{event}"',
|
|
364
|
+
'matcher = ".*"',
|
|
365
|
+
f'command = "{cmd}"',
|
|
366
|
+
"timeout = 10",
|
|
367
|
+
"",
|
|
368
|
+
])
|
|
369
|
+
lines.append(KIMI_HOOKS_END)
|
|
370
|
+
return "\n".join(lines) + "\n"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def merge_kimi_hooks_toml(text: str, block: str) -> str:
|
|
374
|
+
pattern = re.compile(
|
|
375
|
+
rf"{re.escape(KIMI_HOOKS_BEGIN)}.*?{re.escape(KIMI_HOOKS_END)}\n?",
|
|
376
|
+
re.DOTALL,
|
|
377
|
+
)
|
|
378
|
+
stripped = pattern.sub("", text)
|
|
379
|
+
if stripped and not stripped.endswith("\n"):
|
|
380
|
+
stripped += "\n"
|
|
381
|
+
if stripped.strip():
|
|
382
|
+
return stripped + "\n" + block
|
|
383
|
+
return block
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def install_kimi_global_hooks(
|
|
387
|
+
*, repo_root: Path | None = None, python: str | None = None
|
|
388
|
+
) -> Path | None:
|
|
389
|
+
"""Append Agentmetry hooks to ~/.kimi-code/config.toml (TOML [[hooks]] tables)."""
|
|
390
|
+
root = repo_root or _repo_root()
|
|
391
|
+
ingest = _ingest_script(root)
|
|
392
|
+
if not ingest.is_file():
|
|
393
|
+
logger.warning("Kimi hook bootstrap skipped: missing %s", ingest)
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
py = python or sys.executable
|
|
397
|
+
config_dir = _kimi_config_dir()
|
|
398
|
+
config_path = config_dir / "config.toml"
|
|
399
|
+
block = kimi_hooks_toml_block(python=py, ingest=ingest)
|
|
400
|
+
|
|
401
|
+
existing = ""
|
|
402
|
+
if config_path.is_file():
|
|
403
|
+
existing = config_path.read_text(encoding="utf-8")
|
|
404
|
+
|
|
405
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
406
|
+
config_path.write_text(merge_kimi_hooks_toml(existing, block), encoding="utf-8")
|
|
407
|
+
logger.info("Installed global Kimi audit hooks -> %s", config_path)
|
|
408
|
+
return config_path
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def bootstrap_tier_b_hooks(*, repo_root: Path | None = None) -> dict[str, str | None]:
|
|
412
|
+
"""Best-effort hook install for launch-and-forget Tier B logging."""
|
|
413
|
+
results: dict[str, str | None] = {"cursor": None, "claude": None}
|
|
414
|
+
try:
|
|
415
|
+
path = install_cursor_global_hooks(repo_root=repo_root)
|
|
416
|
+
results["cursor"] = str(path) if path else None
|
|
417
|
+
except OSError as exc:
|
|
418
|
+
logger.warning("Cursor hook bootstrap failed: %s", exc)
|
|
419
|
+
try:
|
|
420
|
+
cpath = install_claude_global_hooks(repo_root=repo_root)
|
|
421
|
+
results["claude"] = str(cpath) if cpath else None
|
|
422
|
+
except OSError as exc:
|
|
423
|
+
logger.warning("Claude hook bootstrap failed: %s", exc)
|
|
424
|
+
return results
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == "__main__":
|
|
428
|
+
target = sys.argv[1] if len(sys.argv) > 1 else "bootstrap"
|
|
429
|
+
installers = {
|
|
430
|
+
"bootstrap": bootstrap_tier_b_hooks,
|
|
431
|
+
"qwen": install_qwen_global_hooks,
|
|
432
|
+
"kimi": install_kimi_global_hooks,
|
|
433
|
+
"qoder": install_qoder_global_hooks,
|
|
434
|
+
"codebuddy": install_codebuddy_global_hooks,
|
|
435
|
+
}
|
|
436
|
+
fn = installers.get(target, bootstrap_tier_b_hooks)
|
|
437
|
+
if target in ("qwen", "kimi", "qoder", "codebuddy"):
|
|
438
|
+
path = fn()
|
|
439
|
+
if path:
|
|
440
|
+
print(f"Installed global {target} hooks -> {path}")
|
|
441
|
+
sys.exit(0)
|
|
442
|
+
print(f"{target} hook install skipped (ingest script missing)", file=sys.stderr)
|
|
443
|
+
sys.exit(1)
|
|
444
|
+
paths = bootstrap_tier_b_hooks()
|
|
445
|
+
installed = {k: v for k, v in paths.items() if v}
|
|
446
|
+
if installed:
|
|
447
|
+
for app, path in installed.items():
|
|
448
|
+
print(f"Installed global {app} hooks -> {path}")
|
|
449
|
+
sys.exit(0)
|
|
450
|
+
print("Hook install skipped (ingest script missing)", file=sys.stderr)
|
|
451
|
+
sys.exit(1)
|