nexus-coding-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nexus/__init__.py +4 -0
- nexus/application/__init__.py +2 -0
- nexus/application/approval_service.py +130 -0
- nexus/application/diff_service.py +177 -0
- nexus/application/event_publisher.py +180 -0
- nexus/application/execution_context.py +29 -0
- nexus/application/execution_ledger.py +126 -0
- nexus/application/model_call_context.py +29 -0
- nexus/application/observed_model_gateway.py +194 -0
- nexus/application/plan_approval_service.py +250 -0
- nexus/application/planning.py +668 -0
- nexus/application/runtime.py +624 -0
- nexus/application/session_service.py +411 -0
- nexus/application/structured_output.py +96 -0
- nexus/application/telemetry.py +396 -0
- nexus/application/telemetry_subscriber.py +216 -0
- nexus/application/tool_runtime.py +496 -0
- nexus/application/tracing_dispatcher.py +283 -0
- nexus/application/validation.py +293 -0
- nexus/config/__init__.py +17 -0
- nexus/config/loader.py +253 -0
- nexus/config/models.py +310 -0
- nexus/context/__init__.py +6 -0
- nexus/context/budget.py +24 -0
- nexus/context/builder.py +92 -0
- nexus/context/chunking.py +65 -0
- nexus/context/explorer.py +246 -0
- nexus/context/integration.py +63 -0
- nexus/context/manager.py +328 -0
- nexus/context/retrieval.py +261 -0
- nexus/domain/__init__.py +2 -0
- nexus/domain/agent_decision.py +113 -0
- nexus/domain/agent_state.py +49 -0
- nexus/domain/approvals.py +64 -0
- nexus/domain/context.py +108 -0
- nexus/domain/exploration.py +86 -0
- nexus/domain/mcp.py +51 -0
- nexus/domain/model.py +173 -0
- nexus/domain/observability.py +326 -0
- nexus/domain/persistence.py +122 -0
- nexus/domain/planning.py +329 -0
- nexus/domain/ports/__init__.py +61 -0
- nexus/domain/ports/agent_decision.py +9 -0
- nexus/domain/ports/approval_unit_of_work.py +27 -0
- nexus/domain/ports/checkpoint_provider.py +11 -0
- nexus/domain/ports/context.py +71 -0
- nexus/domain/ports/events.py +21 -0
- nexus/domain/ports/graph_runtime.py +30 -0
- nexus/domain/ports/mcp.py +23 -0
- nexus/domain/ports/model_gateway.py +21 -0
- nexus/domain/ports/model_input_budget.py +15 -0
- nexus/domain/ports/planning.py +62 -0
- nexus/domain/ports/repositories.py +54 -0
- nexus/domain/ports/repository_context.py +18 -0
- nexus/domain/ports/session_unit_of_work.py +33 -0
- nexus/domain/ports/skills.py +41 -0
- nexus/domain/ports/tooling.py +39 -0
- nexus/domain/ports/tracer.py +13 -0
- nexus/domain/ports/validation.py +32 -0
- nexus/domain/runtime_events.py +445 -0
- nexus/domain/skills.py +129 -0
- nexus/domain/tooling.py +163 -0
- nexus/domain/validation.py +161 -0
- nexus/errors/__init__.py +82 -0
- nexus/evaluation/__init__.py +7 -0
- nexus/evaluation/assertions.py +4 -0
- nexus/evaluation/evaluator.py +263 -0
- nexus/evaluation/fixtures.py +36 -0
- nexus/evaluation/harness.py +606 -0
- nexus/evaluation/loader.py +415 -0
- nexus/evaluation/metrics.py +27 -0
- nexus/evaluation/models.py +362 -0
- nexus/evaluation/report.py +43 -0
- nexus/evaluation/runner.py +480 -0
- nexus/infrastructure/__init__.py +2 -0
- nexus/infrastructure/asyncio_compat.py +13 -0
- nexus/infrastructure/bootstrap/__init__.py +17 -0
- nexus/infrastructure/bootstrap/composition.py +663 -0
- nexus/infrastructure/checkpoint/__init__.py +5 -0
- nexus/infrastructure/checkpoint/postgres.py +62 -0
- nexus/infrastructure/database.py +39 -0
- nexus/infrastructure/embedding.py +61 -0
- nexus/infrastructure/graph/__init__.py +6 -0
- nexus/infrastructure/graph/day4_runtime.py +966 -0
- nexus/infrastructure/graph/langgraph_runtime.py +172 -0
- nexus/infrastructure/mcp/__init__.py +5 -0
- nexus/infrastructure/mcp/manager.py +380 -0
- nexus/infrastructure/model_gateway/__init__.py +6 -0
- nexus/infrastructure/model_gateway/openai_compatible.py +345 -0
- nexus/infrastructure/observability/__init__.py +7 -0
- nexus/infrastructure/observability/console.py +31 -0
- nexus/infrastructure/observability/langsmith.py +236 -0
- nexus/infrastructure/observability/structured_logger.py +70 -0
- nexus/infrastructure/persistence/__init__.py +13 -0
- nexus/infrastructure/persistence/approval_unit_of_work.py +74 -0
- nexus/infrastructure/persistence/models.py +146 -0
- nexus/infrastructure/persistence/repositories.py +326 -0
- nexus/infrastructure/persistence/unit_of_work.py +82 -0
- nexus/infrastructure/repository_files.py +117 -0
- nexus/infrastructure/sandbox/__init__.py +5 -0
- nexus/infrastructure/sandbox/local_process.py +353 -0
- nexus/infrastructure/semantic.py +276 -0
- nexus/infrastructure/semantic_models.py +56 -0
- nexus/interfaces/__init__.py +2 -0
- nexus/interfaces/cli/__init__.py +2 -0
- nexus/interfaces/cli/__main__.py +6 -0
- nexus/interfaces/cli/app.py +266 -0
- nexus/interfaces/cli/renderer.py +118 -0
- nexus/security/__init__.py +14 -0
- nexus/security/approval_policies.py +73 -0
- nexus/security/command_policy.py +117 -0
- nexus/security/executables.py +107 -0
- nexus/security/git_commands.py +65 -0
- nexus/security/workspace.py +93 -0
- nexus/skills/__init__.py +7 -0
- nexus/skills/builtin/__init__.py +1 -0
- nexus/skills/builtin/debug-python/SKILL.md +26 -0
- nexus/skills/builtin/review-repository/SKILL.md +26 -0
- nexus/skills/builtin/write-tests/SKILL.md +26 -0
- nexus/skills/loader.py +439 -0
- nexus/skills/registry.py +146 -0
- nexus/skills/selector.py +152 -0
- nexus/tools/__init__.py +6 -0
- nexus/tools/editing.py +312 -0
- nexus/tools/mcp.py +160 -0
- nexus/tools/native.py +654 -0
- nexus/tools/registry.py +27 -0
- nexus_coding_agent-0.1.0.dist-info/METADATA +142 -0
- nexus_coding_agent-0.1.0.dist-info/RECORD +131 -0
- nexus_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- nexus_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Single Nexus-owned instrumentation seam around every real model call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from nexus.application.execution_context import current_execution_context
|
|
12
|
+
from nexus.application.execution_ledger import ToolExecutionLedger
|
|
13
|
+
from nexus.application.model_call_context import current_model_call_phase
|
|
14
|
+
from nexus.domain.model import (
|
|
15
|
+
ModelCallPhase,
|
|
16
|
+
ModelChunk,
|
|
17
|
+
ModelMessage,
|
|
18
|
+
ModelResponse,
|
|
19
|
+
TokenUsage,
|
|
20
|
+
)
|
|
21
|
+
from nexus.domain.ports.model_gateway import ModelGateway
|
|
22
|
+
from nexus.domain.runtime_events import ModelCallFinished, ModelCallStarted, RuntimeEvent
|
|
23
|
+
from nexus.errors import NexusError
|
|
24
|
+
|
|
25
|
+
RuntimeEventEmitter = Callable[[RuntimeEvent], Awaitable[None]]
|
|
26
|
+
|
|
27
|
+
_LAST_MODEL_CALL_ID: ContextVar[str | None] = ContextVar(
|
|
28
|
+
"nexus_last_model_call_id", default=None
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ObservedModelGateway:
|
|
33
|
+
"""Emit safe model lifecycle events and own model counts and usage."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
gateway: ModelGateway,
|
|
38
|
+
*,
|
|
39
|
+
emit: RuntimeEventEmitter,
|
|
40
|
+
ledger: ToolExecutionLedger,
|
|
41
|
+
provider: str | None,
|
|
42
|
+
model: str | None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._gateway = gateway
|
|
45
|
+
self._emit = emit
|
|
46
|
+
self._ledger = ledger
|
|
47
|
+
self._provider = provider
|
|
48
|
+
self._model = model
|
|
49
|
+
|
|
50
|
+
def last_model_call_id(self) -> str:
|
|
51
|
+
model_call_id = _LAST_MODEL_CALL_ID.get()
|
|
52
|
+
if model_call_id is None:
|
|
53
|
+
raise RuntimeError("No model call completed in this async execution.")
|
|
54
|
+
return model_call_id
|
|
55
|
+
|
|
56
|
+
async def complete(self, messages: Sequence[ModelMessage]) -> ModelResponse:
|
|
57
|
+
context = current_execution_context()
|
|
58
|
+
phase = current_model_call_phase()
|
|
59
|
+
call_id = str(uuid4())
|
|
60
|
+
self._ledger.begin_model(context.run_id)
|
|
61
|
+
await self._emit_started(call_id, phase)
|
|
62
|
+
started = time.perf_counter()
|
|
63
|
+
try:
|
|
64
|
+
response = await self._gateway.complete(messages)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
usage = TokenUsage.unavailable()
|
|
67
|
+
self._ledger.record_model_usage(context.run_id, usage)
|
|
68
|
+
await self._emit_finished(
|
|
69
|
+
call_id,
|
|
70
|
+
phase,
|
|
71
|
+
success=False,
|
|
72
|
+
started=started,
|
|
73
|
+
usage=usage,
|
|
74
|
+
error_code=_safe_error_code(exc),
|
|
75
|
+
)
|
|
76
|
+
_LAST_MODEL_CALL_ID.set(call_id)
|
|
77
|
+
raise
|
|
78
|
+
usage = response.usage or TokenUsage.unavailable()
|
|
79
|
+
self._ledger.record_model_usage(context.run_id, usage)
|
|
80
|
+
await self._emit_finished(
|
|
81
|
+
call_id,
|
|
82
|
+
phase,
|
|
83
|
+
success=True,
|
|
84
|
+
started=started,
|
|
85
|
+
usage=usage,
|
|
86
|
+
error_code=None,
|
|
87
|
+
)
|
|
88
|
+
_LAST_MODEL_CALL_ID.set(call_id)
|
|
89
|
+
return response
|
|
90
|
+
|
|
91
|
+
async def stream(self, messages: Sequence[ModelMessage]) -> AsyncIterator[ModelChunk]:
|
|
92
|
+
context = current_execution_context()
|
|
93
|
+
phase = current_model_call_phase()
|
|
94
|
+
call_id = str(uuid4())
|
|
95
|
+
self._ledger.begin_model(context.run_id)
|
|
96
|
+
await self._emit_started(call_id, phase)
|
|
97
|
+
started = time.perf_counter()
|
|
98
|
+
usage: TokenUsage | None = None
|
|
99
|
+
try:
|
|
100
|
+
async for chunk in self._gateway.stream(messages):
|
|
101
|
+
if chunk.usage is not None:
|
|
102
|
+
usage = chunk.usage
|
|
103
|
+
yield chunk
|
|
104
|
+
except asyncio.CancelledError:
|
|
105
|
+
normalized = usage or TokenUsage.unavailable()
|
|
106
|
+
self._ledger.record_model_usage(context.run_id, normalized)
|
|
107
|
+
await self._emit_finished(
|
|
108
|
+
call_id,
|
|
109
|
+
phase,
|
|
110
|
+
success=False,
|
|
111
|
+
started=started,
|
|
112
|
+
usage=normalized,
|
|
113
|
+
error_code="MODEL_CALL_CANCELLED",
|
|
114
|
+
)
|
|
115
|
+
_LAST_MODEL_CALL_ID.set(call_id)
|
|
116
|
+
raise
|
|
117
|
+
except GeneratorExit:
|
|
118
|
+
normalized = usage or TokenUsage.unavailable()
|
|
119
|
+
self._ledger.record_model_usage(context.run_id, normalized)
|
|
120
|
+
await self._emit_finished(
|
|
121
|
+
call_id,
|
|
122
|
+
phase,
|
|
123
|
+
success=False,
|
|
124
|
+
started=started,
|
|
125
|
+
usage=normalized,
|
|
126
|
+
error_code="MODEL_STREAM_CLOSED",
|
|
127
|
+
)
|
|
128
|
+
_LAST_MODEL_CALL_ID.set(call_id)
|
|
129
|
+
raise
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
normalized = usage or TokenUsage.unavailable()
|
|
132
|
+
self._ledger.record_model_usage(context.run_id, normalized)
|
|
133
|
+
await self._emit_finished(
|
|
134
|
+
call_id,
|
|
135
|
+
phase,
|
|
136
|
+
success=False,
|
|
137
|
+
started=started,
|
|
138
|
+
usage=normalized,
|
|
139
|
+
error_code=_safe_error_code(exc),
|
|
140
|
+
)
|
|
141
|
+
_LAST_MODEL_CALL_ID.set(call_id)
|
|
142
|
+
raise
|
|
143
|
+
normalized = usage or TokenUsage.unavailable()
|
|
144
|
+
self._ledger.record_model_usage(context.run_id, normalized)
|
|
145
|
+
await self._emit_finished(
|
|
146
|
+
call_id,
|
|
147
|
+
phase,
|
|
148
|
+
success=True,
|
|
149
|
+
started=started,
|
|
150
|
+
usage=normalized,
|
|
151
|
+
error_code=None,
|
|
152
|
+
)
|
|
153
|
+
_LAST_MODEL_CALL_ID.set(call_id)
|
|
154
|
+
|
|
155
|
+
async def _emit_started(self, call_id: str, phase: ModelCallPhase) -> None:
|
|
156
|
+
context = current_execution_context()
|
|
157
|
+
await self._emit(
|
|
158
|
+
ModelCallStarted(
|
|
159
|
+
run_id=context.run_id,
|
|
160
|
+
session_id=context.session_id,
|
|
161
|
+
model_call_id=call_id,
|
|
162
|
+
phase=phase,
|
|
163
|
+
provider=self._provider,
|
|
164
|
+
model=self._model,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def _emit_finished(
|
|
169
|
+
self,
|
|
170
|
+
call_id: str,
|
|
171
|
+
phase: ModelCallPhase,
|
|
172
|
+
*,
|
|
173
|
+
success: bool,
|
|
174
|
+
started: float,
|
|
175
|
+
usage: TokenUsage,
|
|
176
|
+
error_code: str | None,
|
|
177
|
+
) -> None:
|
|
178
|
+
context = current_execution_context()
|
|
179
|
+
await self._emit(
|
|
180
|
+
ModelCallFinished(
|
|
181
|
+
run_id=context.run_id,
|
|
182
|
+
session_id=context.session_id,
|
|
183
|
+
model_call_id=call_id,
|
|
184
|
+
phase=phase,
|
|
185
|
+
success=success,
|
|
186
|
+
duration_ms=max(0, int((time.perf_counter() - started) * 1000)),
|
|
187
|
+
usage=usage,
|
|
188
|
+
error_code=error_code,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _safe_error_code(exc: Exception) -> str:
|
|
194
|
+
return exc.code if isinstance(exc, NexusError) else "MODEL_ERROR"
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Day 4 Plan approval persistence and evidence validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from uuid import UUID, uuid5
|
|
8
|
+
|
|
9
|
+
from nexus.domain.approvals import ApprovalRequest
|
|
10
|
+
from nexus.domain.planning import (
|
|
11
|
+
ApprovedPlanEvidence,
|
|
12
|
+
Plan,
|
|
13
|
+
PlanApprovalResumeInput,
|
|
14
|
+
PlanAuthorizationSource,
|
|
15
|
+
PlanStatus,
|
|
16
|
+
approval_summary,
|
|
17
|
+
derive_authorization_scope,
|
|
18
|
+
)
|
|
19
|
+
from nexus.domain.ports.approval_unit_of_work import (
|
|
20
|
+
ApprovalUnitOfWork,
|
|
21
|
+
ApprovalUnitOfWorkFactory,
|
|
22
|
+
)
|
|
23
|
+
from nexus.domain.tooling import ApprovalDecision, RiskLevel
|
|
24
|
+
from nexus.errors import PermissionDeniedError, ToolExecutionError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PlanApprovalService:
|
|
28
|
+
def __init__(self, unit_of_work_factory: ApprovalUnitOfWorkFactory) -> None:
|
|
29
|
+
self._unit_of_work_factory = unit_of_work_factory
|
|
30
|
+
|
|
31
|
+
async def create_pending(self, plan: Plan) -> ApprovalRequest:
|
|
32
|
+
self._validate_created_plan(plan)
|
|
33
|
+
approval = self._request(plan, auto=False)
|
|
34
|
+
return await self._persist_idempotent(approval)
|
|
35
|
+
|
|
36
|
+
async def create_auto_approved(self, plan: Plan) -> ApprovalRequest:
|
|
37
|
+
self._validate_created_plan(plan)
|
|
38
|
+
approval = self._request(plan, auto=True)
|
|
39
|
+
return await self._persist_idempotent(approval)
|
|
40
|
+
|
|
41
|
+
async def persist_user_decision(
|
|
42
|
+
self,
|
|
43
|
+
approval_id: str,
|
|
44
|
+
decision: PlanApprovalResumeInput,
|
|
45
|
+
) -> ApprovalRequest:
|
|
46
|
+
try:
|
|
47
|
+
async with self._unit_of_work_factory() as unit_of_work:
|
|
48
|
+
current = await unit_of_work.approvals.get(approval_id)
|
|
49
|
+
if current is None:
|
|
50
|
+
raise ToolExecutionError(
|
|
51
|
+
"The Plan approval does not exist.",
|
|
52
|
+
code="APPROVAL_NOT_FOUND",
|
|
53
|
+
)
|
|
54
|
+
await _validate_correlation(unit_of_work, current)
|
|
55
|
+
if current.operation != "approve_plan":
|
|
56
|
+
raise PermissionDeniedError(
|
|
57
|
+
"The approval does not authorize a Plan.",
|
|
58
|
+
code="PLAN_APPROVAL_REQUIRED",
|
|
59
|
+
)
|
|
60
|
+
if current.decision is not ApprovalDecision.PENDING:
|
|
61
|
+
if current.decision is decision.decision:
|
|
62
|
+
return current
|
|
63
|
+
raise ToolExecutionError(
|
|
64
|
+
"The Plan approval already has a terminal decision.",
|
|
65
|
+
code="INVALID_APPROVAL_TRANSITION",
|
|
66
|
+
)
|
|
67
|
+
updated = replace(
|
|
68
|
+
current,
|
|
69
|
+
decision=decision.decision,
|
|
70
|
+
actor="user",
|
|
71
|
+
reason=decision.reason,
|
|
72
|
+
decided_at=datetime.now(UTC),
|
|
73
|
+
)
|
|
74
|
+
await unit_of_work.approvals.update(updated)
|
|
75
|
+
return updated
|
|
76
|
+
except (PermissionDeniedError, ToolExecutionError):
|
|
77
|
+
raise
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
raise ToolExecutionError(
|
|
80
|
+
"Nexus could not persist the Plan approval decision.",
|
|
81
|
+
code="APPROVAL_PERSISTENCE_ERROR",
|
|
82
|
+
retryable=True,
|
|
83
|
+
) from exc
|
|
84
|
+
|
|
85
|
+
async def require_approved(
|
|
86
|
+
self,
|
|
87
|
+
evidence: ApprovedPlanEvidence,
|
|
88
|
+
) -> ApprovalRequest:
|
|
89
|
+
try:
|
|
90
|
+
async with self._unit_of_work_factory() as unit_of_work:
|
|
91
|
+
approval = await unit_of_work.approvals.get(evidence.approval_id)
|
|
92
|
+
if approval is None:
|
|
93
|
+
raise PermissionDeniedError(
|
|
94
|
+
"Approved Plan evidence was not found.",
|
|
95
|
+
code="PLAN_SCOPE_DENIED",
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
await _validate_correlation(unit_of_work, approval)
|
|
99
|
+
except PermissionDeniedError as exc:
|
|
100
|
+
raise PermissionDeniedError(
|
|
101
|
+
"Approved Plan correlation is invalid.",
|
|
102
|
+
code="PLAN_SCOPE_DENIED",
|
|
103
|
+
) from exc
|
|
104
|
+
expected = approval_summary(
|
|
105
|
+
evidence.plan_id,
|
|
106
|
+
evidence.plan_version,
|
|
107
|
+
evidence.scope_digest,
|
|
108
|
+
)
|
|
109
|
+
if (
|
|
110
|
+
approval.run_id != evidence.run_id
|
|
111
|
+
or approval.session_id != evidence.session_id
|
|
112
|
+
or approval.operation != "approve_plan"
|
|
113
|
+
or approval.risk_level is not RiskLevel.WRITE
|
|
114
|
+
or approval.resource_or_command_summary != expected
|
|
115
|
+
or approval.decision is not ApprovalDecision.APPROVED
|
|
116
|
+
or approval.actor not in {"user", "auto_policy"}
|
|
117
|
+
):
|
|
118
|
+
raise PermissionDeniedError(
|
|
119
|
+
"Approved Plan evidence does not match persisted approval.",
|
|
120
|
+
code="PLAN_SCOPE_DENIED",
|
|
121
|
+
)
|
|
122
|
+
return approval
|
|
123
|
+
except (PermissionDeniedError, ToolExecutionError):
|
|
124
|
+
raise
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
raise ToolExecutionError(
|
|
127
|
+
"Nexus could not validate approved Plan evidence.",
|
|
128
|
+
code="APPROVAL_PERSISTENCE_ERROR",
|
|
129
|
+
retryable=True,
|
|
130
|
+
) from exc
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def evidence(plan: Plan, approval: ApprovalRequest) -> ApprovedPlanEvidence:
|
|
134
|
+
if approval.decision is not ApprovalDecision.APPROVED or approval.decided_at is None:
|
|
135
|
+
raise PermissionDeniedError(
|
|
136
|
+
"The Plan has no approved evidence.",
|
|
137
|
+
code="PLAN_APPROVAL_REQUIRED",
|
|
138
|
+
)
|
|
139
|
+
return ApprovedPlanEvidence(
|
|
140
|
+
plan.plan_id,
|
|
141
|
+
plan.version,
|
|
142
|
+
plan.run_id,
|
|
143
|
+
plan.session_id,
|
|
144
|
+
PlanAuthorizationSource.AUTO_MODE
|
|
145
|
+
if approval.actor == "auto_policy"
|
|
146
|
+
else PlanAuthorizationSource.INTERACTIVE,
|
|
147
|
+
approval.approval_id,
|
|
148
|
+
plan.scope_digest,
|
|
149
|
+
plan.authorization_scope,
|
|
150
|
+
approval.decided_at,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def activate(plan: Plan, approval: ApprovalRequest) -> Plan:
|
|
155
|
+
if approval.decision is ApprovalDecision.APPROVED and approval.decided_at is not None:
|
|
156
|
+
return replace(
|
|
157
|
+
plan,
|
|
158
|
+
status=PlanStatus.ACTIVE,
|
|
159
|
+
approval_status=ApprovalDecision.APPROVED,
|
|
160
|
+
approval_id=approval.approval_id,
|
|
161
|
+
approved_at=approval.decided_at,
|
|
162
|
+
)
|
|
163
|
+
return replace(
|
|
164
|
+
plan,
|
|
165
|
+
status=PlanStatus.FAILED,
|
|
166
|
+
approval_status=ApprovalDecision.DENIED,
|
|
167
|
+
approval_id=approval.approval_id,
|
|
168
|
+
approved_at=approval.decided_at,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _validate_created_plan(plan: Plan) -> None:
|
|
173
|
+
if plan.status is not PlanStatus.CREATED:
|
|
174
|
+
raise ToolExecutionError(
|
|
175
|
+
"Only a CREATED Plan may enter approval.",
|
|
176
|
+
code="INVALID_PLAN_OUTPUT",
|
|
177
|
+
)
|
|
178
|
+
if plan.authorization_scope != derive_authorization_scope(plan.steps):
|
|
179
|
+
raise ToolExecutionError(
|
|
180
|
+
"Plan authorization exceeds visible Plan steps.",
|
|
181
|
+
code="INVALID_PLAN_OUTPUT",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _request(plan: Plan, *, auto: bool) -> ApprovalRequest:
|
|
186
|
+
now = datetime.now(UTC)
|
|
187
|
+
return ApprovalRequest(
|
|
188
|
+
approval_id=str(uuid5(UUID(plan.plan_id), f"approval:v{plan.version}")),
|
|
189
|
+
run_id=plan.run_id,
|
|
190
|
+
session_id=plan.session_id,
|
|
191
|
+
operation="approve_plan",
|
|
192
|
+
risk_level=RiskLevel.WRITE,
|
|
193
|
+
resource_or_command_summary=approval_summary(
|
|
194
|
+
plan.plan_id,
|
|
195
|
+
plan.version,
|
|
196
|
+
plan.scope_digest,
|
|
197
|
+
),
|
|
198
|
+
decision=ApprovalDecision.APPROVED if auto else ApprovalDecision.PENDING,
|
|
199
|
+
actor="auto_policy" if auto else "runtime",
|
|
200
|
+
reason="Approved by scoped Plan auto policy." if auto else None,
|
|
201
|
+
created_at=now,
|
|
202
|
+
decided_at=now if auto else None,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
async def _persist_idempotent(self, approval: ApprovalRequest) -> ApprovalRequest:
|
|
206
|
+
try:
|
|
207
|
+
async with self._unit_of_work_factory() as unit_of_work:
|
|
208
|
+
await _validate_correlation(unit_of_work, approval)
|
|
209
|
+
existing = await unit_of_work.approvals.get(approval.approval_id)
|
|
210
|
+
if existing is not None:
|
|
211
|
+
if _identity(existing) != _identity(approval):
|
|
212
|
+
raise PermissionDeniedError(
|
|
213
|
+
"Persisted Plan approval identity is inconsistent.",
|
|
214
|
+
code="PLAN_APPROVAL_REQUIRED",
|
|
215
|
+
)
|
|
216
|
+
return existing
|
|
217
|
+
await unit_of_work.approvals.add(approval)
|
|
218
|
+
return approval
|
|
219
|
+
except (PermissionDeniedError, ToolExecutionError):
|
|
220
|
+
raise
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
raise ToolExecutionError(
|
|
223
|
+
"Nexus could not persist the Plan approval.",
|
|
224
|
+
code="APPROVAL_PERSISTENCE_ERROR",
|
|
225
|
+
retryable=True,
|
|
226
|
+
) from exc
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _identity(approval: ApprovalRequest) -> tuple[object, ...]:
|
|
230
|
+
return (
|
|
231
|
+
approval.approval_id,
|
|
232
|
+
approval.run_id,
|
|
233
|
+
approval.session_id,
|
|
234
|
+
approval.operation,
|
|
235
|
+
approval.risk_level,
|
|
236
|
+
approval.resource_or_command_summary,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def _validate_correlation(
|
|
241
|
+
unit_of_work: ApprovalUnitOfWork,
|
|
242
|
+
approval: ApprovalRequest,
|
|
243
|
+
) -> None:
|
|
244
|
+
session = await unit_of_work.sessions.get(approval.session_id)
|
|
245
|
+
run = await unit_of_work.runs.get(approval.run_id)
|
|
246
|
+
if session is None or run is None or run.session_id != approval.session_id:
|
|
247
|
+
raise PermissionDeniedError(
|
|
248
|
+
"The Plan approval Run/Session correlation is invalid.",
|
|
249
|
+
code="APPROVAL_CORRELATION_DENIED",
|
|
250
|
+
)
|