talkops-opscloud 0.3.0b1__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 (309) hide show
  1. opscloud/__init__.py +5 -0
  2. opscloud/__main__.py +6 -0
  3. opscloud/_constants.py +53 -0
  4. opscloud/_debug.py +145 -0
  5. opscloud/_debug_buffer.py +250 -0
  6. opscloud/_textual_patches.py +367 -0
  7. opscloud/_version.py +15 -0
  8. opscloud/agent/__init__.py +27 -0
  9. opscloud/agent/config.py +168 -0
  10. opscloud/agent/factory.py +1103 -0
  11. opscloud/approval_mode.py +44 -0
  12. opscloud/backend/__init__.py +49 -0
  13. opscloud/backend/composite.py +89 -0
  14. opscloud/backend/local.py +183 -0
  15. opscloud/backend/registry.py +79 -0
  16. opscloud/backend/sandbox_config.py +107 -0
  17. opscloud/backend/sandbox_factory.py +432 -0
  18. opscloud/built_in_skills/remember/SKILL.md +124 -0
  19. opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
  20. opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
  21. opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
  22. opscloud/cli/__init__.py +1 -0
  23. opscloud/cli/commands/__init__.py +15 -0
  24. opscloud/cli/commands/auth.py +45 -0
  25. opscloud/cli/commands/doctor.py +100 -0
  26. opscloud/cli/commands/mcp.py +137 -0
  27. opscloud/cli/commands/threads.py +51 -0
  28. opscloud/cli/interactive.py +154 -0
  29. opscloud/cli/main.py +903 -0
  30. opscloud/cli/non_interactive.py +425 -0
  31. opscloud/cli/server_manager.py +58 -0
  32. opscloud/commands/__init__.py +19 -0
  33. opscloud/commands/_base.py +110 -0
  34. opscloud/commands/_guards.py +52 -0
  35. opscloud/commands/_router.py +146 -0
  36. opscloud/commands/_types.py +36 -0
  37. opscloud/commands/core/__init__.py +47 -0
  38. opscloud/commands/core/auth.py +94 -0
  39. opscloud/commands/core/bug.py +46 -0
  40. opscloud/commands/core/clear.py +106 -0
  41. opscloud/commands/core/cloud.py +185 -0
  42. opscloud/commands/core/compact.py +128 -0
  43. opscloud/commands/core/config_cmd.py +120 -0
  44. opscloud/commands/core/context.py +220 -0
  45. opscloud/commands/core/cost.py +44 -0
  46. opscloud/commands/core/doctor.py +535 -0
  47. opscloud/commands/core/effort.py +108 -0
  48. opscloud/commands/core/exit_cmd.py +41 -0
  49. opscloud/commands/core/fast.py +148 -0
  50. opscloud/commands/core/help_cmd.py +94 -0
  51. opscloud/commands/core/mcp.py +134 -0
  52. opscloud/commands/core/model.py +109 -0
  53. opscloud/commands/core/permissions.py +196 -0
  54. opscloud/commands/core/plugins.py +92 -0
  55. opscloud/commands/core/pool.py +108 -0
  56. opscloud/commands/core/resume.py +97 -0
  57. opscloud/commands/core/skills.py +178 -0
  58. opscloud/commands/power/__init__.py +51 -0
  59. opscloud/commands/power/agents.py +98 -0
  60. opscloud/commands/power/btw.py +85 -0
  61. opscloud/commands/power/copy.py +97 -0
  62. opscloud/commands/power/goal.py +548 -0
  63. opscloud/commands/power/loop.py +295 -0
  64. opscloud/commands/power/memory.py +287 -0
  65. opscloud/commands/power/review.py +152 -0
  66. opscloud/commands/power/rubric.py +287 -0
  67. opscloud/commands/power/runtime.py +321 -0
  68. opscloud/commands/power/skill_creator.py +70 -0
  69. opscloud/commands/power/skill_invoke.py +165 -0
  70. opscloud/commands/power/tasks.py +209 -0
  71. opscloud/commands/power/trace.py +117 -0
  72. opscloud/commands/power/ui_toggles.py +135 -0
  73. opscloud/commands/power/version.py +52 -0
  74. opscloud/commands/registry.py +176 -0
  75. opscloud/config/__init__.py +114 -0
  76. opscloud/config/adapters/__init__.py +5 -0
  77. opscloud/config/adapters/postgres.py +776 -0
  78. opscloud/config/adapters/sqlite.py +769 -0
  79. opscloud/config/aws.py +248 -0
  80. opscloud/config/cloud_profiles.py +437 -0
  81. opscloud/config/env_vars.py +81 -0
  82. opscloud/config/langsmith.py +319 -0
  83. opscloud/config/manifest.py +817 -0
  84. opscloud/config/metadata.py +205 -0
  85. opscloud/config/paths.py +604 -0
  86. opscloud/config/plugins.py +130 -0
  87. opscloud/config/settings.py +834 -0
  88. opscloud/config/store.py +453 -0
  89. opscloud/config/store_factory.py +97 -0
  90. opscloud/config/toml_config.py +562 -0
  91. opscloud/editor.py +173 -0
  92. opscloud/exceptions.py +65 -0
  93. opscloud/file_ops.py +217 -0
  94. opscloud/hooks/__init__.py +139 -0
  95. opscloud/hooks/env.py +44 -0
  96. opscloud/hooks/interrupt.py +121 -0
  97. opscloud/hooks/models/__init__.py +109 -0
  98. opscloud/hooks/models/adapters.py +19 -0
  99. opscloud/hooks/models/config.py +79 -0
  100. opscloud/hooks/models/domain.py +434 -0
  101. opscloud/hooks/models/transport.py +37 -0
  102. opscloud/hooks/tools.py +231 -0
  103. opscloud/input.py +48 -0
  104. opscloud/integrations/__init__.py +80 -0
  105. opscloud/integrations/base.py +153 -0
  106. opscloud/integrations/content_filter.py +119 -0
  107. opscloud/integrations/event_bus.py +480 -0
  108. opscloud/integrations/hooks.py +238 -0
  109. opscloud/integrations/identity.py +181 -0
  110. opscloud/integrations/notifications.py +134 -0
  111. opscloud/integrations/stream_bridge.py +397 -0
  112. opscloud/integrations/thread_store.py +81 -0
  113. opscloud/json_types.py +20 -0
  114. opscloud/mcp/__init__.py +57 -0
  115. opscloud/mcp/config.py +203 -0
  116. opscloud/mcp/discovery.py +229 -0
  117. opscloud/mcp/mcp_info.py +109 -0
  118. opscloud/mcp/middleware.py +102 -0
  119. opscloud/mcp/preload.py +505 -0
  120. opscloud/mcp/raw_config.py +149 -0
  121. opscloud/mcp/semantic_profiler.py +305 -0
  122. opscloud/mcp/session_manager.py +727 -0
  123. opscloud/mcp/trust.py +105 -0
  124. opscloud/memory/__init__.py +27 -0
  125. opscloud/memory/branch.py +61 -0
  126. opscloud/memory/guard.py +19 -0
  127. opscloud/memory/onboarding.py +120 -0
  128. opscloud/memory/registry.py +134 -0
  129. opscloud/memory/store.py +223 -0
  130. opscloud/middleware/__init__.py +106 -0
  131. opscloud/middleware/_repository_bounds.py +459 -0
  132. opscloud/middleware/ask_user.py +152 -0
  133. opscloud/middleware/auto_mode.py +60 -0
  134. opscloud/middleware/auto_mode_hitl.py +2434 -0
  135. opscloud/middleware/compaction.py +566 -0
  136. opscloud/middleware/configurable_model.py +233 -0
  137. opscloud/middleware/cost_tracking.py +1021 -0
  138. opscloud/middleware/glm_stall_recovery.py +109 -0
  139. opscloud/middleware/goal_criteria.py +1234 -0
  140. opscloud/middleware/goal_state_notice.py +712 -0
  141. opscloud/middleware/goal_tools.py +407 -0
  142. opscloud/middleware/headless_mcp_guard.py +88 -0
  143. opscloud/middleware/jev_model_router.py +1018 -0
  144. opscloud/middleware/local_context.py +460 -0
  145. opscloud/middleware/mcp_context.py +151 -0
  146. opscloud/middleware/mcp_middleware.py +146 -0
  147. opscloud/middleware/memory_guard.py +393 -0
  148. opscloud/middleware/model_retry.py +205 -0
  149. opscloud/middleware/registry.py +137 -0
  150. opscloud/middleware/reliable_rubric.py +709 -0
  151. opscloud/middleware/resume_state.py +91 -0
  152. opscloud/middleware/server_hooks.py +1189 -0
  153. opscloud/middleware/shell_allow_list.py +90 -0
  154. opscloud/middleware/skills.py +382 -0
  155. opscloud/middleware/subagent_artifacts.py +84 -0
  156. opscloud/middleware/subagent_telemetry.py +261 -0
  157. opscloud/middleware/subagents.py +243 -0
  158. opscloud/middleware/tool_filter.py +384 -0
  159. opscloud/middleware/unified_system_message.py +62 -0
  160. opscloud/model/__init__.py +37 -0
  161. opscloud/model/config.py +2992 -0
  162. opscloud/model/factory.py +477 -0
  163. opscloud/model/pool.py +459 -0
  164. opscloud/model/reasoning.py +282 -0
  165. opscloud/offload.py +173 -0
  166. opscloud/output.py +64 -0
  167. opscloud/plugins/__init__.py +189 -0
  168. opscloud/plugins/_json.py +22 -0
  169. opscloud/plugins/adapters/__init__.py +13 -0
  170. opscloud/plugins/adapters/agents.py +112 -0
  171. opscloud/plugins/adapters/commands.py +190 -0
  172. opscloud/plugins/adapters/mcp.py +363 -0
  173. opscloud/plugins/adapters/skills.py +132 -0
  174. opscloud/plugins/commands_cli.py +541 -0
  175. opscloud/plugins/discovery.py +512 -0
  176. opscloud/plugins/manifest.py +335 -0
  177. opscloud/plugins/marketplace.py +893 -0
  178. opscloud/plugins/models.py +334 -0
  179. opscloud/plugins/project_plugins.py +317 -0
  180. opscloud/plugins/protocol.py +31 -0
  181. opscloud/plugins/store.py +905 -0
  182. opscloud/plugins/substitution.py +122 -0
  183. opscloud/project_utils.py +145 -0
  184. opscloud/prompts/__init__.py +37 -0
  185. opscloud/prompts/system.py +483 -0
  186. opscloud/prompts/templates/system_prompt.md +166 -0
  187. opscloud/prompts/types.py +54 -0
  188. opscloud/rubrics/__init__.py +42 -0
  189. opscloud/rubrics/evaluator.py +404 -0
  190. opscloud/rubrics/evidence_extractor.py +229 -0
  191. opscloud/rubrics/generator.py +239 -0
  192. opscloud/rubrics/jev_compiler.py +175 -0
  193. opscloud/rubrics/jev_grader.py +297 -0
  194. opscloud/rubrics/middleware.py +8 -0
  195. opscloud/schema/__init__.py +107 -0
  196. opscloud/schema/interrupts.py +981 -0
  197. opscloud/security/__init__.py +91 -0
  198. opscloud/security/approval_mode.py +528 -0
  199. opscloud/security/approval_mode_source.py +230 -0
  200. opscloud/security/cli_ast_evaluator.py +552 -0
  201. opscloud/security/jev_classifier.py +651 -0
  202. opscloud/security/shell_safety.py +340 -0
  203. opscloud/security/unicode_security.py +439 -0
  204. opscloud/security/url_validation.py +160 -0
  205. opscloud/server/__init__.py +13 -0
  206. opscloud/server/_server_config.py +158 -0
  207. opscloud/server/server.py +242 -0
  208. opscloud/server/server_graph.py +93 -0
  209. opscloud/skills/__init__.py +91 -0
  210. opscloud/skills/commands.py +1099 -0
  211. opscloud/skills/invocation.py +200 -0
  212. opscloud/skills/load.py +388 -0
  213. opscloud/skills/loader.py +23 -0
  214. opscloud/skills/merge.py +65 -0
  215. opscloud/skills/registry.py +263 -0
  216. opscloud/skills/sources.py +174 -0
  217. opscloud/skills/trust.py +417 -0
  218. opscloud/state/__init__.py +142 -0
  219. opscloud/state/base.py +154 -0
  220. opscloud/state/cloud_context.py +262 -0
  221. opscloud/state/goal_channels.py +124 -0
  222. opscloud/state/goal_state_limits.py +175 -0
  223. opscloud/state/resume_state.py +58 -0
  224. opscloud/state/service_context.py +281 -0
  225. opscloud/state/session.py +957 -0
  226. opscloud/state/state_migration.py +86 -0
  227. opscloud/subagents/__init__.py +30 -0
  228. opscloud/subagents/loader.py +331 -0
  229. opscloud/subagents/subagents_parser.py +117 -0
  230. opscloud/subagents/types.py +25 -0
  231. opscloud/tools/__init__.py +36 -0
  232. opscloud/tools/catalog.py +37 -0
  233. opscloud/tools/display.py +24 -0
  234. opscloud/tools/fetch_url.py +176 -0
  235. opscloud/tools/goal_tools.py +423 -0
  236. opscloud/tools/registry.py +105 -0
  237. opscloud/tools/thread.py +32 -0
  238. opscloud/tools/web_search.py +215 -0
  239. opscloud/ui/__init__.py +119 -0
  240. opscloud/ui/app.py +5400 -0
  241. opscloud/ui/app.tcss +479 -0
  242. opscloud/ui/clipboard.py +147 -0
  243. opscloud/ui/command_registry.py +482 -0
  244. opscloud/ui/message_store.py +152 -0
  245. opscloud/ui/permission_store.py +360 -0
  246. opscloud/ui/preferences.py +113 -0
  247. opscloud/ui/prompt_manager.py +96 -0
  248. opscloud/ui/remote_client.py +505 -0
  249. opscloud/ui/textual_adapter.py +1269 -0
  250. opscloud/ui/theme.py +728 -0
  251. opscloud/ui/ui_help.py +415 -0
  252. opscloud/ui/widgets/__init__.py +383 -0
  253. opscloud/ui/widgets/_ask_user_types.py +81 -0
  254. opscloud/ui/widgets/_copy_spans.py +52 -0
  255. opscloud/ui/widgets/_inline_prompt.py +290 -0
  256. opscloud/ui/widgets/_js_eval_display.py +139 -0
  257. opscloud/ui/widgets/_links.py +175 -0
  258. opscloud/ui/widgets/_tool_stream.py +308 -0
  259. opscloud/ui/widgets/agent_selector.py +297 -0
  260. opscloud/ui/widgets/approval.py +741 -0
  261. opscloud/ui/widgets/ask_user.py +539 -0
  262. opscloud/ui/widgets/auth.py +695 -0
  263. opscloud/ui/widgets/auth_manager.py +214 -0
  264. opscloud/ui/widgets/auto_mode_notice.py +146 -0
  265. opscloud/ui/widgets/autocomplete.py +709 -0
  266. opscloud/ui/widgets/chat_input.py +805 -0
  267. opscloud/ui/widgets/cloud_selector.py +528 -0
  268. opscloud/ui/widgets/config_manager.py +652 -0
  269. opscloud/ui/widgets/debug_console.py +1269 -0
  270. opscloud/ui/widgets/devops_renderers.py +184 -0
  271. opscloud/ui/widgets/diff.py +237 -0
  272. opscloud/ui/widgets/effort_selector.py +166 -0
  273. opscloud/ui/widgets/goal_review.py +612 -0
  274. opscloud/ui/widgets/goal_status.py +57 -0
  275. opscloud/ui/widgets/infra_panel.py +141 -0
  276. opscloud/ui/widgets/install_confirm.py +114 -0
  277. opscloud/ui/widgets/loading.py +170 -0
  278. opscloud/ui/widgets/mcp_viewer.py +1204 -0
  279. opscloud/ui/widgets/messages.py +4461 -0
  280. opscloud/ui/widgets/model_selector.py +863 -0
  281. opscloud/ui/widgets/notification_center.py +108 -0
  282. opscloud/ui/widgets/notification_settings.py +119 -0
  283. opscloud/ui/widgets/operation_card.py +106 -0
  284. opscloud/ui/widgets/permissions_manager.py +667 -0
  285. opscloud/ui/widgets/plugin_manager.py +1212 -0
  286. opscloud/ui/widgets/pool_selector.py +376 -0
  287. opscloud/ui/widgets/preamble.py +53 -0
  288. opscloud/ui/widgets/skills_viewer.py +432 -0
  289. opscloud/ui/widgets/status.py +1037 -0
  290. opscloud/ui/widgets/subagent_panel.py +927 -0
  291. opscloud/ui/widgets/theme_selector.py +232 -0
  292. opscloud/ui/widgets/thread_selector.py +358 -0
  293. opscloud/ui/widgets/toast.py +31 -0
  294. opscloud/ui/widgets/tool_display.py +325 -0
  295. opscloud/ui/widgets/tool_renderers.py +234 -0
  296. opscloud/ui/widgets/tool_widgets.py +278 -0
  297. opscloud/ui/widgets/welcome.py +421 -0
  298. opscloud/ui/widgets/welcome_popup.py +102 -0
  299. opscloud/utils/__init__.py +24 -0
  300. opscloud/utils/cost_estimation.py +169 -0
  301. opscloud/utils/git.py +390 -0
  302. opscloud/utils/logger.py +571 -0
  303. opscloud/utils/session_stats.py +115 -0
  304. opscloud/utils/startup_error.py +37 -0
  305. talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
  306. talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
  307. talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
  308. talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
  309. talkops_opscloud-0.3.0b1.dist-info/licenses/LICENSE +175 -0
