karox-runtime 5.0.0rc2__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.
- karox/__init__.py +6 -0
- karox/access_mode.py +248 -0
- karox/action_execution.py +231 -0
- karox/action_policy.py +610 -0
- karox/activity_stream.py +439 -0
- karox/affected_checks.py +962 -0
- karox/agent.py +2576 -0
- karox/agent_mcp.py +92 -0
- karox/agent_modes.py +168 -0
- karox/agent_protocol.py +347 -0
- karox/artifacts.py +306 -0
- karox/autonomy_benchmark.py +1012 -0
- karox/autonomy_runtime.py +3207 -0
- karox/background_orchestration.py +344 -0
- karox/background_worker.py +58 -0
- karox/bridge.py +465 -0
- karox/bridge_handoff.py +185 -0
- karox/browser_access.py +1892 -0
- karox/browser_approval.py +451 -0
- karox/browser_bootstrap.py +218 -0
- karox/browser_credential_injection.py +128 -0
- karox/browser_credentials.py +224 -0
- karox/browser_errors.py +144 -0
- karox/browser_extension/__init__.py +1 -0
- karox/browser_extension/content.js +509 -0
- karox/browser_extension/dom_helpers.js +442 -0
- karox/browser_extension/manifest.json +57 -0
- karox/browser_extension/newtab.css +1 -0
- karox/browser_extension/newtab.html +21 -0
- karox/browser_extension/newtab.js +1 -0
- karox/browser_extension/page_console_bridge.js +93 -0
- karox/browser_extension/service_worker.js +1426 -0
- karox/browser_extension/sidepanel.css +1 -0
- karox/browser_extension/sidepanel.html +27 -0
- karox/browser_extension/sidepanel.js +1 -0
- karox/browser_session.py +521 -0
- karox/build_identity.py +124 -0
- karox/cache_scheduler.py +263 -0
- karox/chatgpt_project_binding.py +307 -0
- karox/check_jobs.py +1523 -0
- karox/checkpoints.py +75 -0
- karox/cli.py +6897 -0
- karox/cli_shortcuts.py +128 -0
- karox/clickup_setup.py +1412 -0
- karox/client_capabilities.py +279 -0
- karox/clipboard.py +175 -0
- karox/command_guard.py +217 -0
- karox/connection_controller.py +581 -0
- karox/connection_runtime.py +556 -0
- karox/connection_status.py +413 -0
- karox/connection_tests.py +391 -0
- karox/connections.py +1456 -0
- karox/constitution.py +288 -0
- karox/context_ab.py +300 -0
- karox/context_bus.py +425 -0
- karox/context_compiler.py +375 -0
- karox/core.py +2603 -0
- karox/core_tools.py +908 -0
- karox/cost_intelligence.py +648 -0
- karox/credentials.py +273 -0
- karox/delegated_provider.py +252 -0
- karox/desktop_apps.py +1058 -0
- karox/detached_process.py +92 -0
- karox/disk_maintenance.py +1099 -0
- karox/economy_engine.py +306 -0
- karox/economy_eval.py +179 -0
- karox/ecosystem.py +268 -0
- karox/effort.py +442 -0
- karox/effort_signals.py +324 -0
- karox/ellipsis_api.py +459 -0
- karox/ellipsis_bridge_server.py +382 -0
- karox/ellipsis_checkpoint.py +367 -0
- karox/ellipsis_cli.py +409 -0
- karox/ellipsis_helpers.py +407 -0
- karox/ellipsis_runtime.py +810 -0
- karox/ellipsis_watchdog.py +115 -0
- karox/entrypoint.py +70 -0
- karox/event_bus.py +343 -0
- karox/evidence_packets.py +443 -0
- karox/extension_browser.py +1586 -0
- karox/file_mentions.py +108 -0
- karox/handoff.py +208 -0
- karox/hosted_bridge.py +1328 -0
- karox/hosted_project_orchestration.py +139 -0
- karox/hosted_tools_runtime.py +3080 -0
- karox/hot_worker.py +341 -0
- karox/image_attachments.py +109 -0
- karox/intelligence_pool.py +508 -0
- karox/launch_support.py +219 -0
- karox/lsp_diagnostics.py +362 -0
- karox/managed_browser.py +661 -0
- karox/managed_process_cleanup.py +63 -0
- karox/map_service.py +798 -0
- karox/mcp_client.py +1171 -0
- karox/mcp_oauth.py +268 -0
- karox/mcp_status.py +302 -0
- karox/memory.py +672 -0
- karox/migration.py +245 -0
- karox/mission_control.py +462 -0
- karox/mission_control_server.py +320 -0
- karox/mission_control_worker.py +112 -0
- karox/mobile_mission.py +309 -0
- karox/mode_artifacts.py +141 -0
- karox/model_auto.py +124 -0
- karox/model_delegation_broker.py +213 -0
- karox/model_grant_requests.py +496 -0
- karox/model_grants.py +1084 -0
- karox/model_hot_actions.py +467 -0
- karox/model_micro_benchmark.py +157 -0
- karox/model_quality_benchmark.py +316 -0
- karox/models.py +251 -0
- karox/notion_mcp.py +60 -0
- karox/oauth_bridge.py +1465 -0
- karox/openapi_bridge.py +392 -0
- karox/openrouter_delegation.py +472 -0
- karox/orchestration_cli.py +861 -0
- karox/orchestration_native.py +319 -0
- karox/orchestration_planning.py +170 -0
- karox/orchestration_presenter.py +372 -0
- karox/orchestration_recipes.py +162 -0
- karox/orchestration_recovery.py +287 -0
- karox/orchestration_routing.py +481 -0
- karox/orchestrator.py +1715 -0
- karox/orchestrator_advisor.py +252 -0
- karox/orchestrator_delegation.py +329 -0
- karox/output_policy.py +151 -0
- karox/packs.py +951 -0
- karox/paths.py +160 -0
- karox/plan_act.py +97 -0
- karox/plan_executor.py +1605 -0
- karox/policy.py +212 -0
- karox/port_allocation.py +144 -0
- karox/port_ownership.py +593 -0
- karox/process_identity.py +430 -0
- karox/process_launcher.py +150 -0
- karox/project_context.py +506 -0
- karox/project_map.py +363 -0
- karox/project_registry.py +250 -0
- karox/prompt_benchmark.py +239 -0
- karox/prompt_cache_plan.py +64 -0
- karox/promptql_outbound.py +276 -0
- karox/provider_adapters.py +1321 -0
- karox/provider_controller.py +661 -0
- karox/provider_credential_capture.py +200 -0
- karox/provider_factory.py +50 -0
- karox/provider_presets.py +378 -0
- karox/provider_pricing.py +209 -0
- karox/providers.py +1123 -0
- karox/proxy.py +252 -0
- karox/proxy_server.py +2049 -0
- karox/quickstart.py +387 -0
- karox/quota_brain.py +136 -0
- karox/recipe_registry.py +187 -0
- karox/registry.py +659 -0
- karox/release_hot_actions.py +577 -0
- karox/remote_lease.py +280 -0
- karox/remote_tools.py +806 -0
- karox/repo_context.py +1950 -0
- karox/repository_lease.py +492 -0
- karox/research_subagent.py +613 -0
- karox/result_envelope.py +197 -0
- karox/risk_engine.py +648 -0
- karox/risk_mapping.py +583 -0
- karox/route_health.py +170 -0
- karox/routing.py +686 -0
- karox/runtime_restart.py +510 -0
- karox/saved_bridge_autostart.py +889 -0
- karox/saved_bridge_supervisor.py +1134 -0
- karox/security.py +157 -0
- karox/service_supervisor.py +758 -0
- karox/session_view.py +1129 -0
- karox/sessions.py +825 -0
- karox/shadow_economy.py +229 -0
- karox/skills.py +873 -0
- karox/smart_stop.py +173 -0
- karox/static_server.py +45 -0
- karox/subscription_cli.py +640 -0
- karox/system_chrome.py +321 -0
- karox/tailscale.py +938 -0
- karox/tailscale_routes.py +389 -0
- karox/task_state.py +469 -0
- karox/tool_catalog.py +66 -0
- karox/tool_telemetry.py +425 -0
- karox/tool_universe.py +187 -0
- karox/transcript.py +407 -0
- karox/transcript_shadow.py +435 -0
- karox/tui.py +16842 -0
- karox/tui_connections.py +9408 -0
- karox/tui_dashboard.py +1192 -0
- karox/tui_workspace.py +332 -0
- karox/unified_patch.py +295 -0
- karox/usage_analytics.py +467 -0
- karox/usage_report.py +376 -0
- karox/user_commands.py +232 -0
- karox/verification.py +351 -0
- karox/web_bridge_launcher.py +6698 -0
- karox/web_bridge_profiles.py +552 -0
- karox/worker_adapter_registry.py +101 -0
- karox/worker_adapters.py +236 -0
- karox/workspace_change_guard.py +269 -0
- karox/workspace_transaction.py +490 -0
- karox/workspace_worker.py +639 -0
- karox/worktree_pool.py +576 -0
- karox_runtime-5.0.0rc2.dist-info/METADATA +448 -0
- karox_runtime-5.0.0rc2.dist-info/RECORD +209 -0
- karox_runtime-5.0.0rc2.dist-info/WHEEL +5 -0
- karox_runtime-5.0.0rc2.dist-info/entry_points.txt +4 -0
- karox_runtime-5.0.0rc2.dist-info/licenses/LICENSE +21 -0
- karox_runtime-5.0.0rc2.dist-info/top_level.txt +1 -0
karox/__init__.py
ADDED
karox/access_mode.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Common Bypass (access mode) contract for every KaroX connection family.
|
|
2
|
+
|
|
3
|
+
RU: "Bypass rezhim" -- minimum vnutrennikh ogranichenij KaroX dlya avtonomnoj
|
|
4
|
+
razrabotki. EN: "Bypass mode" -- minimal KaroX restrictions for autonomous
|
|
5
|
+
development.
|
|
6
|
+
|
|
7
|
+
Bypass is an opt-in, per-connection mode expressed entirely through the
|
|
8
|
+
permission architecture that already exists: the saved record's
|
|
9
|
+
``AccessProfile`` plus the ``CapabilityPolicy`` layer that enforces it at
|
|
10
|
+
runtime. ON selects the highest existing developer capability profile
|
|
11
|
+
(``AccessProfile.ELEVATED``); OFF returns to the normal protected Project
|
|
12
|
+
access contract (``AccessProfile.WORKSPACE_WRITE``). No second permission
|
|
13
|
+
architecture, no parallel store.
|
|
14
|
+
|
|
15
|
+
What Bypass is NOT:
|
|
16
|
+
|
|
17
|
+
* it does not touch OS / provider / platform security;
|
|
18
|
+
* it does not disable credential isolation or the keyring boundary;
|
|
19
|
+
* it does not skip confirmation of irreversible external actions;
|
|
20
|
+
* it does not mean automatic git push, publishing, or payments;
|
|
21
|
+
* it never rotates secrets, connection identity, or bridge URLs.
|
|
22
|
+
|
|
23
|
+
Hyperagent's earlier "Full developer access" toggle is this very mode; its
|
|
24
|
+
canonical saved-profile builder lives here now and Hyperagent is the first
|
|
25
|
+
user of the shared implementation. A record written before this mode
|
|
26
|
+
existed reads as OFF, and a legacy Hyperagent profile that already carries
|
|
27
|
+
the full elevated contract reads as ON -- no migration rewrite, no schema
|
|
28
|
+
version bump.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import replace
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"BYPASS_TOOL_NAMES",
|
|
39
|
+
"build_saved_profile_bypass",
|
|
40
|
+
"provider_access_profile",
|
|
41
|
+
"provider_bypass_enabled",
|
|
42
|
+
"saved_profile_bypass_enabled",
|
|
43
|
+
"session_access_profile",
|
|
44
|
+
"set_provider_bypass",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# The tool names a coding connection gains under Bypass beyond its normal
|
|
48
|
+
# project-access catalogue. They are appended when the mode is first enabled
|
|
49
|
+
# and then kept stable across ON/OFF transitions: several clients cache the
|
|
50
|
+
# MCP tool catalogue for the lifetime of a thread, so only the runtime
|
|
51
|
+
# permission profile may change on a toggle.
|
|
52
|
+
BYPASS_TOOL_NAMES: tuple[str, ...] = (
|
|
53
|
+
"karox.command.run",
|
|
54
|
+
"karox.command.start",
|
|
55
|
+
"karox.command.status",
|
|
56
|
+
"karox.command.logs",
|
|
57
|
+
"karox.command.cancel",
|
|
58
|
+
"karox.git.commit",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def saved_profile_bypass_enabled(profile: Any) -> bool:
|
|
63
|
+
"""Whether a saved web-bridge profile explicitly enables Bypass.
|
|
64
|
+
|
|
65
|
+
New profiles persist a dedicated ``bypass`` bit so Advanced/elevated access
|
|
66
|
+
can exist without silently authorizing destructive deletion. ``from_dict``
|
|
67
|
+
preserves legacy elevated profiles as Bypass during migration.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if hasattr(profile, "bypass"):
|
|
72
|
+
return bool(getattr(profile, "bypass"))
|
|
73
|
+
from .models import AccessProfile
|
|
74
|
+
return getattr(profile, "access_profile", None) == AccessProfile.ELEVATED
|
|
75
|
+
except Exception:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_saved_profile_bypass(profile: Any, enabled: bool) -> Any:
|
|
80
|
+
"""Return the canonical saved profile for Bypass ON/OFF.
|
|
81
|
+
|
|
82
|
+
The returned record keeps its identity: name, target, repository,
|
|
83
|
+
projects, port, tunnel, public URL, and credential references are never
|
|
84
|
+
rewritten, so flipping the mode cannot rotate a secret, change a bridge
|
|
85
|
+
URL, or spawn a duplicate. Hyperagent additionally opts into its
|
|
86
|
+
historical full-developer browser contract; every other target keeps its
|
|
87
|
+
browser policy exactly as configured.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
if getattr(profile, "target_profile", "") == "hyperagent-web":
|
|
91
|
+
return _build_hyperagent_bypass(profile, enabled)
|
|
92
|
+
|
|
93
|
+
from .models import AccessProfile
|
|
94
|
+
|
|
95
|
+
kwargs: dict[str, Any] = {
|
|
96
|
+
"access_profile": (
|
|
97
|
+
AccessProfile.ELEVATED if enabled else AccessProfile.WORKSPACE_WRITE
|
|
98
|
+
),
|
|
99
|
+
"bypass": bool(enabled),
|
|
100
|
+
}
|
|
101
|
+
if enabled:
|
|
102
|
+
tools = list(getattr(profile, "tools", ()) or ())
|
|
103
|
+
extended = False
|
|
104
|
+
for name in BYPASS_TOOL_NAMES:
|
|
105
|
+
if name not in tools:
|
|
106
|
+
tools.append(name)
|
|
107
|
+
extended = True
|
|
108
|
+
if extended:
|
|
109
|
+
kwargs["tools"] = tuple(dict.fromkeys(tools))
|
|
110
|
+
return replace(profile, **kwargs)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _build_hyperagent_bypass(profile: Any, enabled: bool) -> Any:
|
|
114
|
+
"""The canonical Hyperagent profile for Full access / Bypass ON-OFF.
|
|
115
|
+
|
|
116
|
+
ON deliberately means one coherent trusted developer contract rather than
|
|
117
|
+
a pile of independent checkboxes: elevated repo/process/check
|
|
118
|
+
capabilities, the whole coding tool surface, Git commit, an unrestricted
|
|
119
|
+
developer command runner, and a headed browser that may navigate public
|
|
120
|
+
HTTPS with network inspection and takeover. OFF returns to the normal
|
|
121
|
+
protected Project access contract.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
repository_raw = str(getattr(profile, "repository", "") or "").strip()
|
|
125
|
+
if not repository_raw:
|
|
126
|
+
raise ValueError("Hyperagent profile has no repository")
|
|
127
|
+
repository = Path(repository_raw).expanduser().resolve(strict=True)
|
|
128
|
+
if not repository.is_dir():
|
|
129
|
+
raise ValueError("Hyperagent repository is not a directory")
|
|
130
|
+
|
|
131
|
+
from .hosted_tools_runtime import server_profiles_for_repository
|
|
132
|
+
from .models import AccessProfile
|
|
133
|
+
from .verification import discover_verification_commands
|
|
134
|
+
from .web_bridge_launcher import (
|
|
135
|
+
CHECKS_RUN_TOOL,
|
|
136
|
+
DEFAULT_WEB_TOOLS,
|
|
137
|
+
EXTERNAL_BROWSER_TOOL_NAMES,
|
|
138
|
+
WRITE_WEB_TOOLS,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
verification = discover_verification_commands(repository)
|
|
142
|
+
if not verification:
|
|
143
|
+
verification = tuple(getattr(profile, "verification_commands", ()) or ())
|
|
144
|
+
runtime_profiles = server_profiles_for_repository(repository)
|
|
145
|
+
server_profiles = tuple(item.to_public_dict() for item in runtime_profiles)
|
|
146
|
+
|
|
147
|
+
tools = list(DEFAULT_WEB_TOOLS)
|
|
148
|
+
tools.extend(WRITE_WEB_TOOLS)
|
|
149
|
+
if verification:
|
|
150
|
+
tools.append(CHECKS_RUN_TOOL)
|
|
151
|
+
if not server_profiles:
|
|
152
|
+
tools = [
|
|
153
|
+
item
|
|
154
|
+
for item in tools
|
|
155
|
+
if item not in {
|
|
156
|
+
"karox.dev_server.start",
|
|
157
|
+
"karox.dev_server.stop",
|
|
158
|
+
"karox.dev_server.restart",
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
# Hyperagent may cache the MCP tool catalogue for the lifetime of a thread.
|
|
162
|
+
# Keep the advertised tool names stable across Project access <-> Bypass
|
|
163
|
+
# and change only the runtime permission/profile. Otherwise flipping the
|
|
164
|
+
# switch can leave a perfectly healthy elevated bridge behind a stale
|
|
165
|
+
# client catalogue that still contains the smaller Project-access set.
|
|
166
|
+
tools.extend(BYPASS_TOOL_NAMES)
|
|
167
|
+
tools.extend(EXTERNAL_BROWSER_TOOL_NAMES)
|
|
168
|
+
|
|
169
|
+
return replace(
|
|
170
|
+
profile,
|
|
171
|
+
tools=tuple(dict.fromkeys(tools)),
|
|
172
|
+
verification_commands=tuple(verification),
|
|
173
|
+
server_profiles=server_profiles,
|
|
174
|
+
access_profile=(
|
|
175
|
+
AccessProfile.ELEVATED if enabled else AccessProfile.WORKSPACE_WRITE
|
|
176
|
+
),
|
|
177
|
+
bypass=bool(enabled),
|
|
178
|
+
browser_external_https=bool(enabled),
|
|
179
|
+
# Empty allowlist + external_https=True means any public HTTPS host
|
|
180
|
+
# that passes KaroX DNS/private-network checks: the intended normal
|
|
181
|
+
# browser.
|
|
182
|
+
browser_allowed_domains=(),
|
|
183
|
+
browser_headed=bool(enabled),
|
|
184
|
+
browser_user_takeover=bool(enabled),
|
|
185
|
+
browser_network_inspection=bool(enabled),
|
|
186
|
+
# Spending/subscription confirmation remains a separate explicit gate.
|
|
187
|
+
browser_payment_confirmation=False,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def provider_bypass_enabled(record: Any) -> bool:
|
|
192
|
+
"""Whether an API/provider connection requests Bypass for new sessions."""
|
|
193
|
+
|
|
194
|
+
return bool(getattr(record, "bypass", False))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def set_provider_bypass(record: Any, enabled: bool) -> Any:
|
|
198
|
+
"""Return the provider record with the persisted Bypass preference.
|
|
199
|
+
|
|
200
|
+
The provider record only carries the preference; enforcement happens when
|
|
201
|
+
a KaroX agent session is created for that provider. Provider HTTP auth,
|
|
202
|
+
transport configuration, and credential storage never change here.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
if bool(getattr(record, "bypass", False)) == bool(enabled):
|
|
206
|
+
return record
|
|
207
|
+
return replace(record, bypass=bool(enabled))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def provider_access_profile(record: Any, base: Any = None) -> Any:
|
|
211
|
+
"""The ``AccessProfile`` a new agent session for this provider gets.
|
|
212
|
+
|
|
213
|
+
Bypass ON maps to the existing elevated developer contract; OFF keeps the
|
|
214
|
+
caller's normal profile (or the protected Project access default).
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
from .models import AccessProfile
|
|
218
|
+
|
|
219
|
+
if provider_bypass_enabled(record):
|
|
220
|
+
return AccessProfile.ELEVATED
|
|
221
|
+
if base is not None:
|
|
222
|
+
return base
|
|
223
|
+
return AccessProfile.WORKSPACE_WRITE
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def session_access_profile(records: Any, base: Any = None) -> Any:
|
|
227
|
+
"""The profile for a session that may be served by several providers.
|
|
228
|
+
|
|
229
|
+
A routed session can fall back from its primary provider to another one
|
|
230
|
+
mid-run, and the session's profile is fixed when the session is created.
|
|
231
|
+
The narrowest safe rule is therefore unanimity: the session is elevated
|
|
232
|
+
only when *every* provider that could serve it has Bypass ON. One
|
|
233
|
+
fallback route left in normal mode keeps the whole session protected
|
|
234
|
+
rather than quietly lending it elevated capabilities.
|
|
235
|
+
|
|
236
|
+
An empty set of records (direct ``--base-url`` mode, which persists no
|
|
237
|
+
provider record and therefore has nowhere to store the preference) is not
|
|
238
|
+
unanimous consent and keeps the normal profile.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
from .models import AccessProfile
|
|
242
|
+
|
|
243
|
+
items = list(records)
|
|
244
|
+
if items and all(provider_bypass_enabled(item) for item in items):
|
|
245
|
+
return AccessProfile.ELEVATED
|
|
246
|
+
if base is not None:
|
|
247
|
+
return base
|
|
248
|
+
return AccessProfile.WORKSPACE_WRITE
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Capability-first execution layer over the stable Extended Core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from .action_policy import (
|
|
8
|
+
ActionConfirmationRequired,
|
|
9
|
+
ActionDecisionEngine,
|
|
10
|
+
ActionDisposition,
|
|
11
|
+
ActionHardBlocked,
|
|
12
|
+
)
|
|
13
|
+
from .core_tools import ExtendedCoreRuntime
|
|
14
|
+
from .models import CoreCommand
|
|
15
|
+
from .risk_engine import ConfirmationRejected
|
|
16
|
+
from .risk_mapping import action_for_command
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CapabilityCoreRuntime(ExtendedCoreRuntime):
|
|
20
|
+
"""Extended Core with consequence-based action authorization."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*args: Any,
|
|
25
|
+
action_decisions: ActionDecisionEngine | None = None,
|
|
26
|
+
rollback_checkpoint_id: str | None = None,
|
|
27
|
+
checkpoint_factory: Callable[[], Optional[str]] | None = None,
|
|
28
|
+
bypass_mode: bool = False,
|
|
29
|
+
**kwargs: Any,
|
|
30
|
+
) -> None:
|
|
31
|
+
risk = kwargs.get("risk")
|
|
32
|
+
if action_decisions is not None:
|
|
33
|
+
if risk is None:
|
|
34
|
+
kwargs["risk"] = action_decisions.risk
|
|
35
|
+
elif risk is not action_decisions.risk:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"action decision engine and risk engine must share one ledger"
|
|
38
|
+
)
|
|
39
|
+
self._action_decisions = action_decisions
|
|
40
|
+
self._rollback_checkpoint_id = rollback_checkpoint_id
|
|
41
|
+
self._checkpoint_factory = checkpoint_factory
|
|
42
|
+
self._bypass_mode = bool(bypass_mode)
|
|
43
|
+
super().__init__(*args, **kwargs)
|
|
44
|
+
|
|
45
|
+
def _apply_smart_stop(self, command: CoreCommand) -> None:
|
|
46
|
+
engine = self._action_decisions
|
|
47
|
+
if engine is None:
|
|
48
|
+
super()._apply_smart_stop(command)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
if command.name == "repo.command":
|
|
52
|
+
payload = command.arguments.get("payload")
|
|
53
|
+
if isinstance(payload, dict) and payload.get("dry_run") is True:
|
|
54
|
+
# A dry run mutates nothing: it exists so the agent can inspect
|
|
55
|
+
# the impact of a batch before executing it. Keeping the
|
|
56
|
+
# preview free is what makes consequence-based decisions cheap.
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
bypass_mode = self._bypass_mode
|
|
60
|
+
try:
|
|
61
|
+
record = self.sessions.load(command.session_id)
|
|
62
|
+
# Hosted callers carry the active workstream objective out-of-band
|
|
63
|
+
# on CoreCommand. Native callers intentionally leave it empty and
|
|
64
|
+
# inherit the durable session task. This keeps scoped authority
|
|
65
|
+
# current without putting user intent into a tool payload.
|
|
66
|
+
user_intent = command.user_intent or record.task
|
|
67
|
+
# Checkpoints remain useful evidence/rollback support, but they no
|
|
68
|
+
# longer silently convert source deletion into ordinary autonomy.
|
|
69
|
+
reversible_by_checkpoint = bool(self._rollback_checkpoint_id)
|
|
70
|
+
except Exception:
|
|
71
|
+
user_intent = ""
|
|
72
|
+
reversible_by_checkpoint = False
|
|
73
|
+
action = action_for_command(
|
|
74
|
+
command,
|
|
75
|
+
repository=self.repository,
|
|
76
|
+
reversible_by_checkpoint=reversible_by_checkpoint,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
decision = engine.authorize(
|
|
81
|
+
action,
|
|
82
|
+
user_intent=user_intent,
|
|
83
|
+
confirmation_token=command.confirmation_token,
|
|
84
|
+
bypass_mode=bypass_mode,
|
|
85
|
+
)
|
|
86
|
+
except ActionConfirmationRequired as stop:
|
|
87
|
+
# Confirmation-worthy operations stay pending. Do not mutate state
|
|
88
|
+
# merely to try to dissolve the prompt: the agent can continue
|
|
89
|
+
# independent work and surface the deferred gate only when needed.
|
|
90
|
+
decision = stop.decision
|
|
91
|
+
self._publish_risk(
|
|
92
|
+
decision.assessment, allowed=False, reason="confirmation_required"
|
|
93
|
+
)
|
|
94
|
+
self._audit(
|
|
95
|
+
"core.command.stopped",
|
|
96
|
+
{
|
|
97
|
+
"session_id": command.session_id,
|
|
98
|
+
"origin": command.origin.key,
|
|
99
|
+
"command": command.name,
|
|
100
|
+
"correlation_id": command.correlation_id,
|
|
101
|
+
"risk": decision.assessment.level.value,
|
|
102
|
+
"disposition": decision.disposition.value,
|
|
103
|
+
"consequence": decision.consequence.value,
|
|
104
|
+
"reasons": list(decision.reasons),
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
raise
|
|
108
|
+
except ActionHardBlocked as blocked:
|
|
109
|
+
decision = blocked.decision
|
|
110
|
+
self._publish_risk(decision.assessment, allowed=False, reason="hard_block")
|
|
111
|
+
self._audit(
|
|
112
|
+
"core.command.hard_blocked",
|
|
113
|
+
{
|
|
114
|
+
"session_id": command.session_id,
|
|
115
|
+
"origin": command.origin.key,
|
|
116
|
+
"command": command.name,
|
|
117
|
+
"correlation_id": command.correlation_id,
|
|
118
|
+
"risk": decision.assessment.level.value,
|
|
119
|
+
"disposition": decision.disposition.value,
|
|
120
|
+
"consequence": decision.consequence.value,
|
|
121
|
+
"reasons": list(decision.reasons),
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
raise
|
|
125
|
+
except ConfirmationRejected as rejected:
|
|
126
|
+
decision = engine.decide(
|
|
127
|
+
action,
|
|
128
|
+
user_intent=user_intent,
|
|
129
|
+
bypass_mode=bypass_mode,
|
|
130
|
+
)
|
|
131
|
+
self._publish_risk(
|
|
132
|
+
decision.assessment, allowed=False, reason=rejected.reason
|
|
133
|
+
)
|
|
134
|
+
self._audit(
|
|
135
|
+
"core.command.confirmation_rejected",
|
|
136
|
+
{
|
|
137
|
+
"session_id": command.session_id,
|
|
138
|
+
"origin": command.origin.key,
|
|
139
|
+
"command": command.name,
|
|
140
|
+
"correlation_id": command.correlation_id,
|
|
141
|
+
"reason": rejected.reason,
|
|
142
|
+
"disposition": decision.disposition.value,
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
self._publish_risk(
|
|
148
|
+
decision.assessment,
|
|
149
|
+
allowed=True,
|
|
150
|
+
reason=decision.disposition.value,
|
|
151
|
+
)
|
|
152
|
+
if decision.disposition is ActionDisposition.GUARDED_AUTO:
|
|
153
|
+
self._audit(
|
|
154
|
+
"core.command.guarded_auto",
|
|
155
|
+
{
|
|
156
|
+
"session_id": command.session_id,
|
|
157
|
+
"origin": command.origin.key,
|
|
158
|
+
"command": command.name,
|
|
159
|
+
"correlation_id": command.correlation_id,
|
|
160
|
+
"risk": decision.assessment.level.value,
|
|
161
|
+
"consequence": decision.consequence.value,
|
|
162
|
+
"intent_authorized": decision.intent_authorized,
|
|
163
|
+
"reasons": list(decision.reasons),
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _auto_guard_with_checkpoint(
|
|
168
|
+
self,
|
|
169
|
+
command: CoreCommand,
|
|
170
|
+
stop: ActionConfirmationRequired,
|
|
171
|
+
user_intent: str,
|
|
172
|
+
):
|
|
173
|
+
"""Reversibility adapter: dissolve a deletion CONFIRM with a checkpoint.
|
|
174
|
+
|
|
175
|
+
When the only reason a repository-scoped deletion needs confirmation is
|
|
176
|
+
that no rollback guarantee exists yet, the runtime creates one on demand
|
|
177
|
+
and lets the decision engine re-evaluate honestly. Safety increases
|
|
178
|
+
autonomy instead of prompting. System, external, user-data and
|
|
179
|
+
out-of-repository boundaries are never widened: the engine re-decides
|
|
180
|
+
with the same rules and only a WORKSPACE deletion backed by a real
|
|
181
|
+
checkpoint becomes GUARDED_AUTO.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
engine = self._action_decisions
|
|
185
|
+
factory = self._checkpoint_factory
|
|
186
|
+
if engine is None or factory is None:
|
|
187
|
+
return None
|
|
188
|
+
if self._rollback_checkpoint_id:
|
|
189
|
+
# The current turn already has a checkpoint; the stop had a
|
|
190
|
+
# different cause that a second checkpoint cannot dissolve.
|
|
191
|
+
return None
|
|
192
|
+
decision = stop.decision
|
|
193
|
+
if "delete_not_scoped_by_user" not in decision.reasons:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
checkpoint_id = factory()
|
|
197
|
+
except Exception:
|
|
198
|
+
checkpoint_id = None
|
|
199
|
+
if not isinstance(checkpoint_id, str) or not checkpoint_id:
|
|
200
|
+
return None
|
|
201
|
+
self._rollback_checkpoint_id = checkpoint_id
|
|
202
|
+
guarded = action_for_command(
|
|
203
|
+
command,
|
|
204
|
+
repository=self.repository,
|
|
205
|
+
reversible_by_checkpoint=True,
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
recovered = engine.authorize(
|
|
209
|
+
guarded,
|
|
210
|
+
user_intent=user_intent,
|
|
211
|
+
confirmation_token=command.confirmation_token,
|
|
212
|
+
)
|
|
213
|
+
except (ActionConfirmationRequired, ActionHardBlocked):
|
|
214
|
+
return None
|
|
215
|
+
self._audit(
|
|
216
|
+
"core.command.auto_guarded_checkpoint",
|
|
217
|
+
{
|
|
218
|
+
"session_id": command.session_id,
|
|
219
|
+
"origin": command.origin.key,
|
|
220
|
+
"command": command.name,
|
|
221
|
+
"correlation_id": command.correlation_id,
|
|
222
|
+
"rollback_checkpoint_id": checkpoint_id,
|
|
223
|
+
"disposition": recovered.disposition.value,
|
|
224
|
+
"consequence": recovered.consequence.value,
|
|
225
|
+
"reasons": list(recovered.reasons),
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
return recovered
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
__all__ = ["CapabilityCoreRuntime"]
|