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.
- opscloud/__init__.py +5 -0
- opscloud/__main__.py +6 -0
- opscloud/_constants.py +53 -0
- opscloud/_debug.py +145 -0
- opscloud/_debug_buffer.py +250 -0
- opscloud/_textual_patches.py +367 -0
- opscloud/_version.py +15 -0
- opscloud/agent/__init__.py +27 -0
- opscloud/agent/config.py +168 -0
- opscloud/agent/factory.py +1103 -0
- opscloud/approval_mode.py +44 -0
- opscloud/backend/__init__.py +49 -0
- opscloud/backend/composite.py +89 -0
- opscloud/backend/local.py +183 -0
- opscloud/backend/registry.py +79 -0
- opscloud/backend/sandbox_config.py +107 -0
- opscloud/backend/sandbox_factory.py +432 -0
- opscloud/built_in_skills/remember/SKILL.md +124 -0
- opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
- opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
- opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
- opscloud/cli/__init__.py +1 -0
- opscloud/cli/commands/__init__.py +15 -0
- opscloud/cli/commands/auth.py +45 -0
- opscloud/cli/commands/doctor.py +100 -0
- opscloud/cli/commands/mcp.py +137 -0
- opscloud/cli/commands/threads.py +51 -0
- opscloud/cli/interactive.py +154 -0
- opscloud/cli/main.py +903 -0
- opscloud/cli/non_interactive.py +425 -0
- opscloud/cli/server_manager.py +58 -0
- opscloud/commands/__init__.py +19 -0
- opscloud/commands/_base.py +110 -0
- opscloud/commands/_guards.py +52 -0
- opscloud/commands/_router.py +146 -0
- opscloud/commands/_types.py +36 -0
- opscloud/commands/core/__init__.py +47 -0
- opscloud/commands/core/auth.py +94 -0
- opscloud/commands/core/bug.py +46 -0
- opscloud/commands/core/clear.py +106 -0
- opscloud/commands/core/cloud.py +185 -0
- opscloud/commands/core/compact.py +128 -0
- opscloud/commands/core/config_cmd.py +120 -0
- opscloud/commands/core/context.py +220 -0
- opscloud/commands/core/cost.py +44 -0
- opscloud/commands/core/doctor.py +535 -0
- opscloud/commands/core/effort.py +108 -0
- opscloud/commands/core/exit_cmd.py +41 -0
- opscloud/commands/core/fast.py +148 -0
- opscloud/commands/core/help_cmd.py +94 -0
- opscloud/commands/core/mcp.py +134 -0
- opscloud/commands/core/model.py +109 -0
- opscloud/commands/core/permissions.py +196 -0
- opscloud/commands/core/plugins.py +92 -0
- opscloud/commands/core/pool.py +108 -0
- opscloud/commands/core/resume.py +97 -0
- opscloud/commands/core/skills.py +178 -0
- opscloud/commands/power/__init__.py +51 -0
- opscloud/commands/power/agents.py +98 -0
- opscloud/commands/power/btw.py +85 -0
- opscloud/commands/power/copy.py +97 -0
- opscloud/commands/power/goal.py +548 -0
- opscloud/commands/power/loop.py +295 -0
- opscloud/commands/power/memory.py +287 -0
- opscloud/commands/power/review.py +152 -0
- opscloud/commands/power/rubric.py +287 -0
- opscloud/commands/power/runtime.py +321 -0
- opscloud/commands/power/skill_creator.py +70 -0
- opscloud/commands/power/skill_invoke.py +165 -0
- opscloud/commands/power/tasks.py +209 -0
- opscloud/commands/power/trace.py +117 -0
- opscloud/commands/power/ui_toggles.py +135 -0
- opscloud/commands/power/version.py +52 -0
- opscloud/commands/registry.py +176 -0
- opscloud/config/__init__.py +114 -0
- opscloud/config/adapters/__init__.py +5 -0
- opscloud/config/adapters/postgres.py +776 -0
- opscloud/config/adapters/sqlite.py +769 -0
- opscloud/config/aws.py +248 -0
- opscloud/config/cloud_profiles.py +437 -0
- opscloud/config/env_vars.py +81 -0
- opscloud/config/langsmith.py +319 -0
- opscloud/config/manifest.py +817 -0
- opscloud/config/metadata.py +205 -0
- opscloud/config/paths.py +604 -0
- opscloud/config/plugins.py +130 -0
- opscloud/config/settings.py +834 -0
- opscloud/config/store.py +453 -0
- opscloud/config/store_factory.py +97 -0
- opscloud/config/toml_config.py +562 -0
- opscloud/editor.py +173 -0
- opscloud/exceptions.py +65 -0
- opscloud/file_ops.py +217 -0
- opscloud/hooks/__init__.py +139 -0
- opscloud/hooks/env.py +44 -0
- opscloud/hooks/interrupt.py +121 -0
- opscloud/hooks/models/__init__.py +109 -0
- opscloud/hooks/models/adapters.py +19 -0
- opscloud/hooks/models/config.py +79 -0
- opscloud/hooks/models/domain.py +434 -0
- opscloud/hooks/models/transport.py +37 -0
- opscloud/hooks/tools.py +231 -0
- opscloud/input.py +48 -0
- opscloud/integrations/__init__.py +80 -0
- opscloud/integrations/base.py +153 -0
- opscloud/integrations/content_filter.py +119 -0
- opscloud/integrations/event_bus.py +480 -0
- opscloud/integrations/hooks.py +238 -0
- opscloud/integrations/identity.py +181 -0
- opscloud/integrations/notifications.py +134 -0
- opscloud/integrations/stream_bridge.py +397 -0
- opscloud/integrations/thread_store.py +81 -0
- opscloud/json_types.py +20 -0
- opscloud/mcp/__init__.py +57 -0
- opscloud/mcp/config.py +203 -0
- opscloud/mcp/discovery.py +229 -0
- opscloud/mcp/mcp_info.py +109 -0
- opscloud/mcp/middleware.py +102 -0
- opscloud/mcp/preload.py +505 -0
- opscloud/mcp/raw_config.py +149 -0
- opscloud/mcp/semantic_profiler.py +305 -0
- opscloud/mcp/session_manager.py +727 -0
- opscloud/mcp/trust.py +105 -0
- opscloud/memory/__init__.py +27 -0
- opscloud/memory/branch.py +61 -0
- opscloud/memory/guard.py +19 -0
- opscloud/memory/onboarding.py +120 -0
- opscloud/memory/registry.py +134 -0
- opscloud/memory/store.py +223 -0
- opscloud/middleware/__init__.py +106 -0
- opscloud/middleware/_repository_bounds.py +459 -0
- opscloud/middleware/ask_user.py +152 -0
- opscloud/middleware/auto_mode.py +60 -0
- opscloud/middleware/auto_mode_hitl.py +2434 -0
- opscloud/middleware/compaction.py +566 -0
- opscloud/middleware/configurable_model.py +233 -0
- opscloud/middleware/cost_tracking.py +1021 -0
- opscloud/middleware/glm_stall_recovery.py +109 -0
- opscloud/middleware/goal_criteria.py +1234 -0
- opscloud/middleware/goal_state_notice.py +712 -0
- opscloud/middleware/goal_tools.py +407 -0
- opscloud/middleware/headless_mcp_guard.py +88 -0
- opscloud/middleware/jev_model_router.py +1018 -0
- opscloud/middleware/local_context.py +460 -0
- opscloud/middleware/mcp_context.py +151 -0
- opscloud/middleware/mcp_middleware.py +146 -0
- opscloud/middleware/memory_guard.py +393 -0
- opscloud/middleware/model_retry.py +205 -0
- opscloud/middleware/registry.py +137 -0
- opscloud/middleware/reliable_rubric.py +709 -0
- opscloud/middleware/resume_state.py +91 -0
- opscloud/middleware/server_hooks.py +1189 -0
- opscloud/middleware/shell_allow_list.py +90 -0
- opscloud/middleware/skills.py +382 -0
- opscloud/middleware/subagent_artifacts.py +84 -0
- opscloud/middleware/subagent_telemetry.py +261 -0
- opscloud/middleware/subagents.py +243 -0
- opscloud/middleware/tool_filter.py +384 -0
- opscloud/middleware/unified_system_message.py +62 -0
- opscloud/model/__init__.py +37 -0
- opscloud/model/config.py +2992 -0
- opscloud/model/factory.py +477 -0
- opscloud/model/pool.py +459 -0
- opscloud/model/reasoning.py +282 -0
- opscloud/offload.py +173 -0
- opscloud/output.py +64 -0
- opscloud/plugins/__init__.py +189 -0
- opscloud/plugins/_json.py +22 -0
- opscloud/plugins/adapters/__init__.py +13 -0
- opscloud/plugins/adapters/agents.py +112 -0
- opscloud/plugins/adapters/commands.py +190 -0
- opscloud/plugins/adapters/mcp.py +363 -0
- opscloud/plugins/adapters/skills.py +132 -0
- opscloud/plugins/commands_cli.py +541 -0
- opscloud/plugins/discovery.py +512 -0
- opscloud/plugins/manifest.py +335 -0
- opscloud/plugins/marketplace.py +893 -0
- opscloud/plugins/models.py +334 -0
- opscloud/plugins/project_plugins.py +317 -0
- opscloud/plugins/protocol.py +31 -0
- opscloud/plugins/store.py +905 -0
- opscloud/plugins/substitution.py +122 -0
- opscloud/project_utils.py +145 -0
- opscloud/prompts/__init__.py +37 -0
- opscloud/prompts/system.py +483 -0
- opscloud/prompts/templates/system_prompt.md +166 -0
- opscloud/prompts/types.py +54 -0
- opscloud/rubrics/__init__.py +42 -0
- opscloud/rubrics/evaluator.py +404 -0
- opscloud/rubrics/evidence_extractor.py +229 -0
- opscloud/rubrics/generator.py +239 -0
- opscloud/rubrics/jev_compiler.py +175 -0
- opscloud/rubrics/jev_grader.py +297 -0
- opscloud/rubrics/middleware.py +8 -0
- opscloud/schema/__init__.py +107 -0
- opscloud/schema/interrupts.py +981 -0
- opscloud/security/__init__.py +91 -0
- opscloud/security/approval_mode.py +528 -0
- opscloud/security/approval_mode_source.py +230 -0
- opscloud/security/cli_ast_evaluator.py +552 -0
- opscloud/security/jev_classifier.py +651 -0
- opscloud/security/shell_safety.py +340 -0
- opscloud/security/unicode_security.py +439 -0
- opscloud/security/url_validation.py +160 -0
- opscloud/server/__init__.py +13 -0
- opscloud/server/_server_config.py +158 -0
- opscloud/server/server.py +242 -0
- opscloud/server/server_graph.py +93 -0
- opscloud/skills/__init__.py +91 -0
- opscloud/skills/commands.py +1099 -0
- opscloud/skills/invocation.py +200 -0
- opscloud/skills/load.py +388 -0
- opscloud/skills/loader.py +23 -0
- opscloud/skills/merge.py +65 -0
- opscloud/skills/registry.py +263 -0
- opscloud/skills/sources.py +174 -0
- opscloud/skills/trust.py +417 -0
- opscloud/state/__init__.py +142 -0
- opscloud/state/base.py +154 -0
- opscloud/state/cloud_context.py +262 -0
- opscloud/state/goal_channels.py +124 -0
- opscloud/state/goal_state_limits.py +175 -0
- opscloud/state/resume_state.py +58 -0
- opscloud/state/service_context.py +281 -0
- opscloud/state/session.py +957 -0
- opscloud/state/state_migration.py +86 -0
- opscloud/subagents/__init__.py +30 -0
- opscloud/subagents/loader.py +331 -0
- opscloud/subagents/subagents_parser.py +117 -0
- opscloud/subagents/types.py +25 -0
- opscloud/tools/__init__.py +36 -0
- opscloud/tools/catalog.py +37 -0
- opscloud/tools/display.py +24 -0
- opscloud/tools/fetch_url.py +176 -0
- opscloud/tools/goal_tools.py +423 -0
- opscloud/tools/registry.py +105 -0
- opscloud/tools/thread.py +32 -0
- opscloud/tools/web_search.py +215 -0
- opscloud/ui/__init__.py +119 -0
- opscloud/ui/app.py +5400 -0
- opscloud/ui/app.tcss +479 -0
- opscloud/ui/clipboard.py +147 -0
- opscloud/ui/command_registry.py +482 -0
- opscloud/ui/message_store.py +152 -0
- opscloud/ui/permission_store.py +360 -0
- opscloud/ui/preferences.py +113 -0
- opscloud/ui/prompt_manager.py +96 -0
- opscloud/ui/remote_client.py +505 -0
- opscloud/ui/textual_adapter.py +1269 -0
- opscloud/ui/theme.py +728 -0
- opscloud/ui/ui_help.py +415 -0
- opscloud/ui/widgets/__init__.py +383 -0
- opscloud/ui/widgets/_ask_user_types.py +81 -0
- opscloud/ui/widgets/_copy_spans.py +52 -0
- opscloud/ui/widgets/_inline_prompt.py +290 -0
- opscloud/ui/widgets/_js_eval_display.py +139 -0
- opscloud/ui/widgets/_links.py +175 -0
- opscloud/ui/widgets/_tool_stream.py +308 -0
- opscloud/ui/widgets/agent_selector.py +297 -0
- opscloud/ui/widgets/approval.py +741 -0
- opscloud/ui/widgets/ask_user.py +539 -0
- opscloud/ui/widgets/auth.py +695 -0
- opscloud/ui/widgets/auth_manager.py +214 -0
- opscloud/ui/widgets/auto_mode_notice.py +146 -0
- opscloud/ui/widgets/autocomplete.py +709 -0
- opscloud/ui/widgets/chat_input.py +805 -0
- opscloud/ui/widgets/cloud_selector.py +528 -0
- opscloud/ui/widgets/config_manager.py +652 -0
- opscloud/ui/widgets/debug_console.py +1269 -0
- opscloud/ui/widgets/devops_renderers.py +184 -0
- opscloud/ui/widgets/diff.py +237 -0
- opscloud/ui/widgets/effort_selector.py +166 -0
- opscloud/ui/widgets/goal_review.py +612 -0
- opscloud/ui/widgets/goal_status.py +57 -0
- opscloud/ui/widgets/infra_panel.py +141 -0
- opscloud/ui/widgets/install_confirm.py +114 -0
- opscloud/ui/widgets/loading.py +170 -0
- opscloud/ui/widgets/mcp_viewer.py +1204 -0
- opscloud/ui/widgets/messages.py +4461 -0
- opscloud/ui/widgets/model_selector.py +863 -0
- opscloud/ui/widgets/notification_center.py +108 -0
- opscloud/ui/widgets/notification_settings.py +119 -0
- opscloud/ui/widgets/operation_card.py +106 -0
- opscloud/ui/widgets/permissions_manager.py +667 -0
- opscloud/ui/widgets/plugin_manager.py +1212 -0
- opscloud/ui/widgets/pool_selector.py +376 -0
- opscloud/ui/widgets/preamble.py +53 -0
- opscloud/ui/widgets/skills_viewer.py +432 -0
- opscloud/ui/widgets/status.py +1037 -0
- opscloud/ui/widgets/subagent_panel.py +927 -0
- opscloud/ui/widgets/theme_selector.py +232 -0
- opscloud/ui/widgets/thread_selector.py +358 -0
- opscloud/ui/widgets/toast.py +31 -0
- opscloud/ui/widgets/tool_display.py +325 -0
- opscloud/ui/widgets/tool_renderers.py +234 -0
- opscloud/ui/widgets/tool_widgets.py +278 -0
- opscloud/ui/widgets/welcome.py +421 -0
- opscloud/ui/widgets/welcome_popup.py +102 -0
- opscloud/utils/__init__.py +24 -0
- opscloud/utils/cost_estimation.py +169 -0
- opscloud/utils/git.py +390 -0
- opscloud/utils/logger.py +571 -0
- opscloud/utils/session_stats.py +115 -0
- opscloud/utils/startup_error.py +37 -0
- talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
- talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
- talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
- talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|