lisa-harness-kernel 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.
- kernel/__init__.py +5 -0
- kernel/approval.py +417 -0
- kernel/capability.py +153 -0
- kernel/capability_binding.py +75 -0
- kernel/capability_manifest.py +232 -0
- kernel/config.py +475 -0
- kernel/content_roots.py +265 -0
- kernel/context/__init__.py +34 -0
- kernel/context/ambient.py +89 -0
- kernel/context/content_registry.py +33 -0
- kernel/context/context_budget.py +357 -0
- kernel/context/context_mapper.py +926 -0
- kernel/context/event_bus.py +58 -0
- kernel/context/extension_item.py +23 -0
- kernel/context/input_guard.py +25 -0
- kernel/context/mcp_catalog.py +514 -0
- kernel/context/mcp_content_registry.py +76 -0
- kernel/context/mcp_guard.py +132 -0
- kernel/context/model_policy.py +44 -0
- kernel/context/models.py +161 -0
- kernel/context/output_guard.py +25 -0
- kernel/context/provenance.py +181 -0
- kernel/context/request_budget.py +152 -0
- kernel/context/sub_turn_contract.py +211 -0
- kernel/context/tests/test_evidence_freshness_regression.py +37 -0
- kernel/context/tests/test_pack_scoped_items.py +135 -0
- kernel/context/tests/test_provenance.py +143 -0
- kernel/delegation.py +26 -0
- kernel/execution_ledger.py +118 -0
- kernel/execution_policy.py +120 -0
- kernel/extension_names.py +11 -0
- kernel/extension_runtime.py +32 -0
- kernel/function_tools.py +250 -0
- kernel/host_command_discovery.py +63 -0
- kernel/host_commands.py +65 -0
- kernel/intake/__init__.py +28 -0
- kernel/intake/connector_discovery.py +70 -0
- kernel/intake/connectors.py +129 -0
- kernel/intake/contracts.py +111 -0
- kernel/ipsar.py +85 -0
- kernel/ipsar_fsm.py +2357 -0
- kernel/mcp_launch/__init__.py +37 -0
- kernel/mcp_launch/compiler.py +304 -0
- kernel/mcp_launch/policy.py +427 -0
- kernel/mcp_launch/server_manifest.py +765 -0
- kernel/mcp_launch/tool_filter.py +27 -0
- kernel/model_budgets.py +233 -0
- kernel/model_manifest.py +681 -0
- kernel/model_router_config_discovery.py +89 -0
- kernel/model_router_discovery.py +105 -0
- kernel/models.py +2569 -0
- kernel/operations/__init__.py +1 -0
- kernel/operations/declarative.py +504 -0
- kernel/operations/registry.py +1082 -0
- kernel/operations/structured_output.py +381 -0
- kernel/provider_contracts.py +446 -0
- kernel/provider_gateway_discovery.py +87 -0
- kernel/proxy_launch/__init__.py +20 -0
- kernel/proxy_launch/compiler.py +213 -0
- kernel/proxy_launch/manifest.py +283 -0
- kernel/proxy_launch/policy.py +101 -0
- kernel/proxy_launch/pool.py +191 -0
- kernel/py.typed +0 -0
- kernel/recovery.py +135 -0
- kernel/routing_vocabulary.py +372 -0
- kernel/sandbox_floor.py +611 -0
- kernel/seams/__init__.py +7 -0
- kernel/seams/host_adapter.py +196 -0
- kernel/seams/protocols.py +466 -0
- kernel/session_plan.py +710 -0
- kernel/slice.py +296 -0
- kernel/slice_provider_discovery.py +107 -0
- kernel/state_providers.py +133 -0
- kernel/system_segments.py +146 -0
- kernel/telemetry.py +28 -0
- kernel/testing/__init__.py +1 -0
- kernel/testing/endpoint_registry.py +118 -0
- kernel/testing/extension_harness.py +200 -0
- kernel/testing/fixtures/prompts/sub_turn/budget_exhausted.md +3 -0
- kernel/testing/fixtures/prompts/sub_turn/delegate_contract.md +8 -0
- kernel/testing/fixtures/prompts/sub_turn/fork_contract.md +8 -0
- kernel/testing/fixtures/prompts/sub_turn/midpoint_write_nudge.md +6 -0
- kernel/testing/gateway_stub.py +145 -0
- kernel/testing/import_boundaries.py +63 -0
- kernel/testing/routing_vocabulary.py +45 -0
- kernel/testing/stubs.py +22 -0
- kernel/token_usage.py +302 -0
- kernel/tools/__init__.py +1 -0
- kernel/tools/mcp_client.py +2422 -0
- kernel/tools/output_stream.py +91 -0
- kernel/tools/registry.py +607 -0
- kernel/tools/schema_translator.py +282 -0
- kernel/tools/synthetic.py +113 -0
- kernel/turn_continuation.py +118 -0
- kernel/turn_hooks.py +388 -0
- kernel/turn_phases/__init__.py +4 -0
- kernel/turn_phases/_child_turn.py +442 -0
- kernel/turn_phases/_completion_contract.py +301 -0
- kernel/turn_phases/_dispatch.py +870 -0
- kernel/turn_phases/_entry.py +251 -0
- kernel/turn_phases/_fork.py +496 -0
- kernel/turn_phases/_lifecycle.py +1040 -0
- kernel/turn_phases/_post_turn.py +356 -0
- kernel/turn_phases/_routing.py +282 -0
- kernel/turn_phases/_routing_hint.py +1502 -0
- kernel/turn_phases/_tool_schemas.py +425 -0
- kernel/turn_phases/_turn_state.py +906 -0
- kernel/turn_runner/__init__.py +10374 -0
- kernel/turn_runner/_defaults.py +497 -0
- kernel/turn_runner/_helpers.py +222 -0
- kernel/turn_runner/_result.py +156 -0
- kernel/turn_runner/_seams.py +109 -0
- kernel/turn_runner/_streaming.py +66 -0
- kernel/workflow_position.py +593 -0
- kernel/workflow_view.py +524 -0
- lisa_harness_kernel-0.1.0.dist-info/METADATA +22 -0
- lisa_harness_kernel-0.1.0.dist-info/RECORD +118 -0
- lisa_harness_kernel-0.1.0.dist-info/WHEEL +4 -0
kernel/__init__.py
ADDED
kernel/approval.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Exact-action approval contracts for catalog-backed effects.
|
|
2
|
+
|
|
3
|
+
The kernel owns digest construction and receipt validation. Hosts own durable
|
|
4
|
+
storage and approval UX through the :class:`ApprovalService` protocol.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ApprovalDecision",
|
|
21
|
+
"ApprovalIdentity",
|
|
22
|
+
"ApprovalInvocation",
|
|
23
|
+
"ApprovalReceipt",
|
|
24
|
+
"ApprovalRecord",
|
|
25
|
+
"ApprovalRequired",
|
|
26
|
+
"ApprovalService",
|
|
27
|
+
"ApprovalState",
|
|
28
|
+
"EffectKind",
|
|
29
|
+
"InMemoryApprovalService",
|
|
30
|
+
"build_approval_invocation",
|
|
31
|
+
"canonical_json",
|
|
32
|
+
"is_expired",
|
|
33
|
+
"render_approval_request",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
EffectKind = Literal["read", "write", "execute", "external_write"]
|
|
37
|
+
ApprovalState = Literal["pending", "approved", "denied", "consumed", "expired"]
|
|
38
|
+
|
|
39
|
+
_DEFAULT_APPROVAL_TTL = timedelta(minutes=30)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _FrozenApprovalModel(BaseModel):
|
|
43
|
+
"""Base contract for immutable approval values."""
|
|
44
|
+
|
|
45
|
+
model_config = ConfigDict(frozen=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ApprovalIdentity(_FrozenApprovalModel):
|
|
49
|
+
"""Identity and session boundaries that scope an approval receipt."""
|
|
50
|
+
|
|
51
|
+
workspace_id: str = "default"
|
|
52
|
+
user_id: str = ""
|
|
53
|
+
user_slug: str = "default"
|
|
54
|
+
surface_type: str = "personal_dm"
|
|
55
|
+
surface_id: str = ""
|
|
56
|
+
memory_scope: str = "personal"
|
|
57
|
+
mode_id: str = ""
|
|
58
|
+
session_plan_digest: str = ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ApprovalInvocation(_FrozenApprovalModel):
|
|
62
|
+
"""A canonical effectful invocation that can be approved exactly once."""
|
|
63
|
+
|
|
64
|
+
request_id: str
|
|
65
|
+
target_kind: Literal["tool", "operation"]
|
|
66
|
+
target_id: str
|
|
67
|
+
pack_id: str = ""
|
|
68
|
+
effect: EffectKind
|
|
69
|
+
parameters: dict[str, Any] = Field(default_factory=dict)
|
|
70
|
+
identity: ApprovalIdentity
|
|
71
|
+
pack_config_fingerprint: str
|
|
72
|
+
session_plan_digest: str
|
|
73
|
+
action_digest: str
|
|
74
|
+
safe_preview: str
|
|
75
|
+
created_at: datetime
|
|
76
|
+
expires_at: datetime
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ApprovalReceipt(_FrozenApprovalModel):
|
|
80
|
+
"""Evidence that one exact pending invocation was approved."""
|
|
81
|
+
|
|
82
|
+
request_id: str
|
|
83
|
+
action_digest: str
|
|
84
|
+
identity: ApprovalIdentity
|
|
85
|
+
approved_at: datetime
|
|
86
|
+
expires_at: datetime
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ApprovalDecision(_FrozenApprovalModel):
|
|
90
|
+
"""An operator decision recorded against an approval request."""
|
|
91
|
+
|
|
92
|
+
request_id: str
|
|
93
|
+
state: ApprovalState
|
|
94
|
+
decided_at: datetime
|
|
95
|
+
identity: ApprovalIdentity
|
|
96
|
+
reason: str = ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ApprovalRecord(BaseModel):
|
|
100
|
+
"""Durable state for one requested action, owned by a host service."""
|
|
101
|
+
|
|
102
|
+
invocation: ApprovalInvocation
|
|
103
|
+
state: ApprovalState = "pending"
|
|
104
|
+
receipt: ApprovalReceipt | None = None
|
|
105
|
+
decision: ApprovalDecision | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ApprovalUnavailable(RuntimeError):
|
|
109
|
+
"""The receipt failed authoritative consumption; do not continue its saved batch."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ApprovalRequired(RuntimeError):
|
|
113
|
+
"""Interrupt execution until the host has approved the exact action."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, invocation: ApprovalInvocation) -> None:
|
|
116
|
+
self.invocation = invocation
|
|
117
|
+
super().__init__(f"Approval required: {invocation.request_id}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@runtime_checkable
|
|
121
|
+
class ApprovalService(Protocol):
|
|
122
|
+
"""Host-owned durable approval service used by execution registries."""
|
|
123
|
+
|
|
124
|
+
async def request(self, invocation: ApprovalInvocation) -> ApprovalInvocation:
|
|
125
|
+
"""Persist a new pending invocation and return it."""
|
|
126
|
+
|
|
127
|
+
async def approve(
|
|
128
|
+
self,
|
|
129
|
+
request_id: str,
|
|
130
|
+
identity: ApprovalIdentity,
|
|
131
|
+
) -> ApprovalReceipt | None:
|
|
132
|
+
"""Approve a matching pending invocation, or return ``None``."""
|
|
133
|
+
|
|
134
|
+
async def deny(
|
|
135
|
+
self,
|
|
136
|
+
request_id: str,
|
|
137
|
+
identity: ApprovalIdentity,
|
|
138
|
+
*,
|
|
139
|
+
reason: str = "",
|
|
140
|
+
) -> bool:
|
|
141
|
+
"""Deny a matching pending invocation."""
|
|
142
|
+
|
|
143
|
+
async def consume(
|
|
144
|
+
self,
|
|
145
|
+
request_id: str,
|
|
146
|
+
invocation: ApprovalInvocation,
|
|
147
|
+
) -> ApprovalReceipt | None:
|
|
148
|
+
"""Atomically consume a matching approved invocation once."""
|
|
149
|
+
|
|
150
|
+
async def get(self, request_id: str) -> ApprovalRecord | None:
|
|
151
|
+
"""Return the persisted request state without changing it."""
|
|
152
|
+
|
|
153
|
+
async def list_pending(self, identity: ApprovalIdentity) -> list[ApprovalRecord]:
|
|
154
|
+
"""Return non-expired pending records owned by ``identity``.
|
|
155
|
+
|
|
156
|
+
Hosts use this to detect affirmative-resume conditions (e.g. the
|
|
157
|
+
user typing "proceed" while a paused action awaits approval) and
|
|
158
|
+
to surface pending approvals in UIs. The identity match is exact.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _find_pending_for_action(
|
|
163
|
+
records: Mapping[str, ApprovalRecord],
|
|
164
|
+
invocation: ApprovalInvocation,
|
|
165
|
+
*,
|
|
166
|
+
now: datetime,
|
|
167
|
+
) -> ApprovalRecord | None:
|
|
168
|
+
"""Return a non-expired pending record matching action_digest + identity.
|
|
169
|
+
|
|
170
|
+
The exact-action contract approves one canonical invocation once. When
|
|
171
|
+
a caller re-issues the same effectful action (same target, parameters,
|
|
172
|
+
pack config fingerprint, and owner identity) before the user has
|
|
173
|
+
decided, the service must not mint a fresh ``request_id`` — that would
|
|
174
|
+
strand the original pending request and force the user to re-approve a
|
|
175
|
+
new id on every retry. Returning the existing pending record preserves
|
|
176
|
+
the id the user already saw so a prior or future ``/approve <id>`` still
|
|
177
|
+
satisfies it. Only the ``pending`` state is idempotent; once approved or
|
|
178
|
+
consumed, a genuinely new request mints a new id.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
for record in records.values():
|
|
182
|
+
if record.state != "pending":
|
|
183
|
+
continue
|
|
184
|
+
if is_expired(record.invocation, now=now):
|
|
185
|
+
continue
|
|
186
|
+
if record.invocation.action_digest == invocation.action_digest and _identity_matches(
|
|
187
|
+
record.invocation.identity, invocation.identity
|
|
188
|
+
):
|
|
189
|
+
return record
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _canonicalize(value: Any) -> Any:
|
|
194
|
+
"""Normalize supported JSON-like values into stable, safe digest input."""
|
|
195
|
+
|
|
196
|
+
if isinstance(value, BaseModel):
|
|
197
|
+
return _canonicalize(value.model_dump(mode="json"))
|
|
198
|
+
if isinstance(value, Mapping):
|
|
199
|
+
return {str(key): _canonicalize(item) for key, item in sorted(value.items())}
|
|
200
|
+
if isinstance(value, list | tuple):
|
|
201
|
+
return [_canonicalize(item) for item in value]
|
|
202
|
+
if isinstance(value, set | frozenset):
|
|
203
|
+
normalized = [_canonicalize(item) for item in value]
|
|
204
|
+
return sorted(normalized, key=lambda item: json.dumps(item, sort_keys=True))
|
|
205
|
+
return _canonicalize_scalar(value)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _canonicalize_scalar(value: Any) -> Any:
|
|
209
|
+
"""Normalize a scalar leaf (datetime / None / str / int / float / bool)."""
|
|
210
|
+
if isinstance(value, datetime):
|
|
211
|
+
return value.astimezone(UTC).isoformat()
|
|
212
|
+
if value is None or isinstance(value, str | int | float | bool):
|
|
213
|
+
return value
|
|
214
|
+
return str(value)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def canonical_json(value: Any) -> str:
|
|
218
|
+
"""Return a deterministic JSON representation suitable for SHA-256 input."""
|
|
219
|
+
|
|
220
|
+
return json.dumps(
|
|
221
|
+
_canonicalize(value),
|
|
222
|
+
ensure_ascii=True,
|
|
223
|
+
sort_keys=True,
|
|
224
|
+
separators=(",", ":"),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _digest(value: Any) -> str:
|
|
229
|
+
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def build_approval_invocation(
|
|
233
|
+
*,
|
|
234
|
+
target_kind: Literal["tool", "operation"],
|
|
235
|
+
target_id: str,
|
|
236
|
+
pack_id: str,
|
|
237
|
+
effect: EffectKind,
|
|
238
|
+
parameters: Mapping[str, Any],
|
|
239
|
+
identity: ApprovalIdentity,
|
|
240
|
+
pack_config: Mapping[str, Any],
|
|
241
|
+
safe_preview: str = "",
|
|
242
|
+
now: datetime | None = None,
|
|
243
|
+
ttl: timedelta = _DEFAULT_APPROVAL_TTL,
|
|
244
|
+
) -> ApprovalInvocation:
|
|
245
|
+
"""Build an exact-action request from canonical validated inputs."""
|
|
246
|
+
|
|
247
|
+
created_at = now or datetime.now(UTC)
|
|
248
|
+
normalized_parameters = _canonicalize(parameters)
|
|
249
|
+
if not isinstance(normalized_parameters, dict):
|
|
250
|
+
normalized_parameters = {}
|
|
251
|
+
config_fingerprint = _digest(pack_config)
|
|
252
|
+
action_payload = {
|
|
253
|
+
"target_kind": target_kind,
|
|
254
|
+
"target_id": target_id,
|
|
255
|
+
"pack_id": pack_id,
|
|
256
|
+
"effect": effect,
|
|
257
|
+
"parameters": normalized_parameters,
|
|
258
|
+
"identity": identity,
|
|
259
|
+
"pack_config_fingerprint": config_fingerprint,
|
|
260
|
+
"session_plan_digest": identity.session_plan_digest,
|
|
261
|
+
}
|
|
262
|
+
return ApprovalInvocation(
|
|
263
|
+
request_id=uuid.uuid4().hex,
|
|
264
|
+
target_kind=target_kind,
|
|
265
|
+
target_id=target_id,
|
|
266
|
+
pack_id=pack_id,
|
|
267
|
+
effect=effect,
|
|
268
|
+
parameters=normalized_parameters,
|
|
269
|
+
identity=identity,
|
|
270
|
+
pack_config_fingerprint=config_fingerprint,
|
|
271
|
+
session_plan_digest=identity.session_plan_digest,
|
|
272
|
+
action_digest=_digest(action_payload),
|
|
273
|
+
safe_preview=safe_preview.strip() or f"{effect.replace('_', ' ')} {target_id}",
|
|
274
|
+
created_at=created_at,
|
|
275
|
+
expires_at=created_at + ttl,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def is_expired(invocation: ApprovalInvocation, *, now: datetime | None = None) -> bool:
|
|
280
|
+
"""Return whether an approval request has passed its expiry time."""
|
|
281
|
+
|
|
282
|
+
return (now or datetime.now(UTC)) >= invocation.expires_at
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def render_approval_request(invocation: ApprovalInvocation) -> str:
|
|
286
|
+
"""Render the host-neutral user prompt for a paused action.
|
|
287
|
+
|
|
288
|
+
The kernel states the situation without command syntax: which action is
|
|
289
|
+
paused and that an operator decision is pending. Hosts own the *how* —
|
|
290
|
+
the runtime builds the structured ``data["approval"]`` payload (with
|
|
291
|
+
``request_id``) for panel transports and appends the installed command
|
|
292
|
+
hints to the text for plain transports.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
f"Approval required for {invocation.safe_preview}.\n\n"
|
|
297
|
+
f"This action is paused until you approve or deny it "
|
|
298
|
+
f"(request {invocation.request_id})."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _identity_matches(expected: ApprovalIdentity, actual: ApprovalIdentity) -> bool:
|
|
303
|
+
return expected == actual
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class InMemoryApprovalService:
|
|
307
|
+
"""Lock-protected approval service used by kernel tests and lightweight hosts."""
|
|
308
|
+
|
|
309
|
+
def __init__(self) -> None:
|
|
310
|
+
self._records: dict[str, ApprovalRecord] = {}
|
|
311
|
+
self._lock = asyncio.Lock()
|
|
312
|
+
|
|
313
|
+
async def request(self, invocation: ApprovalInvocation) -> ApprovalInvocation:
|
|
314
|
+
async with self._lock:
|
|
315
|
+
existing = _find_pending_for_action(self._records, invocation, now=datetime.now(UTC))
|
|
316
|
+
if existing is not None:
|
|
317
|
+
return existing.invocation
|
|
318
|
+
self._records.setdefault(invocation.request_id, ApprovalRecord(invocation=invocation))
|
|
319
|
+
return self._records[invocation.request_id].invocation
|
|
320
|
+
|
|
321
|
+
async def approve(
|
|
322
|
+
self,
|
|
323
|
+
request_id: str,
|
|
324
|
+
identity: ApprovalIdentity,
|
|
325
|
+
) -> ApprovalReceipt | None:
|
|
326
|
+
async with self._lock:
|
|
327
|
+
record = self._records.get(request_id)
|
|
328
|
+
if record is None or not _identity_matches(record.invocation.identity, identity):
|
|
329
|
+
return None
|
|
330
|
+
if is_expired(record.invocation):
|
|
331
|
+
record.state = "expired"
|
|
332
|
+
return None
|
|
333
|
+
if record.state != "pending":
|
|
334
|
+
return None
|
|
335
|
+
receipt = ApprovalReceipt(
|
|
336
|
+
request_id=request_id,
|
|
337
|
+
action_digest=record.invocation.action_digest,
|
|
338
|
+
identity=identity,
|
|
339
|
+
approved_at=datetime.now(UTC),
|
|
340
|
+
expires_at=record.invocation.expires_at,
|
|
341
|
+
)
|
|
342
|
+
record.state = "approved"
|
|
343
|
+
record.receipt = receipt
|
|
344
|
+
record.decision = ApprovalDecision(
|
|
345
|
+
request_id=request_id,
|
|
346
|
+
state="approved",
|
|
347
|
+
decided_at=receipt.approved_at,
|
|
348
|
+
identity=identity,
|
|
349
|
+
)
|
|
350
|
+
return receipt
|
|
351
|
+
|
|
352
|
+
async def deny(
|
|
353
|
+
self,
|
|
354
|
+
request_id: str,
|
|
355
|
+
identity: ApprovalIdentity,
|
|
356
|
+
*,
|
|
357
|
+
reason: str = "",
|
|
358
|
+
) -> bool:
|
|
359
|
+
async with self._lock:
|
|
360
|
+
record = self._records.get(request_id)
|
|
361
|
+
if record is None or not _identity_matches(record.invocation.identity, identity):
|
|
362
|
+
return False
|
|
363
|
+
if record.state != "pending":
|
|
364
|
+
return False
|
|
365
|
+
record.state = "denied"
|
|
366
|
+
record.decision = ApprovalDecision(
|
|
367
|
+
request_id=request_id,
|
|
368
|
+
state="denied",
|
|
369
|
+
decided_at=datetime.now(UTC),
|
|
370
|
+
identity=identity,
|
|
371
|
+
reason=reason.strip(),
|
|
372
|
+
)
|
|
373
|
+
return True
|
|
374
|
+
|
|
375
|
+
async def consume(
|
|
376
|
+
self,
|
|
377
|
+
request_id: str,
|
|
378
|
+
invocation: ApprovalInvocation,
|
|
379
|
+
) -> ApprovalReceipt | None:
|
|
380
|
+
async with self._lock:
|
|
381
|
+
record = self._records.get(request_id)
|
|
382
|
+
if record is None:
|
|
383
|
+
return None
|
|
384
|
+
if is_expired(record.invocation):
|
|
385
|
+
record.state = "expired"
|
|
386
|
+
return None
|
|
387
|
+
if (
|
|
388
|
+
record.state != "approved"
|
|
389
|
+
or record.receipt is None
|
|
390
|
+
or record.receipt.action_digest != invocation.action_digest
|
|
391
|
+
or not _identity_matches(record.invocation.identity, invocation.identity)
|
|
392
|
+
):
|
|
393
|
+
return None
|
|
394
|
+
receipt = record.receipt
|
|
395
|
+
record.state = "consumed"
|
|
396
|
+
record.decision = ApprovalDecision(
|
|
397
|
+
request_id=request_id,
|
|
398
|
+
state="consumed",
|
|
399
|
+
decided_at=datetime.now(UTC),
|
|
400
|
+
identity=invocation.identity,
|
|
401
|
+
)
|
|
402
|
+
return receipt
|
|
403
|
+
|
|
404
|
+
async def get(self, request_id: str) -> ApprovalRecord | None:
|
|
405
|
+
async with self._lock:
|
|
406
|
+
return self._records.get(request_id)
|
|
407
|
+
|
|
408
|
+
async def list_pending(self, identity: ApprovalIdentity) -> list[ApprovalRecord]:
|
|
409
|
+
async with self._lock:
|
|
410
|
+
now = datetime.now(UTC)
|
|
411
|
+
return [
|
|
412
|
+
record
|
|
413
|
+
for record in self._records.values()
|
|
414
|
+
if record.state == "pending"
|
|
415
|
+
and not is_expired(record.invocation, now=now)
|
|
416
|
+
and _identity_matches(record.invocation.identity, identity)
|
|
417
|
+
]
|
kernel/capability.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Single resolution layer for capability ids across operation and tool catalogs.
|
|
2
|
+
|
|
3
|
+
The kernel resolves a capability id (``operation_id`` or ``tool_id``) against
|
|
4
|
+
two in-process catalogs: ``get_operation_catalog()`` and ``get_tool_catalog()``.
|
|
5
|
+
Before this module existed, the resolution pattern
|
|
6
|
+
``operation_catalog.get(x) or tool_catalog.get(x)`` was copy-pasted at every
|
|
7
|
+
call site (``ipsar_fsm``, ``turn_runner``, the operation registry's schema
|
|
8
|
+
builder). That made resolution-order changes fragile and left no single place
|
|
9
|
+
to add cross-cutting concerns (telemetry, normalized keying, audit).
|
|
10
|
+
|
|
11
|
+
This module owns the canonical resolution contract:
|
|
12
|
+
|
|
13
|
+
* ``resolve_capability(id)`` returns the first matching definition, with
|
|
14
|
+
operations taking precedence over tools on id collision. This matches the
|
|
15
|
+
order every existing call site used, so behavior is unchanged.
|
|
16
|
+
* ``resolve_operation_definition`` / ``resolve_tool_definition`` are the
|
|
17
|
+
per-catalog entry points for callers that know which catalog they want.
|
|
18
|
+
* ``resolve_capability_for_turn_limit`` is the dedicated entry point for
|
|
19
|
+
per-turn ``max_calls_per_turn`` enforcement, which previously inlined the
|
|
20
|
+
dual-catalog fallback with a ``getattr(..., "max_calls_per_turn", 0)``
|
|
21
|
+
tail at four sites.
|
|
22
|
+
|
|
23
|
+
Internal pack tools are keyed **bare** in the tool catalog
|
|
24
|
+
(e.g. ``web_search``). MCP-native tools are keyed **namespaced**
|
|
25
|
+
(``server_id.tool``, e.g. ``lisa-storage.read_model_manifest``); the MCP
|
|
26
|
+
client pool owns that namespace bridging separately (see
|
|
27
|
+
``kernel.tools.mcp_client``). This module resolves against the kernel
|
|
28
|
+
catalog only — the MCP pool's namespaced index is not consulted here, by
|
|
29
|
+
design, so resolution never spawns a subprocess.
|
|
30
|
+
|
|
31
|
+
Called by: kernel.ipsar_fsm (lifecycle target dispatch)
|
|
32
|
+
kernel.turn_runner (tool-call dispatch, per-turn cap enforcement)
|
|
33
|
+
kernel.operations.registry (schema building)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from kernel.models import OperationDefinition, ToolDefinition
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"CapabilityDefinition",
|
|
42
|
+
"resolve_capability",
|
|
43
|
+
"resolve_capability_for_turn_limit",
|
|
44
|
+
"resolve_operation_definition",
|
|
45
|
+
"resolve_tool_definition",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
CapabilityDefinition = OperationDefinition | ToolDefinition
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_operation_definition(
|
|
52
|
+
capability_id: str,
|
|
53
|
+
*,
|
|
54
|
+
operation_catalog: dict[str, OperationDefinition] | None = None,
|
|
55
|
+
) -> OperationDefinition | None:
|
|
56
|
+
"""Look up one operation id in the operation catalog.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
capability_id: The bare ``operation_id`` to resolve.
|
|
60
|
+
operation_catalog: Optional pre-resolved catalog. When omitted, the
|
|
61
|
+
kernel's ``get_operation_catalog()`` is used.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The matching ``OperationDefinition`` or ``None``.
|
|
65
|
+
"""
|
|
66
|
+
catalog = operation_catalog if operation_catalog is not None else _default_operation_catalog()
|
|
67
|
+
return catalog.get(capability_id)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_tool_definition(
|
|
71
|
+
capability_id: str,
|
|
72
|
+
*,
|
|
73
|
+
tool_catalog: dict[str, ToolDefinition] | None = None,
|
|
74
|
+
) -> ToolDefinition | None:
|
|
75
|
+
"""Look up one tool id in the tool catalog.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
capability_id: The tool id to resolve. Internal pack tools are keyed
|
|
79
|
+
bare (e.g. ``web_search``); MCP-native tools are keyed namespaced
|
|
80
|
+
(e.g. ``lisa-storage.read_model_manifest``) but are owned by the
|
|
81
|
+
MCP client pool, not the kernel tool catalog.
|
|
82
|
+
tool_catalog: Optional pre-resolved catalog. When omitted, the
|
|
83
|
+
kernel's ``get_tool_catalog()`` is used.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The matching ``ToolDefinition`` or ``None``.
|
|
87
|
+
"""
|
|
88
|
+
catalog = tool_catalog if tool_catalog is not None else _default_tool_catalog()
|
|
89
|
+
return catalog.get(capability_id)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_capability(
|
|
93
|
+
capability_id: str,
|
|
94
|
+
*,
|
|
95
|
+
operation_catalog: dict[str, OperationDefinition] | None = None,
|
|
96
|
+
tool_catalog: dict[str, ToolDefinition] | None = None,
|
|
97
|
+
) -> CapabilityDefinition | None:
|
|
98
|
+
"""Resolve a capability id against operations first, then tools.
|
|
99
|
+
|
|
100
|
+
On id collision, operations take precedence over tools. This matches the
|
|
101
|
+
order every existing inlined call site used
|
|
102
|
+
(``operation_catalog.get(x) or tool_catalog.get(x)``), so behavior is
|
|
103
|
+
unchanged. Callers that need a specific catalog should use
|
|
104
|
+
``resolve_operation_definition`` or ``resolve_tool_definition`` instead.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
capability_id: The ``operation_id`` or ``tool_id`` to resolve.
|
|
108
|
+
operation_catalog: Optional pre-resolved operation catalog.
|
|
109
|
+
tool_catalog: Optional pre-resolved tool catalog.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The matching ``OperationDefinition`` or ``ToolDefinition``, or
|
|
113
|
+
``None`` when the id is unknown to both catalogs.
|
|
114
|
+
"""
|
|
115
|
+
op = resolve_operation_definition(capability_id, operation_catalog=operation_catalog)
|
|
116
|
+
if op is not None:
|
|
117
|
+
return op
|
|
118
|
+
return resolve_tool_definition(capability_id, tool_catalog=tool_catalog)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve_capability_for_turn_limit(
|
|
122
|
+
capability_id: str,
|
|
123
|
+
*,
|
|
124
|
+
operation_catalog: dict[str, OperationDefinition] | None = None,
|
|
125
|
+
tool_catalog: dict[str, ToolDefinition] | None = None,
|
|
126
|
+
) -> CapabilityDefinition | None:
|
|
127
|
+
"""Resolve a capability id for per-turn ``max_calls_per_turn`` enforcement.
|
|
128
|
+
|
|
129
|
+
Same precedence as ``resolve_capability`` (operations before tools). Both
|
|
130
|
+
definition types expose ``max_calls_per_turn``; callers apply
|
|
131
|
+
``getattr(definition, "max_calls_per_turn", 0)`` on the result. MCP-native
|
|
132
|
+
tools have no kernel ``ToolDefinition`` and are bounded only by the global
|
|
133
|
+
iteration count, so they correctly resolve to ``None`` here.
|
|
134
|
+
"""
|
|
135
|
+
return resolve_capability(
|
|
136
|
+
capability_id,
|
|
137
|
+
operation_catalog=operation_catalog,
|
|
138
|
+
tool_catalog=tool_catalog,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _default_operation_catalog() -> dict[str, OperationDefinition]:
|
|
143
|
+
"""Return the kernel operation catalog, importing lazily to avoid cycles."""
|
|
144
|
+
from kernel.operations.registry import get_operation_catalog
|
|
145
|
+
|
|
146
|
+
return get_operation_catalog()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _default_tool_catalog() -> dict[str, ToolDefinition]:
|
|
150
|
+
"""Return the kernel tool catalog, importing lazily to avoid cycles."""
|
|
151
|
+
from kernel.tools.registry import get_tool_catalog
|
|
152
|
+
|
|
153
|
+
return get_tool_catalog()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Execution-time bindings for catalog capabilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
CapabilityKind = Literal["operation", "tool"]
|
|
11
|
+
OwnerId = str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class CapabilityRef:
|
|
16
|
+
"""Stable identity of one catalog capability."""
|
|
17
|
+
|
|
18
|
+
kind: CapabilityKind
|
|
19
|
+
capability_id: str
|
|
20
|
+
owner_id: OwnerId
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class CapabilityBinding:
|
|
25
|
+
"""Immutable runtime configuration bound to one catalog capability."""
|
|
26
|
+
|
|
27
|
+
owner_id: OwnerId
|
|
28
|
+
configuration: Mapping[str, Any]
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
object.__setattr__(self, "configuration", _freeze(self.configuration))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ExecutionFloorView:
|
|
36
|
+
"""Non-secret execution posture exposed to a composing capability."""
|
|
37
|
+
|
|
38
|
+
execution: str | None = None
|
|
39
|
+
persistence: str | None = None
|
|
40
|
+
paths: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
|
|
41
|
+
# Sandbox postures the composing capability must re-request explicitly
|
|
42
|
+
# (bwrap's policy join treats an unspecified request axis as the pack's
|
|
43
|
+
# restrictive default, so a floor allowance like ``network: allow`` only
|
|
44
|
+
# takes effect when the caller forwards it).
|
|
45
|
+
network: str | None = None
|
|
46
|
+
device: str | None = None
|
|
47
|
+
ipc: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@runtime_checkable
|
|
51
|
+
class CapabilityBindingProvider(Protocol):
|
|
52
|
+
"""Resolve one concrete catalog capability for the active session."""
|
|
53
|
+
|
|
54
|
+
def binding_for(self, capability: CapabilityRef) -> CapabilityBinding | None:
|
|
55
|
+
"""Return the binding, or ``None`` when the capability is not active."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _freeze(value: Any) -> Any:
|
|
59
|
+
if isinstance(value, Mapping):
|
|
60
|
+
return MappingProxyType({str(key): _freeze(item) for key, item in value.items()})
|
|
61
|
+
if isinstance(value, list | tuple):
|
|
62
|
+
return tuple(_freeze(item) for item in value)
|
|
63
|
+
if isinstance(value, set | frozenset):
|
|
64
|
+
return frozenset(_freeze(item) for item in value)
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"CapabilityBinding",
|
|
70
|
+
"CapabilityBindingProvider",
|
|
71
|
+
"CapabilityKind",
|
|
72
|
+
"CapabilityRef",
|
|
73
|
+
"ExecutionFloorView",
|
|
74
|
+
"OwnerId",
|
|
75
|
+
]
|