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,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)