@@ -0,0 +1,238 @@
1
+ """Event hook dispatch for opscloud.
2
+
3
+ Hooks allow external tools to react to agent events. Configured in
4
+ ``~/.opscloud/hooks.json``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ from opscloud.utils.logger import get_logger
12
+ import os
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ logger = get_logger(__name__)
19
+
20
+ HOOK_SUBPROCESS_TIMEOUT = 5
21
+ """Seconds before a hook subprocess is killed."""
22
+
23
+ HOOK_TOOL_OUTPUT_LIMIT = 2000
24
+ """Max chars kept in tool_output payload fields."""
25
+
26
+ # Standard events
27
+ STANDARD_EVENTS = frozenset(
28
+ {
29
+ "session.start",
30
+ "session.end",
31
+ "user.prompt",
32
+ "tool.use",
33
+ "tool.result",
34
+ "tool.error",
35
+ "task.complete",
36
+ "user.name.set",
37
+ "context.compact",
38
+ "permission.request",
39
+ }
40
+ )
41
+
42
+ # Cloud / DevOps events
43
+ DEVOPS_EVENTS = frozenset(
44
+ {
45
+ "terraform.plan",
46
+ "terraform.apply",
47
+ "helm.install",
48
+ "kubectl.apply",
49
+ "deploy.start",
50
+ "deploy.complete",
51
+ "aws.resource.create",
52
+ "aws.resource.update",
53
+ "aws.resource.delete",
54
+ }
55
+ )
56
+
57
+ ALL_KNOWN_EVENTS = STANDARD_EVENTS | DEVOPS_EVENTS
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class HookConfig:
62
+ """A single hook definition from ``hooks.json``."""
63
+
64
+ command: list[str]
65
+ """Subprocess command to execute."""
66
+
67
+ events: frozenset[str]
68
+ """Events this hook listens to. Empty -> all events."""
69
+
70
+ def matches(self, event: str) -> bool:
71
+ """Return True if this hook should fire for event."""
72
+ return len(self.events) == 0 or event in self.events
73
+
74
+
75
+ _hooks: list[HookConfig] = []
76
+ _pending_tasks: set[asyncio.Task[None]] = set()
77
+ _loaded = False
78
+
79
+
80
+ def _default_hooks_path() -> Path:
81
+ from opscloud.config.paths import HOOKS_PATH
82
+ return HOOKS_PATH
83
+
84
+
85
+ def load_hooks(path: Path | None = None) -> list[HookConfig]:
86
+ """Parse hooks.json and populate the module-level hook list."""
87
+ global _hooks, _loaded
88
+
89
+ parsed: list[HookConfig] = []
90
+
91
+ def _parse_hooks_data(data: dict[str, Any], source_name: str) -> None:
92
+ for entry in data.get("hooks", []):
93
+ cmd = entry.get("command")
94
+ if not isinstance(cmd, list) or not cmd:
95
+ logger.warning("Skipping hook with invalid command from %s: %r", source_name, entry)
96
+ continue
97
+ events_raw = entry.get("events", [])
98
+ if not isinstance(events_raw, list):
99
+ events_raw = []
100
+ parsed.append(
101
+ HookConfig(
102
+ command=cmd,
103
+ events=frozenset(events_raw),
104
+ )
105
+ )
106
+
107
+ hooks_file = path or _default_hooks_path()
108
+ if hooks_file.is_file():
109
+ try:
110
+ data = json.loads(hooks_file.read_text(encoding="utf-8"))
111
+ if isinstance(data, dict):
112
+ _parse_hooks_data(data, str(hooks_file))
113
+ except (json.JSONDecodeError, OSError) as exc:
114
+ logger.warning("Failed to parse hooks config %s: %s", hooks_file, exc)
115
+
116
+ # Discover and merge plugin hooks
117
+ try:
118
+ from opscloud.plugins.discovery import discover_plugins
119
+
120
+ plugins = discover_plugins().plugins
121
+ for plugin in plugins:
122
+ plugin_hooks_file = plugin.root / "hooks" / "hooks.json"
123
+ if plugin_hooks_file.is_file():
124
+ try:
125
+ p_data = json.loads(plugin_hooks_file.read_text(encoding="utf-8"))
126
+ if isinstance(p_data, dict):
127
+ _parse_hooks_data(p_data, f"plugin:{plugin.plugin_id}")
128
+ except (json.JSONDecodeError, OSError) as exc:
129
+ logger.warning("Failed to parse plugin hooks config %s: %s", plugin_hooks_file, exc)
130
+ except Exception as exc:
131
+ logger.debug("Plugin hook discovery skipped: %s", exc)
132
+
133
+ _hooks = parsed
134
+ _loaded = True
135
+ return _hooks
136
+
137
+
138
+ def _ensure_loaded() -> list[HookConfig]:
139
+ if not _loaded:
140
+ load_hooks()
141
+ return _hooks
142
+
143
+
144
+ def _sanitise_payload(payload: dict[str, Any]) -> dict[str, Any]:
145
+ out = dict(payload)
146
+ if "tool_output" in out and isinstance(out["tool_output"], str):
147
+ if len(out["tool_output"]) > HOOK_TOOL_OUTPUT_LIMIT:
148
+ out["tool_output"] = out["tool_output"][:HOOK_TOOL_OUTPUT_LIMIT] + "…[truncated]"
149
+ return out
150
+
151
+
152
+ async def _run_hook(hook: HookConfig, event: str, payload: dict[str, Any]) -> None:
153
+ from opscloud.hooks.env import sanitize_hook_environ
154
+
155
+ env = {**sanitize_hook_environ(), "OPSCLOUD_HOOK_EVENT": event}
156
+ json_payload = json.dumps(_sanitise_payload(payload))
157
+
158
+ try:
159
+ proc = await asyncio.create_subprocess_exec(
160
+ *hook.command,
161
+ stdin=subprocess.PIPE,
162
+ stdout=subprocess.DEVNULL,
163
+ stderr=subprocess.PIPE,
164
+ env=env,
165
+ )
166
+ _, stderr = await asyncio.wait_for(
167
+ proc.communicate(input=json_payload.encode()),
168
+ timeout=HOOK_SUBPROCESS_TIMEOUT,
169
+ )
170
+ if proc.returncode != 0:
171
+ logger.debug(
172
+ "Hook %s exited %d for event %s: %s",
173
+ hook.command[0],
174
+ proc.returncode,
175
+ event,
176
+ stderr.decode(errors="replace")[:200],
177
+ )
178
+ except asyncio.TimeoutError:
179
+ logger.debug("Hook %s timed out after %ds for event %s", hook.command[0], HOOK_SUBPROCESS_TIMEOUT, event)
180
+ with suppress_os_errors():
181
+ proc.kill() # type: ignore[possibly-undefined]
182
+ except OSError as exc:
183
+ logger.debug("Hook %s failed to start: %s", hook.command[0], exc)
184
+
185
+
186
+ class suppress_os_errors:
187
+ def __enter__(self) -> None:
188
+ pass
189
+
190
+ def __exit__(self, exc_type: type | None, *_: object) -> bool:
191
+ return exc_type is not None and issubclass(exc_type, OSError)
192
+
193
+
194
+ async def dispatch_hook(event: str, payload: dict[str, Any]) -> None:
195
+ """Dispatch event to all matching hooks and await completion."""
196
+ hooks = _ensure_loaded()
197
+ tasks = [_run_hook(h, event, payload) for h in hooks if h.matches(event)]
198
+ if tasks:
199
+ await asyncio.gather(*tasks, return_exceptions=True)
200
+
201
+
202
+ def dispatch_hook_fire_and_forget(event: str, payload: dict[str, Any]) -> None:
203
+ """Dispatch event without waiting."""
204
+ hooks = _ensure_loaded()
205
+ matching = [h for h in hooks if h.matches(event)]
206
+ if not matching:
207
+ return
208
+
209
+ try:
210
+ loop = asyncio.get_running_loop()
211
+ except RuntimeError:
212
+ return
213
+
214
+ for hook in matching:
215
+ task = loop.create_task(_run_hook(hook, event, payload))
216
+ _pending_tasks.add(task)
217
+ task.add_done_callback(_pending_tasks.discard)
218
+
219
+
220
+ async def drain_pending_hooks() -> None:
221
+ """Wait for all fire-and-forget hooks to complete."""
222
+ if _pending_tasks:
223
+ await asyncio.gather(*_pending_tasks, return_exceptions=True)
224
+ _pending_tasks.clear()
225
+
226
+
227
+ __all__ = [
228
+ "ALL_KNOWN_EVENTS",
229
+ "DEVOPS_EVENTS",
230
+ "HOOK_SUBPROCESS_TIMEOUT",
231
+ "HOOK_TOOL_OUTPUT_LIMIT",
232
+ "HookConfig",
233
+ "STANDARD_EVENTS",
234
+ "dispatch_hook",
235
+ "dispatch_hook_fire_and_forget",
236
+ "drain_pending_hooks",
237
+ "load_hooks",
238
+ ]
@@ -0,0 +1,181 @@
1
+ """User identity resolution and cloud RBAC authorization mapping.
2
+
3
+ Resolves terminal operators and external callers to internal identities,
4
+ AWS IAM/cloud authorization roles, and scoped resource boundaries.
5
+
6
+ Identity mappings are loaded from:
7
+
8
+ 1. ``IDENTITY_MAPPING_FILE`` env var — path to a JSON file.
9
+ 2. ``IDENTITY_MAPPING`` env var — inline JSON string.
10
+ 3. Default: unmapped callers receive the ``"viewer"`` role with read-only access.
11
+
12
+ Example mapping file::
13
+
14
+ {
15
+ "terminal:local": {
16
+ "internal_id": "alice@company.com",
17
+ "display_name": "Alice",
18
+ "rbac_role": "admin",
19
+ "accounts": ["123456789012"],
20
+ "regions": ["us-east-1", "us-west-2"],
21
+ "services": ["*"],
22
+ "operations": ["read", "deploy", "delete", "scale"]
23
+ },
24
+ "event_bus:ci-pipeline": {
25
+ "internal_id": "ci-runner@company.com",
26
+ "display_name": "CI Runner",
27
+ "rbac_role": "operator",
28
+ "accounts": ["123456789012"],
29
+ "regions": ["us-east-1"],
30
+ "services": ["ec2", "s3"],
31
+ "operations": ["read", "deploy"]
32
+ }
33
+ }
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from dataclasses import dataclass, field
39
+ import getpass
40
+ import json
41
+ import os
42
+ from typing import Any
43
+
44
+ from opscloud.utils.logger import AgentLogger
45
+
46
+ logger = AgentLogger("IdentityIntegration")
47
+
48
+
49
+ @dataclass
50
+ class UserIdentity:
51
+ """Resolved user identity with cloud authorization and boundary context.
52
+
53
+ Attributes:
54
+ platform: Source platform or interface (e.g. ``"terminal"``, ``"event_bus"``).
55
+ platform_user_id: Caller identifier on that platform.
56
+ internal_user_id: Internal corporate/IAM user identifier or email.
57
+ display_name: Human-readable display name.
58
+ rbac_role: Cloud authorization role (``"admin"``, ``"operator"``, ``"viewer"``).
59
+ allowed_accounts: Permitted cloud account IDs (``["*"]`` for all).
60
+ allowed_regions: Permitted cloud regions (``["*"]`` for all).
61
+ allowed_services: Permitted cloud services (e.g. ``["ec2", "s3"]`` or ``["*"]``).
62
+ allowed_operations: Permitted operations: ``"read"``, ``"deploy"``, ``"delete"``, etc.
63
+ allowed_namespaces: Permitted Kubernetes namespaces if cluster operations are used.
64
+ """
65
+
66
+ platform: str = "terminal"
67
+ platform_user_id: str = "local"
68
+ internal_user_id: str = "local"
69
+ display_name: str = "Terminal Operator"
70
+ rbac_role: str = "viewer"
71
+ allowed_accounts: list[str] = field(default_factory=lambda: ["*"])
72
+ allowed_regions: list[str] = field(default_factory=lambda: ["*"])
73
+ allowed_services: list[str] = field(default_factory=lambda: ["*"])
74
+ allowed_operations: list[str] = field(default_factory=lambda: ["read"])
75
+ allowed_namespaces: list[str] = field(default_factory=lambda: ["*"])
76
+
77
+
78
+ class IdentityMapper:
79
+ """Resolves platform users to :class:`UserIdentity` with cloud RBAC context.
80
+
81
+ Uses composite keys of the form ``"platform:user_id"`` (e.g. ``"terminal:local"``).
82
+ """
83
+
84
+ def __init__(self) -> None:
85
+ """Initialize IdentityMapper and load configured user mappings."""
86
+ self._mapping: dict[str, dict[str, Any]] = {}
87
+ self._load_mapping()
88
+
89
+ # ── Loading ───────────────────────────────────────────────────────
90
+
91
+ def _load_mapping(self) -> None:
92
+ """Load identity mapping from env var sources."""
93
+ # Priority 1: JSON file
94
+ mapping_file = os.getenv("IDENTITY_MAPPING_FILE")
95
+ if mapping_file:
96
+ try:
97
+ with open(mapping_file, encoding="utf-8") as fh:
98
+ self._mapping = json.load(fh)
99
+ logger.info(f"Loaded identity mapping from file: {mapping_file} ({len(self._mapping):d} entries)")
100
+ return
101
+ except (FileNotFoundError, json.JSONDecodeError, OSError) as exc:
102
+ logger.warning(f"Failed to load identity mapping file {mapping_file}: {exc}")
103
+
104
+ # Priority 2: Inline JSON string
105
+ mapping_str = os.getenv("IDENTITY_MAPPING")
106
+ if mapping_str:
107
+ try:
108
+ self._mapping = json.loads(mapping_str)
109
+ logger.info(f"Loaded identity mapping from IDENTITY_MAPPING env ({len(self._mapping):d} entries)")
110
+ return
111
+ except json.JSONDecodeError as exc:
112
+ logger.warning(f"Failed to parse IDENTITY_MAPPING env: {exc}")
113
+
114
+ logger.info("No identity mapping configured — using default viewer role")
115
+
116
+ # ── Resolution ────────────────────────────────────────────────────
117
+
118
+ def resolve(
119
+ self,
120
+ platform: str = "terminal",
121
+ platform_user_id: str | None = None,
122
+ ) -> UserIdentity:
123
+ """Resolve a caller identity to a :class:`UserIdentity`.
124
+
125
+ Unmapped callers receive the ``"viewer"`` role with read-only access.
126
+
127
+ Args:
128
+ platform: Platform key (e.g. ``"terminal"``, ``"event_bus"``).
129
+ platform_user_id: Platform-native or local user identifier.
130
+ Defaults to current operating system user when platform is "terminal".
131
+
132
+ Returns:
133
+ Resolved :class:`UserIdentity` with cloud authorization context.
134
+ """
135
+ if platform_user_id is None:
136
+ if platform == "terminal":
137
+ try:
138
+ platform_user_id = getpass.getuser()
139
+ except Exception:
140
+ platform_user_id = "local"
141
+ else:
142
+ platform_user_id = "anonymous"
143
+
144
+ key = f"{platform}:{platform_user_id}"
145
+ user_data = self._mapping.get(key, {})
146
+
147
+ accounts = user_data.get("accounts") or user_data.get("allowed_accounts") or ["*"]
148
+ regions = user_data.get("regions") or user_data.get("allowed_regions") or ["*"]
149
+ services = user_data.get("services") or user_data.get("allowed_services") or ["*"]
150
+ namespaces = user_data.get("namespaces") or user_data.get("allowed_namespaces") or ["*"]
151
+ operations = user_data.get("operations") or user_data.get("allowed_operations") or ["read"]
152
+
153
+ return UserIdentity(
154
+ platform=platform,
155
+ platform_user_id=platform_user_id,
156
+ internal_user_id=user_data.get("internal_id", platform_user_id),
157
+ display_name=user_data.get("display_name", platform_user_id.title()),
158
+ rbac_role=user_data.get("rbac_role", "viewer"),
159
+ allowed_accounts=list(accounts),
160
+ allowed_regions=list(regions),
161
+ allowed_services=list(services),
162
+ allowed_operations=list(operations),
163
+ allowed_namespaces=list(namespaces),
164
+ )
165
+
166
+ # ── Utilities ─────────────────────────────────────────────────────
167
+
168
+ def is_mapped(self, platform: str, platform_user_id: str) -> bool:
169
+ """Check if a user has an explicit mapping configured.
170
+
171
+ Args:
172
+ platform: Platform key.
173
+ platform_user_id: User identifier.
174
+
175
+ Returns:
176
+ True if user is explicitly present in the mapping.
177
+ """
178
+ return f"{platform}:{platform_user_id}" in self._mapping
179
+
180
+
181
+ __all__ = ["IdentityMapper", "UserIdentity"]
@@ -0,0 +1,134 @@
1
+ """Convenience notification helpers for OpsCloud events.
2
+
3
+ Thin wrappers around :func:`~opscloud.integrations.hooks.dispatch_hook` and
4
+ :func:`~opscloud.integrations.hooks.dispatch_hook_fire_and_forget` that assemble
5
+ standard event payload dictionaries for callers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from opscloud.integrations.hooks import (
13
+ dispatch_hook,
14
+ dispatch_hook_fire_and_forget,
15
+ )
16
+
17
+
18
+ async def notify_session_start(session_id: str, model: str) -> None:
19
+ """Fire ``session.start`` event (awaited).
20
+
21
+ Args:
22
+ session_id: Unique session identifier.
23
+ model: Active model identifier.
24
+ """
25
+ await dispatch_hook(
26
+ "session.start",
27
+ {"session_id": session_id, "model": model},
28
+ )
29
+
30
+
31
+ async def notify_session_end(session_id: str, duration_s: float) -> None:
32
+ """Fire ``session.end`` event (awaited).
33
+
34
+ Args:
35
+ session_id: Unique session identifier.
36
+ duration_s: Total session duration in seconds.
37
+ """
38
+ await dispatch_hook(
39
+ "session.end",
40
+ {"session_id": session_id, "duration_s": duration_s},
41
+ )
42
+
43
+
44
+ def notify_tool_use(
45
+ tool_name: str,
46
+ tool_id: str,
47
+ tool_args: dict[str, Any] | None = None,
48
+ ) -> None:
49
+ """Fire ``tool.use`` event (fire-and-forget).
50
+
51
+ Args:
52
+ tool_name: Name of tool being invoked.
53
+ tool_id: Unique tool execution ID.
54
+ tool_args: Arguments passed to the tool.
55
+ """
56
+ dispatch_hook_fire_and_forget(
57
+ "tool.use",
58
+ {
59
+ "tool_name": tool_name,
60
+ "tool_id": tool_id,
61
+ "tool_args": tool_args or {},
62
+ },
63
+ )
64
+
65
+
66
+ def notify_tool_result(
67
+ tool_name: str,
68
+ tool_status: str = "success",
69
+ tool_output: str = "",
70
+ ) -> None:
71
+ """Fire ``tool.result`` event (fire-and-forget).
72
+
73
+ Args:
74
+ tool_name: Name of completed tool.
75
+ tool_status: Execution status (e.g. ``"success"``, ``"error"``).
76
+ tool_output: Tool output string.
77
+ """
78
+ dispatch_hook_fire_and_forget(
79
+ "tool.result",
80
+ {
81
+ "tool_name": tool_name,
82
+ "tool_status": tool_status,
83
+ "tool_output": tool_output,
84
+ },
85
+ )
86
+
87
+
88
+ async def notify_task_complete(summary: str) -> None:
89
+ """Fire ``task.complete`` event (awaited).
90
+
91
+ Args:
92
+ summary: Human-readable task completion summary.
93
+ """
94
+ await dispatch_hook("task.complete", {"summary": summary})
95
+
96
+
97
+ def notify_deploy_event(
98
+ event_type: str,
99
+ details: dict[str, Any] | None = None,
100
+ ) -> None:
101
+ """Fire a DevOps deployment lifecycle event (fire-and-forget).
102
+
103
+ Args:
104
+ event_type: One of ``"deploy.start"``, ``"deploy.complete"``,
105
+ ``"terraform.plan"``, ``"terraform.apply"``, ``"helm.install"``,
106
+ ``"kubectl.apply"``.
107
+ details: Additional payload parameters.
108
+ """
109
+ dispatch_hook_fire_and_forget(event_type, details or {})
110
+
111
+
112
+ def notify_cloud_event(
113
+ event_type: str,
114
+ details: dict[str, Any] | None = None,
115
+ ) -> None:
116
+ """Fire a cloud infrastructure lifecycle event (fire-and-forget).
117
+
118
+ Args:
119
+ event_type: Cloud event name (e.g. ``"aws.resource.create"``,
120
+ ``"aws.resource.update"``, ``"aws.resource.delete"``).
121
+ details: Details of the cloud resource or operation.
122
+ """
123
+ dispatch_hook_fire_and_forget(event_type, details or {})
124
+
125
+
126
+ __all__ = [
127
+ "notify_cloud_event",
128
+ "notify_deploy_event",
129
+ "notify_session_end",
130
+ "notify_session_start",
131
+ "notify_task_complete",
132
+ "notify_tool_result",
133
+ "notify_tool_use",
134
+ ]