millforge 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.
- millforge/__init__.py +1174 -0
- millforge/_forge/LICENSE +21 -0
- millforge/_forge/PROVENANCE.json +295 -0
- millforge/_forge/UPDATE_POLICY.md +24 -0
- millforge/_forge/__init__.py +14 -0
- millforge/_forge/adapter.py +2232 -0
- millforge/_forge/base_runner.py +121 -0
- millforge/_forge/clients/__init__.py +10 -0
- millforge/_forge/clients/base.py +200 -0
- millforge/_forge/context/__init__.py +23 -0
- millforge/_forge/context/manager.py +178 -0
- millforge/_forge/context/strategies.py +335 -0
- millforge/_forge/core/__init__.py +16 -0
- millforge/_forge/core/inference.py +433 -0
- millforge/_forge/core/messages.py +119 -0
- millforge/_forge/core/runner.py +479 -0
- millforge/_forge/core/steps.py +108 -0
- millforge/_forge/core/workflow.py +400 -0
- millforge/_forge/errors.py +222 -0
- millforge/_forge/guardrails/__init__.py +21 -0
- millforge/_forge/guardrails/error_tracker.py +71 -0
- millforge/_forge/guardrails/guardrails.py +194 -0
- millforge/_forge/guardrails/nudge.py +47 -0
- millforge/_forge/guardrails/response_validator.py +119 -0
- millforge/_forge/guardrails/step_enforcer.py +183 -0
- millforge/_forge/prompts/__init__.py +16 -0
- millforge/_forge/prompts/nudges.py +95 -0
- millforge/_forge/prompts/templates.py +285 -0
- millforge/_version.py +3 -0
- millforge/artifacts.py +570 -0
- millforge/base/__init__.py +97 -0
- millforge/base/composition.py +402 -0
- millforge/base/context.py +285 -0
- millforge/base/harness.py +138 -0
- millforge/base/identity.py +465 -0
- millforge/base/options.py +34 -0
- millforge/base/platform.py +17 -0
- millforge/base/prompt.py +317 -0
- millforge/base/runner.py +546 -0
- millforge/compiled_plan.py +970 -0
- millforge/compiler/__init__.py +231 -0
- millforge/compiler/artifact_validation.py +257 -0
- millforge/compiler/canonicalization.py +169 -0
- millforge/compiler/capabilities.py +66 -0
- millforge/compiler/catalogs.py +500 -0
- millforge/compiler/diagnostics.py +491 -0
- millforge/compiler/graph.py +678 -0
- millforge/compiler/lowering.py +198 -0
- millforge/compiler/output.py +692 -0
- millforge/compiler/parsing.py +1424 -0
- millforge/compiler/requests.py +1180 -0
- millforge/compiler/schema_validation.py +272 -0
- millforge/compiler/semantic.py +490 -0
- millforge/compiler/service.py +448 -0
- millforge/compiler/source.py +375 -0
- millforge/compiler/validators.py +184 -0
- millforge/connectors/__init__.py +95 -0
- millforge/connectors/admission.py +801 -0
- millforge/connectors/broker.py +202 -0
- millforge/connectors/contracts.py +1159 -0
- millforge/connectors/diagnostics.py +189 -0
- millforge/connectors/fake.py +66 -0
- millforge/connectors/runtime.py +236 -0
- millforge/contracts.py +2860 -0
- millforge/custom_tools/__init__.py +67 -0
- millforge/custom_tools/compiler.py +724 -0
- millforge/custom_tools/contracts.py +1093 -0
- millforge/custom_tools/diagnostics.py +205 -0
- millforge/eval_artifacts.py +952 -0
- millforge/eval_boundary.py +2435 -0
- millforge/eval_fixtures/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.bug_diagnosis.traceback.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.direct_edit.import_sort.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.evidence_discipline.no_source_change.v1.json +51 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.false_closure.visible_green.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.multi_file.api_contract.v1.json +54 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.recovery.malformed_artifact.v1.json +54 -0
- millforge/eval_fixtures/default_pack/manifest.json +12 -0
- millforge/eval_modes.py +1282 -0
- millforge/eval_presets.py +1398 -0
- millforge/eval_reports.py +2517 -0
- millforge/eval_suite.py +2429 -0
- millforge/eval_trials.py +2632 -0
- millforge/eval_workflow.py +794 -0
- millforge/exceptions.py +122 -0
- millforge/model_backend.py +2098 -0
- millforge/protocols.py +340 -0
- millforge/py.typed +0 -0
- millforge/runtime.py +1791 -0
- millforge/testing/__init__.py +1089 -0
- millforge/tools/__init__.py +83 -0
- millforge/tools/builtin_runtime.py +1339 -0
- millforge/tools/builtins.py +773 -0
- millforge/tools/execution.py +1545 -0
- millforge/tools/path_policy.py +155 -0
- millforge/tools/pi_compat/PI_LICENSE +21 -0
- millforge/tools/pi_compat/PROVENANCE.json +55 -0
- millforge/tools/pi_compat/UPDATE_POLICY.md +36 -0
- millforge/tools/pi_compat/__init__.py +34 -0
- millforge/tools/pi_compat/contracts.py +49 -0
- millforge/tools/pi_compat/editing.py +390 -0
- millforge/tools/pi_compat/mutations.py +57 -0
- millforge/tools/pi_compat/operations.py +401 -0
- millforge/tools/pi_compat/paths.py +155 -0
- millforge/tools/pi_compat/process.py +1375 -0
- millforge/tools/pi_compat/search.py +738 -0
- millforge/tools/pi_compat/truncation.py +267 -0
- millforge/tools/pi_compat_catalog.py +396 -0
- millforge/tools/pi_compat_runtime.py +460 -0
- millforge/tools/registry.py +553 -0
- millforge/tools/results.py +533 -0
- millforge-0.1.0.dist-info/METADATA +844 -0
- millforge-0.1.0.dist-info/RECORD +116 -0
- millforge-0.1.0.dist-info/WHEEL +4 -0
- millforge-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
"""Frozen custom-tool source, policy, compilation, and diagnostic contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from types import MappingProxyType
|
|
11
|
+
from typing import Any, ClassVar, cast
|
|
12
|
+
|
|
13
|
+
from pydantic import (
|
|
14
|
+
BaseModel,
|
|
15
|
+
ConfigDict,
|
|
16
|
+
Field,
|
|
17
|
+
StrictBool,
|
|
18
|
+
StrictInt,
|
|
19
|
+
StrictStr,
|
|
20
|
+
ValidationError,
|
|
21
|
+
field_serializer,
|
|
22
|
+
field_validator,
|
|
23
|
+
model_validator,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from millforge import IdempotencyClass, SideEffectClass, canonical_json_serialize
|
|
27
|
+
from millforge.compiler.diagnostics import detect_secret_candidate
|
|
28
|
+
from millforge.compiler.schema_validation import normalize_json_schema
|
|
29
|
+
from millforge.compiler.validators import (
|
|
30
|
+
validate_artifact_id,
|
|
31
|
+
validate_capability_id,
|
|
32
|
+
validate_canonical_tool_id,
|
|
33
|
+
validate_nonblank,
|
|
34
|
+
validate_sha256,
|
|
35
|
+
validate_tool_version,
|
|
36
|
+
validate_unique,
|
|
37
|
+
validate_utf8_size,
|
|
38
|
+
)
|
|
39
|
+
from millforge.contracts import RedactionPolicy
|
|
40
|
+
from millforge.custom_tools.diagnostics import (
|
|
41
|
+
CustomToolDiagnostic,
|
|
42
|
+
CustomToolDiagnosticCode,
|
|
43
|
+
CustomToolDiagnosticPhase,
|
|
44
|
+
custom_tool_diagnostic_sort_key,
|
|
45
|
+
malformed_input_diagnostic,
|
|
46
|
+
)
|
|
47
|
+
from millforge.tools.registry import ToolDescriptor, ToolOutputPolicy, ToolTimeoutPolicy
|
|
48
|
+
|
|
49
|
+
CUSTOM_TOOL_DECLARATION_HASH_KIND = "millforge.custom_tool.declaration.v1"
|
|
50
|
+
CUSTOM_TOOL_SOURCE_HASH_KIND = "millforge.custom_tool.source.v1"
|
|
51
|
+
CUSTOM_TOOL_COMPILATION_RECORD_HASH_KIND = "millforge.custom_tool.compilation_record.v1"
|
|
52
|
+
CUSTOM_TOOL_SOURCE_SCHEMA = "millforge.custom_tool.source"
|
|
53
|
+
CUSTOM_TOOL_SOURCE_KIND = "custom_tool_source"
|
|
54
|
+
CUSTOM_TOOL_SOURCE_VERSION = "1.0"
|
|
55
|
+
|
|
56
|
+
MAX_CUSTOM_TOOL_FIELD_UTF8 = 512
|
|
57
|
+
MAX_CUSTOM_TOOL_PACKAGE_ID_UTF8 = 160
|
|
58
|
+
MAX_CUSTOM_TOOL_DESCRIPTION_UTF8 = 65_536
|
|
59
|
+
MAX_CUSTOM_TOOL_SCHEMA_BYTES = 65_536
|
|
60
|
+
MAX_CUSTOM_TOOL_COUNT = 128
|
|
61
|
+
|
|
62
|
+
JsonValue = Any
|
|
63
|
+
|
|
64
|
+
_SIDE_EFFECTS_REQUIRING_APPROVAL = frozenset(
|
|
65
|
+
item for item in SideEffectClass if item is not SideEffectClass.READ_ONLY
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CustomToolRuntimeKind(str, Enum):
|
|
70
|
+
"""Closed custom-tool runtime kind values for 05C contracts."""
|
|
71
|
+
|
|
72
|
+
CONTRACT_ONLY = "contract_only"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CustomToolDescriptionPolicy(str, Enum):
|
|
76
|
+
"""Closed description provenance policy values."""
|
|
77
|
+
|
|
78
|
+
OPERATOR_SUPPLIED = "operator_supplied"
|
|
79
|
+
SOURCE_SUPPLIED = "source_supplied"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CustomToolApprovalPolicy(str, Enum):
|
|
83
|
+
"""Closed approval policy values for custom-tool declarations."""
|
|
84
|
+
|
|
85
|
+
NONE = "none"
|
|
86
|
+
MILLRACE_EXPLICIT = "millrace_explicit"
|
|
87
|
+
OPERATOR_OUT_OF_BAND = "operator_out_of_band"
|
|
88
|
+
FORBIDDEN = "forbidden"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CustomToolInputPolicy(str, Enum):
|
|
92
|
+
"""Closed input acceptance policy values for later compilation."""
|
|
93
|
+
|
|
94
|
+
JSON_SCHEMA_EXACT = "json_schema_exact"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class CustomToolOutputPolicy(str, Enum):
|
|
98
|
+
"""Closed output acceptance policy values for later compilation."""
|
|
99
|
+
|
|
100
|
+
JSON_SCHEMA_EXACT = "json_schema_exact"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CustomToolDeclaration(BaseModel):
|
|
104
|
+
"""Immutable source declaration for one compile-only custom tool."""
|
|
105
|
+
|
|
106
|
+
model_config = ConfigDict(
|
|
107
|
+
frozen=True,
|
|
108
|
+
extra="forbid",
|
|
109
|
+
arbitrary_types_allowed=True,
|
|
110
|
+
hide_input_in_errors=True,
|
|
111
|
+
use_enum_values=False,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
tool_id: StrictStr
|
|
115
|
+
tool_version: StrictInt = Field(ge=1)
|
|
116
|
+
implementation_id: StrictStr
|
|
117
|
+
runtime_kind: CustomToolRuntimeKind = CustomToolRuntimeKind.CONTRACT_ONLY
|
|
118
|
+
model_tool_name: StrictStr
|
|
119
|
+
description: StrictStr
|
|
120
|
+
description_policy: CustomToolDescriptionPolicy
|
|
121
|
+
input_schema: Mapping[str, JsonValue]
|
|
122
|
+
output_schema: Mapping[str, JsonValue]
|
|
123
|
+
required_capabilities: tuple[StrictStr, ...] = Field(default_factory=tuple)
|
|
124
|
+
produced_artifact_ids: tuple[StrictStr, ...] = Field(default_factory=tuple)
|
|
125
|
+
side_effect_class: SideEffectClass
|
|
126
|
+
idempotency: IdempotencyClass
|
|
127
|
+
timeout_policy: ToolTimeoutPolicy
|
|
128
|
+
output_policy: ToolOutputPolicy
|
|
129
|
+
approval_policy: CustomToolApprovalPolicy
|
|
130
|
+
input_policy: CustomToolInputPolicy = CustomToolInputPolicy.JSON_SCHEMA_EXACT
|
|
131
|
+
output_contract_policy: CustomToolOutputPolicy = (
|
|
132
|
+
CustomToolOutputPolicy.JSON_SCHEMA_EXACT
|
|
133
|
+
)
|
|
134
|
+
idempotency_key_policy: StrictStr | None = None
|
|
135
|
+
expected_declaration_sha256: StrictStr | None = None
|
|
136
|
+
expected_descriptor_sha256: StrictStr | None = None
|
|
137
|
+
expected_compilation_record_sha256: StrictStr | None = None
|
|
138
|
+
|
|
139
|
+
_diagnostic_phase: ClassVar[CustomToolDiagnosticPhase] = (
|
|
140
|
+
CustomToolDiagnosticPhase.DECLARATION
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def validate_contract(cls, raw: Any) -> CustomToolContractValidation:
|
|
145
|
+
"""Validate raw input and return stable diagnostics instead of exception text."""
|
|
146
|
+
return _validate_contract_for_model(cls, raw)
|
|
147
|
+
|
|
148
|
+
@field_validator("tool_id")
|
|
149
|
+
@classmethod
|
|
150
|
+
def _tool_id_valid(cls, value: str) -> str:
|
|
151
|
+
return validate_canonical_tool_id(value)
|
|
152
|
+
|
|
153
|
+
@field_validator("tool_version")
|
|
154
|
+
@classmethod
|
|
155
|
+
def _tool_version_valid(cls, value: int) -> int:
|
|
156
|
+
return validate_tool_version(value)
|
|
157
|
+
|
|
158
|
+
@field_validator("implementation_id", "model_tool_name")
|
|
159
|
+
@classmethod
|
|
160
|
+
def _identity_text_valid(cls, value: str, info: Any) -> str:
|
|
161
|
+
return _safe_custom_tool_string(
|
|
162
|
+
value, info.field_name, MAX_CUSTOM_TOOL_FIELD_UTF8
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@field_validator("description")
|
|
166
|
+
@classmethod
|
|
167
|
+
def _description_valid(cls, value: str) -> str:
|
|
168
|
+
return _safe_custom_tool_string(
|
|
169
|
+
value, "description", MAX_CUSTOM_TOOL_DESCRIPTION_UTF8
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
@field_validator("input_schema", "output_schema")
|
|
173
|
+
@classmethod
|
|
174
|
+
def _schema_frozen(cls, value: Any, info: Any) -> MappingProxyType[str, Any]:
|
|
175
|
+
if not isinstance(value, Mapping):
|
|
176
|
+
raise ValueError(f"{info.field_name} must be a JSON object")
|
|
177
|
+
_reject_secret_values(value, path=f"/{info.field_name}")
|
|
178
|
+
normalized = normalize_json_schema(value, field_name=info.field_name)
|
|
179
|
+
_validate_schema_size(normalized, info.field_name)
|
|
180
|
+
return _freeze_json_object(normalized)
|
|
181
|
+
|
|
182
|
+
@field_validator("required_capabilities", "produced_artifact_ids", mode="before")
|
|
183
|
+
@classmethod
|
|
184
|
+
def _string_tuple_snapshot(cls, value: Any, info: Any) -> tuple[str, ...]:
|
|
185
|
+
return _sorted_string_tuple(value, info.field_name)
|
|
186
|
+
|
|
187
|
+
@field_validator("required_capabilities")
|
|
188
|
+
@classmethod
|
|
189
|
+
def _capabilities_valid(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
190
|
+
for capability_id in value:
|
|
191
|
+
validate_capability_id(capability_id)
|
|
192
|
+
return validate_unique(value, "required_capabilities")
|
|
193
|
+
|
|
194
|
+
@field_validator("produced_artifact_ids")
|
|
195
|
+
@classmethod
|
|
196
|
+
def _produced_artifacts_valid(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
197
|
+
for artifact_id in value:
|
|
198
|
+
validate_artifact_id(artifact_id)
|
|
199
|
+
return validate_unique(value, "produced_artifact_ids")
|
|
200
|
+
|
|
201
|
+
@field_validator("idempotency_key_policy")
|
|
202
|
+
@classmethod
|
|
203
|
+
def _idempotency_key_policy_valid(cls, value: str | None) -> str | None:
|
|
204
|
+
if value is None:
|
|
205
|
+
return None
|
|
206
|
+
return _safe_custom_tool_string(
|
|
207
|
+
value, "idempotency_key_policy", MAX_CUSTOM_TOOL_FIELD_UTF8
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
@field_validator(
|
|
211
|
+
"expected_declaration_sha256",
|
|
212
|
+
"expected_descriptor_sha256",
|
|
213
|
+
"expected_compilation_record_sha256",
|
|
214
|
+
)
|
|
215
|
+
@classmethod
|
|
216
|
+
def _expected_hash_valid(cls, value: str | None, info: Any) -> str | None:
|
|
217
|
+
if value is None:
|
|
218
|
+
return None
|
|
219
|
+
return validate_sha256(value, info.field_name)
|
|
220
|
+
|
|
221
|
+
@model_validator(mode="after")
|
|
222
|
+
def _declaration_semantics_valid(self) -> CustomToolDeclaration:
|
|
223
|
+
if self.approval_policy is CustomToolApprovalPolicy.FORBIDDEN:
|
|
224
|
+
raise ValueError("forbidden approval policy cannot compile a tool")
|
|
225
|
+
if (
|
|
226
|
+
self.side_effect_class in _SIDE_EFFECTS_REQUIRING_APPROVAL
|
|
227
|
+
and self.approval_policy
|
|
228
|
+
not in {
|
|
229
|
+
CustomToolApprovalPolicy.MILLRACE_EXPLICIT,
|
|
230
|
+
CustomToolApprovalPolicy.OPERATOR_OUT_OF_BAND,
|
|
231
|
+
}
|
|
232
|
+
):
|
|
233
|
+
raise ValueError("side-effecting custom tools require explicit approval")
|
|
234
|
+
if (
|
|
235
|
+
self.side_effect_class in _SIDE_EFFECTS_REQUIRING_APPROVAL
|
|
236
|
+
or self.produced_artifact_ids
|
|
237
|
+
) and not self.required_capabilities:
|
|
238
|
+
raise ValueError(
|
|
239
|
+
"side-effecting or artifact-producing custom tools require capabilities"
|
|
240
|
+
)
|
|
241
|
+
return self
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def input_schema_sha256(self) -> str:
|
|
245
|
+
return _sha256_hex(_thaw_json_value(self.input_schema))
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def output_schema_sha256(self) -> str:
|
|
249
|
+
return _sha256_hex(_thaw_json_value(self.output_schema))
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def declaration_sha256(self) -> str:
|
|
253
|
+
return _sha256_hex(
|
|
254
|
+
{
|
|
255
|
+
"kind": CUSTOM_TOOL_DECLARATION_HASH_KIND,
|
|
256
|
+
"tool_id": self.tool_id,
|
|
257
|
+
"tool_version": self.tool_version,
|
|
258
|
+
"implementation_id": self.implementation_id,
|
|
259
|
+
"runtime_kind": self.runtime_kind.value,
|
|
260
|
+
"model_tool_name": self.model_tool_name,
|
|
261
|
+
"description": self.description,
|
|
262
|
+
"description_policy": self.description_policy.value,
|
|
263
|
+
"input_schema": _thaw_json_value(self.input_schema),
|
|
264
|
+
"output_schema": _thaw_json_value(self.output_schema),
|
|
265
|
+
"required_capabilities": list(self.required_capabilities),
|
|
266
|
+
"produced_artifact_ids": list(self.produced_artifact_ids),
|
|
267
|
+
"side_effect_class": self.side_effect_class.value,
|
|
268
|
+
"idempotency": self.idempotency.value,
|
|
269
|
+
"timeout_policy": self.timeout_policy.model_dump(mode="json"),
|
|
270
|
+
"output_policy": self.output_policy.model_dump(mode="json"),
|
|
271
|
+
"approval_policy": self.approval_policy.value,
|
|
272
|
+
"input_policy": self.input_policy.value,
|
|
273
|
+
"output_contract_policy": self.output_contract_policy.value,
|
|
274
|
+
"idempotency_key_policy": self.idempotency_key_policy,
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
@field_serializer("input_schema", "output_schema")
|
|
279
|
+
def _serialize_schema(self, value: Any) -> Any:
|
|
280
|
+
return _thaw_json_value(value)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class CustomToolSourceManifest(BaseModel):
|
|
284
|
+
"""Immutable custom-tool source manifest for deterministic offline compilation."""
|
|
285
|
+
|
|
286
|
+
model_config = ConfigDict(
|
|
287
|
+
frozen=True,
|
|
288
|
+
extra="forbid",
|
|
289
|
+
arbitrary_types_allowed=True,
|
|
290
|
+
hide_input_in_errors=True,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
schema_version: StrictStr = CUSTOM_TOOL_SOURCE_SCHEMA
|
|
294
|
+
kind: StrictStr = CUSTOM_TOOL_SOURCE_KIND
|
|
295
|
+
version: StrictStr = CUSTOM_TOOL_SOURCE_VERSION
|
|
296
|
+
package_id: StrictStr
|
|
297
|
+
package_version: StrictInt = Field(ge=1)
|
|
298
|
+
source_name: StrictStr | None = None
|
|
299
|
+
created_at: StrictStr
|
|
300
|
+
tools: tuple[CustomToolDeclaration, ...]
|
|
301
|
+
policy_metadata: Mapping[str, JsonValue] = Field(
|
|
302
|
+
default_factory=lambda: MappingProxyType({})
|
|
303
|
+
)
|
|
304
|
+
expected_source_sha256: StrictStr | None = None
|
|
305
|
+
|
|
306
|
+
_diagnostic_phase: ClassVar[CustomToolDiagnosticPhase] = (
|
|
307
|
+
CustomToolDiagnosticPhase.SOURCE
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def validate_contract(cls, raw: Any) -> CustomToolContractValidation:
|
|
312
|
+
"""Validate raw input and return stable diagnostics instead of exception text."""
|
|
313
|
+
return _validate_contract_for_model(cls, raw)
|
|
314
|
+
|
|
315
|
+
@field_validator("schema_version")
|
|
316
|
+
@classmethod
|
|
317
|
+
def _schema_version_valid(cls, value: str) -> str:
|
|
318
|
+
if value != CUSTOM_TOOL_SOURCE_SCHEMA:
|
|
319
|
+
raise ValueError(
|
|
320
|
+
"schema_version must identify custom-tool source manifests"
|
|
321
|
+
)
|
|
322
|
+
return value
|
|
323
|
+
|
|
324
|
+
@field_validator("kind")
|
|
325
|
+
@classmethod
|
|
326
|
+
def _kind_valid(cls, value: str) -> str:
|
|
327
|
+
if value != CUSTOM_TOOL_SOURCE_KIND:
|
|
328
|
+
raise ValueError("kind must be custom_tool_source")
|
|
329
|
+
return value
|
|
330
|
+
|
|
331
|
+
@field_validator("version")
|
|
332
|
+
@classmethod
|
|
333
|
+
def _version_valid(cls, value: str) -> str:
|
|
334
|
+
if value != CUSTOM_TOOL_SOURCE_VERSION:
|
|
335
|
+
raise ValueError("version must be 1.0")
|
|
336
|
+
return value
|
|
337
|
+
|
|
338
|
+
@field_validator("package_id", "source_name")
|
|
339
|
+
@classmethod
|
|
340
|
+
def _package_text_valid(cls, value: str | None, info: Any) -> str | None:
|
|
341
|
+
if value is None:
|
|
342
|
+
return None
|
|
343
|
+
maximum = (
|
|
344
|
+
MAX_CUSTOM_TOOL_PACKAGE_ID_UTF8
|
|
345
|
+
if info.field_name == "package_id"
|
|
346
|
+
else MAX_CUSTOM_TOOL_FIELD_UTF8
|
|
347
|
+
)
|
|
348
|
+
return _safe_custom_tool_string(value, info.field_name, maximum)
|
|
349
|
+
|
|
350
|
+
@field_validator("created_at")
|
|
351
|
+
@classmethod
|
|
352
|
+
def _created_at_valid(cls, value: str) -> str:
|
|
353
|
+
return _validate_utc_timestamp(value, "created_at")
|
|
354
|
+
|
|
355
|
+
@field_validator("tools", mode="before")
|
|
356
|
+
@classmethod
|
|
357
|
+
def _tools_tuple(cls, value: Any) -> tuple[Any, ...]:
|
|
358
|
+
if isinstance(value, str) or not isinstance(value, (list, tuple)):
|
|
359
|
+
raise ValueError("tools must be an array")
|
|
360
|
+
return tuple(value)
|
|
361
|
+
|
|
362
|
+
@field_validator("policy_metadata")
|
|
363
|
+
@classmethod
|
|
364
|
+
def _policy_metadata_frozen(cls, value: Any) -> MappingProxyType[str, Any]:
|
|
365
|
+
if not isinstance(value, Mapping):
|
|
366
|
+
raise ValueError("policy_metadata must be a JSON object")
|
|
367
|
+
_reject_secret_values(value, path="/policy_metadata")
|
|
368
|
+
return _freeze_json_object(value)
|
|
369
|
+
|
|
370
|
+
@field_validator("expected_source_sha256")
|
|
371
|
+
@classmethod
|
|
372
|
+
def _expected_source_hash_valid(cls, value: str | None) -> str | None:
|
|
373
|
+
if value is None:
|
|
374
|
+
return None
|
|
375
|
+
return validate_sha256(value, "expected_source_sha256")
|
|
376
|
+
|
|
377
|
+
@model_validator(mode="after")
|
|
378
|
+
def _source_consistent(self) -> CustomToolSourceManifest:
|
|
379
|
+
if not self.tools:
|
|
380
|
+
raise ValueError("tools must contain at least one tool")
|
|
381
|
+
if len(self.tools) > MAX_CUSTOM_TOOL_COUNT:
|
|
382
|
+
raise ValueError("tools exceeds maximum custom-tool count")
|
|
383
|
+
tool_keys = tuple(f"{tool.tool_id}@{tool.tool_version}" for tool in self.tools)
|
|
384
|
+
validate_unique(tool_keys, "custom tool identities")
|
|
385
|
+
validate_unique(
|
|
386
|
+
tuple(tool.model_tool_name for tool in self.tools),
|
|
387
|
+
"custom tool model_tool_name",
|
|
388
|
+
)
|
|
389
|
+
validate_unique(
|
|
390
|
+
tuple(tool.implementation_id for tool in self.tools),
|
|
391
|
+
"custom tool implementation_id",
|
|
392
|
+
)
|
|
393
|
+
return self
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def source_sha256(self) -> str:
|
|
397
|
+
return _sha256_hex(
|
|
398
|
+
{
|
|
399
|
+
"kind": CUSTOM_TOOL_SOURCE_HASH_KIND,
|
|
400
|
+
"schema_version": self.schema_version,
|
|
401
|
+
"source_kind": self.kind,
|
|
402
|
+
"version": self.version,
|
|
403
|
+
"package_id": self.package_id,
|
|
404
|
+
"package_version": self.package_version,
|
|
405
|
+
"source_name": self.source_name,
|
|
406
|
+
"tools": [
|
|
407
|
+
{
|
|
408
|
+
"tool_id": tool.tool_id,
|
|
409
|
+
"tool_version": tool.tool_version,
|
|
410
|
+
"declaration_sha256": tool.declaration_sha256,
|
|
411
|
+
}
|
|
412
|
+
for tool in sorted(
|
|
413
|
+
self.tools, key=lambda item: (item.tool_id, item.tool_version)
|
|
414
|
+
)
|
|
415
|
+
],
|
|
416
|
+
"policy_metadata": _thaw_json_value(self.policy_metadata),
|
|
417
|
+
}
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
@field_serializer("policy_metadata")
|
|
421
|
+
def _serialize_policy_metadata(self, value: Any) -> Any:
|
|
422
|
+
return _thaw_json_value(value)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class CustomToolCompilerPolicy(BaseModel):
|
|
426
|
+
"""Closed policy constraints for deterministic custom-tool compilation."""
|
|
427
|
+
|
|
428
|
+
model_config = ConfigDict(
|
|
429
|
+
frozen=True,
|
|
430
|
+
extra="forbid",
|
|
431
|
+
arbitrary_types_allowed=True,
|
|
432
|
+
hide_input_in_errors=True,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
allowed_capability_ids: tuple[StrictStr, ...]
|
|
436
|
+
allowed_runtime_kinds: tuple[CustomToolRuntimeKind, ...] = (
|
|
437
|
+
CustomToolRuntimeKind.CONTRACT_ONLY,
|
|
438
|
+
)
|
|
439
|
+
max_tools: StrictInt = Field(
|
|
440
|
+
default=MAX_CUSTOM_TOOL_COUNT, ge=1, le=MAX_CUSTOM_TOOL_COUNT
|
|
441
|
+
)
|
|
442
|
+
max_schema_bytes: StrictInt = Field(default=MAX_CUSTOM_TOOL_SCHEMA_BYTES, ge=1)
|
|
443
|
+
max_description_utf8: StrictInt = Field(
|
|
444
|
+
default=MAX_CUSTOM_TOOL_DESCRIPTION_UTF8,
|
|
445
|
+
ge=1,
|
|
446
|
+
le=MAX_CUSTOM_TOOL_DESCRIPTION_UTF8,
|
|
447
|
+
)
|
|
448
|
+
require_expected_hashes: StrictBool = False
|
|
449
|
+
side_effect_approval_matrix: Mapping[
|
|
450
|
+
SideEffectClass, tuple[CustomToolApprovalPolicy, ...]
|
|
451
|
+
] = Field(default_factory=lambda: MappingProxyType(_default_approval_matrix()))
|
|
452
|
+
|
|
453
|
+
_diagnostic_phase: ClassVar[CustomToolDiagnosticPhase] = (
|
|
454
|
+
CustomToolDiagnosticPhase.POLICY
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
@classmethod
|
|
458
|
+
def validate_contract(cls, raw: Any) -> CustomToolContractValidation:
|
|
459
|
+
"""Validate raw input and return stable diagnostics instead of exception text."""
|
|
460
|
+
return _validate_contract_for_model(cls, raw)
|
|
461
|
+
|
|
462
|
+
@field_validator("allowed_capability_ids", mode="before")
|
|
463
|
+
@classmethod
|
|
464
|
+
def _allowed_capabilities_snapshot(cls, value: Any) -> tuple[str, ...]:
|
|
465
|
+
values = _sorted_string_tuple(value, "allowed_capability_ids")
|
|
466
|
+
for capability_id in values:
|
|
467
|
+
validate_capability_id(capability_id)
|
|
468
|
+
return validate_unique(values, "allowed_capability_ids")
|
|
469
|
+
|
|
470
|
+
@field_validator("allowed_runtime_kinds", mode="before")
|
|
471
|
+
@classmethod
|
|
472
|
+
def _runtime_kinds_tuple(cls, value: Any) -> tuple[Any, ...]:
|
|
473
|
+
if isinstance(value, str) or not isinstance(value, (list, tuple)):
|
|
474
|
+
raise ValueError("allowed_runtime_kinds must be an array")
|
|
475
|
+
return tuple(value)
|
|
476
|
+
|
|
477
|
+
@field_validator("allowed_runtime_kinds")
|
|
478
|
+
@classmethod
|
|
479
|
+
def _runtime_kinds_valid(
|
|
480
|
+
cls, value: tuple[CustomToolRuntimeKind, ...]
|
|
481
|
+
) -> tuple[CustomToolRuntimeKind, ...]:
|
|
482
|
+
if value != (CustomToolRuntimeKind.CONTRACT_ONLY,):
|
|
483
|
+
raise ValueError(
|
|
484
|
+
"allowed runtime kinds are initially limited to contract_only"
|
|
485
|
+
)
|
|
486
|
+
return value
|
|
487
|
+
|
|
488
|
+
@field_validator("side_effect_approval_matrix")
|
|
489
|
+
@classmethod
|
|
490
|
+
def _matrix_frozen(
|
|
491
|
+
cls, value: Any
|
|
492
|
+
) -> MappingProxyType[SideEffectClass, tuple[CustomToolApprovalPolicy, ...]]:
|
|
493
|
+
if not isinstance(value, Mapping):
|
|
494
|
+
raise ValueError("side_effect_approval_matrix must be an object")
|
|
495
|
+
matrix: dict[SideEffectClass, tuple[CustomToolApprovalPolicy, ...]] = {}
|
|
496
|
+
for raw_key, raw_values in value.items():
|
|
497
|
+
key = (
|
|
498
|
+
raw_key
|
|
499
|
+
if isinstance(raw_key, SideEffectClass)
|
|
500
|
+
else SideEffectClass(raw_key)
|
|
501
|
+
)
|
|
502
|
+
if isinstance(raw_values, str) or not isinstance(raw_values, (list, tuple)):
|
|
503
|
+
raise ValueError("side_effect_approval_matrix values must be arrays")
|
|
504
|
+
policies = tuple(
|
|
505
|
+
item
|
|
506
|
+
if isinstance(item, CustomToolApprovalPolicy)
|
|
507
|
+
else CustomToolApprovalPolicy(item)
|
|
508
|
+
for item in raw_values
|
|
509
|
+
)
|
|
510
|
+
if CustomToolApprovalPolicy.FORBIDDEN in policies:
|
|
511
|
+
raise ValueError("forbidden approval policy is denial evidence only")
|
|
512
|
+
if (
|
|
513
|
+
key in _SIDE_EFFECTS_REQUIRING_APPROVAL
|
|
514
|
+
and CustomToolApprovalPolicy.NONE in policies
|
|
515
|
+
):
|
|
516
|
+
raise ValueError("side-effecting custom tools cannot use approval none")
|
|
517
|
+
matrix[key] = policies
|
|
518
|
+
return MappingProxyType(
|
|
519
|
+
dict(sorted(matrix.items(), key=lambda item: item[0].value))
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
@field_serializer("side_effect_approval_matrix")
|
|
523
|
+
def _serialize_matrix(
|
|
524
|
+
self,
|
|
525
|
+
value: MappingProxyType[SideEffectClass, tuple[CustomToolApprovalPolicy, ...]],
|
|
526
|
+
) -> dict[str, list[str]]:
|
|
527
|
+
return {
|
|
528
|
+
side_effect.value: [policy.value for policy in policies]
|
|
529
|
+
for side_effect, policies in sorted(
|
|
530
|
+
value.items(), key=lambda item: item[0].value
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
class CustomToolCompilationRecord(BaseModel):
|
|
536
|
+
"""Immutable record for one contract-only compiled custom-tool declaration."""
|
|
537
|
+
|
|
538
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
539
|
+
|
|
540
|
+
package_id: StrictStr
|
|
541
|
+
package_version: StrictInt = Field(ge=1)
|
|
542
|
+
source_sha256: StrictStr
|
|
543
|
+
tool_id: StrictStr
|
|
544
|
+
tool_version: StrictInt = Field(ge=1)
|
|
545
|
+
implementation_id: StrictStr
|
|
546
|
+
runtime_kind: CustomToolRuntimeKind
|
|
547
|
+
model_tool_name: StrictStr
|
|
548
|
+
declaration_sha256: StrictStr
|
|
549
|
+
input_schema_sha256: StrictStr
|
|
550
|
+
output_schema_sha256: StrictStr
|
|
551
|
+
descriptor_sha256: StrictStr
|
|
552
|
+
required_capabilities: tuple[StrictStr, ...]
|
|
553
|
+
produced_artifact_ids: tuple[StrictStr, ...] = Field(default_factory=tuple)
|
|
554
|
+
side_effect_class: SideEffectClass
|
|
555
|
+
idempotency: IdempotencyClass
|
|
556
|
+
timeout_policy: ToolTimeoutPolicy
|
|
557
|
+
output_policy: ToolOutputPolicy
|
|
558
|
+
approval_policy: CustomToolApprovalPolicy
|
|
559
|
+
compilation_record_sha256: StrictStr = ""
|
|
560
|
+
|
|
561
|
+
@field_validator(
|
|
562
|
+
"source_sha256",
|
|
563
|
+
"declaration_sha256",
|
|
564
|
+
"input_schema_sha256",
|
|
565
|
+
"output_schema_sha256",
|
|
566
|
+
"descriptor_sha256",
|
|
567
|
+
"compilation_record_sha256",
|
|
568
|
+
)
|
|
569
|
+
@classmethod
|
|
570
|
+
def _hash_valid(cls, value: str, info: Any) -> str:
|
|
571
|
+
if info.field_name == "compilation_record_sha256" and value == "":
|
|
572
|
+
return value
|
|
573
|
+
return validate_sha256(value, info.field_name)
|
|
574
|
+
|
|
575
|
+
@field_validator("tool_id")
|
|
576
|
+
@classmethod
|
|
577
|
+
def _tool_id_valid(cls, value: str) -> str:
|
|
578
|
+
return validate_canonical_tool_id(value)
|
|
579
|
+
|
|
580
|
+
@field_validator("tool_version")
|
|
581
|
+
@classmethod
|
|
582
|
+
def _tool_version_valid(cls, value: int) -> int:
|
|
583
|
+
return validate_tool_version(value)
|
|
584
|
+
|
|
585
|
+
@field_validator("required_capabilities", "produced_artifact_ids", mode="before")
|
|
586
|
+
@classmethod
|
|
587
|
+
def _string_tuple_snapshot(cls, value: Any, info: Any) -> tuple[str, ...]:
|
|
588
|
+
return _sorted_string_tuple(value, info.field_name)
|
|
589
|
+
|
|
590
|
+
@field_validator("required_capabilities")
|
|
591
|
+
@classmethod
|
|
592
|
+
def _capabilities_valid(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
593
|
+
for capability_id in value:
|
|
594
|
+
validate_capability_id(capability_id)
|
|
595
|
+
return validate_unique(value, "required_capabilities")
|
|
596
|
+
|
|
597
|
+
@field_validator("produced_artifact_ids")
|
|
598
|
+
@classmethod
|
|
599
|
+
def _produced_artifacts_valid(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
600
|
+
for artifact_id in value:
|
|
601
|
+
validate_artifact_id(artifact_id)
|
|
602
|
+
return validate_unique(value, "produced_artifact_ids")
|
|
603
|
+
|
|
604
|
+
@model_validator(mode="after")
|
|
605
|
+
def _record_hash_consistent(self) -> CustomToolCompilationRecord:
|
|
606
|
+
computed = _compilation_record_hash(self)
|
|
607
|
+
if self.compilation_record_sha256 == "":
|
|
608
|
+
object.__setattr__(self, "compilation_record_sha256", computed)
|
|
609
|
+
elif self.compilation_record_sha256 != computed:
|
|
610
|
+
raise ValueError("compilation_record_sha256 must match record contents")
|
|
611
|
+
return self
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
class CustomToolCompilationResult(BaseModel):
|
|
615
|
+
"""Deterministic result container for custom-tool compilation attempts."""
|
|
616
|
+
|
|
617
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
618
|
+
|
|
619
|
+
accepted: bool
|
|
620
|
+
source_sha256: StrictStr | None = None
|
|
621
|
+
descriptors: tuple[ToolDescriptor, ...] = Field(default_factory=tuple)
|
|
622
|
+
records: tuple[CustomToolCompilationRecord, ...] = Field(default_factory=tuple)
|
|
623
|
+
diagnostics: tuple[CustomToolDiagnostic, ...] = Field(default_factory=tuple)
|
|
624
|
+
|
|
625
|
+
_diagnostic_phase: ClassVar[CustomToolDiagnosticPhase] = (
|
|
626
|
+
CustomToolDiagnosticPhase.COMPILATION
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
@classmethod
|
|
630
|
+
def validate_contract(cls, raw: Any) -> CustomToolContractValidation:
|
|
631
|
+
"""Validate raw input and return stable diagnostics instead of exception text."""
|
|
632
|
+
return _validate_contract_for_model(cls, raw)
|
|
633
|
+
|
|
634
|
+
@field_validator("source_sha256")
|
|
635
|
+
@classmethod
|
|
636
|
+
def _source_hash_valid(cls, value: str | None) -> str | None:
|
|
637
|
+
if value is None:
|
|
638
|
+
return None
|
|
639
|
+
return validate_sha256(value, "source_sha256")
|
|
640
|
+
|
|
641
|
+
@model_validator(mode="after")
|
|
642
|
+
def _result_consistent(self) -> CustomToolCompilationResult:
|
|
643
|
+
if self.accepted:
|
|
644
|
+
if self.diagnostics:
|
|
645
|
+
raise ValueError(
|
|
646
|
+
"accepted compilation results cannot contain diagnostics"
|
|
647
|
+
)
|
|
648
|
+
if self.source_sha256 is None:
|
|
649
|
+
raise ValueError("accepted compilation results require source hash")
|
|
650
|
+
if not self.descriptors:
|
|
651
|
+
raise ValueError("accepted compilation results require descriptors")
|
|
652
|
+
if len(self.descriptors) != len(self.records):
|
|
653
|
+
raise ValueError(
|
|
654
|
+
"accepted compilation results require one record per descriptor"
|
|
655
|
+
)
|
|
656
|
+
descriptor_by_hash = {
|
|
657
|
+
descriptor.descriptor_sha256: descriptor
|
|
658
|
+
for descriptor in self.descriptors
|
|
659
|
+
}
|
|
660
|
+
record_by_hash = {
|
|
661
|
+
record.descriptor_sha256: record for record in self.records
|
|
662
|
+
}
|
|
663
|
+
if (
|
|
664
|
+
len(descriptor_by_hash) != len(self.descriptors)
|
|
665
|
+
or len(record_by_hash) != len(self.records)
|
|
666
|
+
or descriptor_by_hash.keys() != record_by_hash.keys()
|
|
667
|
+
):
|
|
668
|
+
raise ValueError("compilation records must match descriptor hashes")
|
|
669
|
+
lowered = tuple(
|
|
670
|
+
sorted(
|
|
671
|
+
(
|
|
672
|
+
(descriptor, record_by_hash[descriptor.descriptor_sha256])
|
|
673
|
+
for descriptor in descriptor_by_hash.values()
|
|
674
|
+
),
|
|
675
|
+
key=lambda item: _accepted_result_identity_key(item[0], item[1]),
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
object.__setattr__(
|
|
679
|
+
self, "descriptors", tuple(descriptor for descriptor, _ in lowered)
|
|
680
|
+
)
|
|
681
|
+
object.__setattr__(self, "records", tuple(record for _, record in lowered))
|
|
682
|
+
elif self.descriptors or self.records:
|
|
683
|
+
raise ValueError(
|
|
684
|
+
"rejected compilation results cannot contain descriptors or records"
|
|
685
|
+
)
|
|
686
|
+
else:
|
|
687
|
+
object.__setattr__(
|
|
688
|
+
self,
|
|
689
|
+
"diagnostics",
|
|
690
|
+
tuple(sorted(self.diagnostics, key=custom_tool_diagnostic_sort_key)),
|
|
691
|
+
)
|
|
692
|
+
return self
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _compilation_record_hash(record: CustomToolCompilationRecord) -> str:
|
|
696
|
+
return _sha256_hex(
|
|
697
|
+
{
|
|
698
|
+
"kind": CUSTOM_TOOL_COMPILATION_RECORD_HASH_KIND,
|
|
699
|
+
"package_id": record.package_id,
|
|
700
|
+
"package_version": record.package_version,
|
|
701
|
+
"source_sha256": record.source_sha256,
|
|
702
|
+
"tool_id": record.tool_id,
|
|
703
|
+
"tool_version": record.tool_version,
|
|
704
|
+
"implementation_id": record.implementation_id,
|
|
705
|
+
"runtime_kind": record.runtime_kind.value,
|
|
706
|
+
"model_tool_name": record.model_tool_name,
|
|
707
|
+
"declaration_sha256": record.declaration_sha256,
|
|
708
|
+
"input_schema_sha256": record.input_schema_sha256,
|
|
709
|
+
"output_schema_sha256": record.output_schema_sha256,
|
|
710
|
+
"descriptor_sha256": record.descriptor_sha256,
|
|
711
|
+
"required_capabilities": list(record.required_capabilities),
|
|
712
|
+
"produced_artifact_ids": list(record.produced_artifact_ids),
|
|
713
|
+
"side_effect_class": record.side_effect_class.value,
|
|
714
|
+
"idempotency": record.idempotency.value,
|
|
715
|
+
"timeout_policy": record.timeout_policy.model_dump(mode="json"),
|
|
716
|
+
"output_policy": record.output_policy.model_dump(mode="json"),
|
|
717
|
+
"approval_policy": record.approval_policy.value,
|
|
718
|
+
}
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _accepted_result_identity_key(
|
|
723
|
+
descriptor: ToolDescriptor,
|
|
724
|
+
record: CustomToolCompilationRecord,
|
|
725
|
+
) -> tuple[str, str, int, str, str, str, str]:
|
|
726
|
+
return (
|
|
727
|
+
record.package_id,
|
|
728
|
+
descriptor.tool_id,
|
|
729
|
+
descriptor.tool_version,
|
|
730
|
+
descriptor.model_tool_name,
|
|
731
|
+
descriptor.implementation_id,
|
|
732
|
+
descriptor.descriptor_sha256,
|
|
733
|
+
record.compilation_record_sha256,
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
class CustomToolContractValidation(BaseModel):
|
|
738
|
+
"""Contract validation result for malformed raw custom-tool inputs."""
|
|
739
|
+
|
|
740
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
741
|
+
|
|
742
|
+
value: Any | None = None
|
|
743
|
+
diagnostics: tuple[CustomToolDiagnostic, ...] = Field(default_factory=tuple)
|
|
744
|
+
|
|
745
|
+
@property
|
|
746
|
+
def accepted(self) -> bool:
|
|
747
|
+
return self.value is not None and not self.diagnostics
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
class CustomToolContractModel(BaseModel):
|
|
751
|
+
"""Base helper for diagnostic validation of raw custom-tool mappings."""
|
|
752
|
+
|
|
753
|
+
model_config = ConfigDict(frozen=True)
|
|
754
|
+
_diagnostic_phase: ClassVar[CustomToolDiagnosticPhase] = (
|
|
755
|
+
CustomToolDiagnosticPhase.DIAGNOSTIC
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
@classmethod
|
|
759
|
+
def validate_contract(cls, raw: Any) -> CustomToolContractValidation:
|
|
760
|
+
"""Validate raw input and return stable diagnostics instead of exception text."""
|
|
761
|
+
try:
|
|
762
|
+
return CustomToolContractValidation(value=cls.model_validate(raw))
|
|
763
|
+
except ValidationError as exc:
|
|
764
|
+
missing_field = _validation_missing_field(exc)
|
|
765
|
+
return CustomToolContractValidation(
|
|
766
|
+
diagnostics=(
|
|
767
|
+
malformed_input_diagnostic(
|
|
768
|
+
phase=cls._diagnostic_phase,
|
|
769
|
+
model_name=cls.__name__,
|
|
770
|
+
path=_validation_pointer(exc),
|
|
771
|
+
missing_field=missing_field,
|
|
772
|
+
code=_validation_code(exc),
|
|
773
|
+
),
|
|
774
|
+
)
|
|
775
|
+
)
|
|
776
|
+
except Exception:
|
|
777
|
+
return CustomToolContractValidation(
|
|
778
|
+
diagnostics=(
|
|
779
|
+
malformed_input_diagnostic(
|
|
780
|
+
phase=cls._diagnostic_phase,
|
|
781
|
+
model_name=cls.__name__,
|
|
782
|
+
code=CustomToolDiagnosticCode.SOURCE_INVALID,
|
|
783
|
+
),
|
|
784
|
+
)
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _validate_contract_for_model(
|
|
789
|
+
cls: type[BaseModel], raw: Any
|
|
790
|
+
) -> CustomToolContractValidation:
|
|
791
|
+
phase = getattr(cls, "_diagnostic_phase", CustomToolDiagnosticPhase.DIAGNOSTIC)
|
|
792
|
+
try:
|
|
793
|
+
return CustomToolContractValidation(value=cls.model_validate(raw))
|
|
794
|
+
except ValidationError as exc:
|
|
795
|
+
missing_field = _validation_missing_field(exc)
|
|
796
|
+
return CustomToolContractValidation(
|
|
797
|
+
diagnostics=(
|
|
798
|
+
malformed_input_diagnostic(
|
|
799
|
+
phase=phase,
|
|
800
|
+
model_name=cls.__name__,
|
|
801
|
+
path=_validation_pointer(exc),
|
|
802
|
+
missing_field=missing_field,
|
|
803
|
+
code=_validation_code(exc),
|
|
804
|
+
),
|
|
805
|
+
)
|
|
806
|
+
)
|
|
807
|
+
except Exception:
|
|
808
|
+
return CustomToolContractValidation(
|
|
809
|
+
diagnostics=(
|
|
810
|
+
malformed_input_diagnostic(
|
|
811
|
+
phase=phase,
|
|
812
|
+
model_name=cls.__name__,
|
|
813
|
+
code=CustomToolDiagnosticCode.SOURCE_INVALID,
|
|
814
|
+
),
|
|
815
|
+
)
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def compilation_record_from_declaration(
|
|
820
|
+
source: CustomToolSourceManifest,
|
|
821
|
+
declaration: CustomToolDeclaration,
|
|
822
|
+
descriptor: ToolDescriptor | None = None,
|
|
823
|
+
) -> CustomToolCompilationRecord:
|
|
824
|
+
"""Create deterministic record metadata for a contract-only declaration."""
|
|
825
|
+
descriptor = descriptor or tool_descriptor_from_declaration(declaration)
|
|
826
|
+
_validate_descriptor_matches_declaration(declaration, descriptor)
|
|
827
|
+
return CustomToolCompilationRecord(
|
|
828
|
+
package_id=source.package_id,
|
|
829
|
+
package_version=source.package_version,
|
|
830
|
+
source_sha256=source.source_sha256,
|
|
831
|
+
tool_id=declaration.tool_id,
|
|
832
|
+
tool_version=declaration.tool_version,
|
|
833
|
+
implementation_id=declaration.implementation_id,
|
|
834
|
+
runtime_kind=declaration.runtime_kind,
|
|
835
|
+
model_tool_name=declaration.model_tool_name,
|
|
836
|
+
declaration_sha256=declaration.declaration_sha256,
|
|
837
|
+
input_schema_sha256=declaration.input_schema_sha256,
|
|
838
|
+
output_schema_sha256=declaration.output_schema_sha256,
|
|
839
|
+
descriptor_sha256=descriptor.descriptor_sha256,
|
|
840
|
+
required_capabilities=declaration.required_capabilities,
|
|
841
|
+
produced_artifact_ids=declaration.produced_artifact_ids,
|
|
842
|
+
side_effect_class=declaration.side_effect_class,
|
|
843
|
+
idempotency=declaration.idempotency,
|
|
844
|
+
timeout_policy=declaration.timeout_policy,
|
|
845
|
+
output_policy=declaration.output_policy,
|
|
846
|
+
approval_policy=declaration.approval_policy,
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def tool_descriptor_from_declaration(
|
|
851
|
+
declaration: CustomToolDeclaration,
|
|
852
|
+
) -> ToolDescriptor:
|
|
853
|
+
"""Create descriptor data for a contract-only declaration without registration."""
|
|
854
|
+
return ToolDescriptor(
|
|
855
|
+
tool_id=declaration.tool_id,
|
|
856
|
+
tool_version=declaration.tool_version,
|
|
857
|
+
implementation_id=declaration.implementation_id,
|
|
858
|
+
model_tool_name=declaration.model_tool_name,
|
|
859
|
+
description=declaration.description,
|
|
860
|
+
input_schema=cast(MappingProxyType[str, JsonValue], declaration.input_schema),
|
|
861
|
+
output_schema=cast(MappingProxyType[str, JsonValue], declaration.output_schema),
|
|
862
|
+
required_capabilities=declaration.required_capabilities,
|
|
863
|
+
produced_artifact_ids=declaration.produced_artifact_ids,
|
|
864
|
+
side_effect_class=declaration.side_effect_class,
|
|
865
|
+
idempotency=declaration.idempotency,
|
|
866
|
+
timeout_policy=declaration.timeout_policy,
|
|
867
|
+
output_policy=declaration.output_policy,
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _validate_descriptor_matches_declaration(
|
|
872
|
+
declaration: CustomToolDeclaration,
|
|
873
|
+
descriptor: ToolDescriptor,
|
|
874
|
+
) -> None:
|
|
875
|
+
expected = tool_descriptor_from_declaration(declaration)
|
|
876
|
+
expected_dump = expected.model_dump(mode="json")
|
|
877
|
+
supplied_dump = descriptor.model_dump(mode="json")
|
|
878
|
+
if supplied_dump != expected_dump:
|
|
879
|
+
raise ValueError("descriptor must match custom-tool declaration")
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _default_approval_matrix() -> dict[
|
|
883
|
+
SideEffectClass, tuple[CustomToolApprovalPolicy, ...]
|
|
884
|
+
]:
|
|
885
|
+
explicit = (
|
|
886
|
+
CustomToolApprovalPolicy.MILLRACE_EXPLICIT,
|
|
887
|
+
CustomToolApprovalPolicy.OPERATOR_OUT_OF_BAND,
|
|
888
|
+
)
|
|
889
|
+
return {
|
|
890
|
+
SideEffectClass.READ_ONLY: (
|
|
891
|
+
CustomToolApprovalPolicy.NONE,
|
|
892
|
+
CustomToolApprovalPolicy.MILLRACE_EXPLICIT,
|
|
893
|
+
CustomToolApprovalPolicy.OPERATOR_OUT_OF_BAND,
|
|
894
|
+
),
|
|
895
|
+
SideEffectClass.ARTIFACT_WRITE: explicit,
|
|
896
|
+
SideEffectClass.WORKSPACE_WRITE: explicit,
|
|
897
|
+
SideEffectClass.PROCESS_EXECUTION: explicit,
|
|
898
|
+
SideEffectClass.NETWORK_READ: explicit,
|
|
899
|
+
SideEffectClass.NETWORK_WRITE: explicit,
|
|
900
|
+
SideEffectClass.TERMINAL: explicit,
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _safe_custom_tool_string(value: str, field_name: str, maximum: int) -> str:
|
|
905
|
+
validate_nonblank(value, field_name)
|
|
906
|
+
validate_utf8_size(value, field_name, maximum)
|
|
907
|
+
_reject_secret_material(value, field_name)
|
|
908
|
+
return value
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _validate_utc_timestamp(value: str, field_name: str) -> str:
|
|
912
|
+
validate_nonblank(value, field_name)
|
|
913
|
+
validate_utf8_size(value, field_name, MAX_CUSTOM_TOOL_FIELD_UTF8)
|
|
914
|
+
_reject_secret_material(value, field_name)
|
|
915
|
+
if not value.endswith("Z"):
|
|
916
|
+
raise ValueError(f"{field_name} must be an explicit UTC timestamp ending in Z")
|
|
917
|
+
try:
|
|
918
|
+
parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
|
|
919
|
+
except ValueError as exc:
|
|
920
|
+
raise ValueError(f"{field_name} must be an ISO 8601 UTC timestamp") from exc
|
|
921
|
+
if parsed.utcoffset() != timezone.utc.utcoffset(parsed):
|
|
922
|
+
raise ValueError(f"{field_name} must be an explicit UTC timestamp")
|
|
923
|
+
return value
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _validate_schema_size(value: Mapping[str, Any], field_name: str) -> None:
|
|
927
|
+
encoded = canonical_json_serialize(value).encode("utf-8")
|
|
928
|
+
if len(encoded) > MAX_CUSTOM_TOOL_SCHEMA_BYTES:
|
|
929
|
+
raise ValueError(f"{field_name} exceeds maximum schema size")
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _reject_secret_material(value: str, field_name: str) -> None:
|
|
933
|
+
if detect_secret_candidate(
|
|
934
|
+
field_path=f"/{field_name}",
|
|
935
|
+
field_name=field_name,
|
|
936
|
+
value=value,
|
|
937
|
+
policy=RedactionPolicy(),
|
|
938
|
+
):
|
|
939
|
+
raise ValueError(f"{field_name} contains suspected secret material")
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _reject_secret_values(value: Any, *, path: str) -> None:
|
|
943
|
+
if isinstance(value, Mapping):
|
|
944
|
+
for key, item in value.items():
|
|
945
|
+
_reject_secret_values(item, path=f"{path}/{key}")
|
|
946
|
+
return
|
|
947
|
+
if isinstance(value, tuple | list):
|
|
948
|
+
for index, item in enumerate(value):
|
|
949
|
+
_reject_secret_values(item, path=f"{path}/{index}")
|
|
950
|
+
return
|
|
951
|
+
if isinstance(value, str):
|
|
952
|
+
_reject_secret_material(value, path)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _sorted_string_tuple(value: Any, field_name: str) -> tuple[str, ...]:
|
|
956
|
+
if value is None:
|
|
957
|
+
return ()
|
|
958
|
+
if isinstance(value, str) or not isinstance(value, (list, tuple)):
|
|
959
|
+
raise ValueError(f"{field_name} must be an array")
|
|
960
|
+
values = tuple(value)
|
|
961
|
+
if not all(isinstance(item, str) for item in values):
|
|
962
|
+
raise ValueError(f"{field_name} must contain strings")
|
|
963
|
+
return tuple(sorted(values))
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _freeze_json_object(value: Mapping[str, Any]) -> MappingProxyType[str, Any]:
|
|
967
|
+
frozen = _freeze_json_value(dict(value), path="")
|
|
968
|
+
if not isinstance(frozen, MappingProxyType):
|
|
969
|
+
raise ValueError("schema must be a JSON object")
|
|
970
|
+
return frozen
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _freeze_json_value(value: Any, *, path: str) -> Any:
|
|
974
|
+
if isinstance(value, Mapping):
|
|
975
|
+
frozen_items: dict[str, Any] = {}
|
|
976
|
+
for key, item in sorted(value.items(), key=lambda item: str(item[0])):
|
|
977
|
+
if not isinstance(key, str):
|
|
978
|
+
raise ValueError(f"{path or '/'} object keys must be strings")
|
|
979
|
+
frozen_items[key] = _freeze_json_value(item, path=f"{path}/{key}")
|
|
980
|
+
return MappingProxyType(frozen_items)
|
|
981
|
+
if isinstance(value, tuple | list):
|
|
982
|
+
return tuple(
|
|
983
|
+
_freeze_json_value(item, path=f"{path}/{index}")
|
|
984
|
+
for index, item in enumerate(value)
|
|
985
|
+
)
|
|
986
|
+
return _validate_json_scalar(value, path=path)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _thaw_json_value(value: Any, *, path: str = "") -> Any:
|
|
990
|
+
if isinstance(value, Mapping):
|
|
991
|
+
thawed: dict[str, Any] = {}
|
|
992
|
+
for key, item in sorted(value.items(), key=lambda item: str(item[0])):
|
|
993
|
+
if not isinstance(key, str):
|
|
994
|
+
raise ValueError(f"{path or '/'} object keys must be strings")
|
|
995
|
+
thawed[key] = _thaw_json_value(item, path=f"{path}/{key}")
|
|
996
|
+
return thawed
|
|
997
|
+
if isinstance(value, tuple | list):
|
|
998
|
+
return [
|
|
999
|
+
_thaw_json_value(item, path=f"{path}/{index}")
|
|
1000
|
+
for index, item in enumerate(value)
|
|
1001
|
+
]
|
|
1002
|
+
return _validate_json_scalar(value, path=path)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _validate_json_scalar(value: Any, *, path: str) -> str | int | float | bool | None:
|
|
1006
|
+
if isinstance(value, str):
|
|
1007
|
+
_reject_secret_material(value, path or "value")
|
|
1008
|
+
json.dumps(value, allow_nan=False)
|
|
1009
|
+
return value
|
|
1010
|
+
if isinstance(value, bool):
|
|
1011
|
+
json.dumps(value, allow_nan=False)
|
|
1012
|
+
return value
|
|
1013
|
+
if isinstance(value, int):
|
|
1014
|
+
json.dumps(value, allow_nan=False)
|
|
1015
|
+
return value
|
|
1016
|
+
if isinstance(value, float):
|
|
1017
|
+
json.dumps(value, allow_nan=False)
|
|
1018
|
+
return value
|
|
1019
|
+
if value is None:
|
|
1020
|
+
json.dumps(value, allow_nan=False)
|
|
1021
|
+
return value
|
|
1022
|
+
raise ValueError(f"{path or '/'} must contain only JSON values")
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _sha256_hex(payload: Any) -> str:
|
|
1026
|
+
return hashlib.sha256(
|
|
1027
|
+
canonical_json_serialize(_thaw_json_value(payload)).encode("utf-8")
|
|
1028
|
+
).hexdigest()
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _validation_pointer(exc: ValidationError) -> str:
|
|
1032
|
+
errors = exc.errors()
|
|
1033
|
+
if not errors:
|
|
1034
|
+
return "/"
|
|
1035
|
+
loc = errors[0].get("loc")
|
|
1036
|
+
if not isinstance(loc, tuple | list) or not loc:
|
|
1037
|
+
return "/"
|
|
1038
|
+
parts = [str(part).replace("~", "~0").replace("/", "~1") for part in loc]
|
|
1039
|
+
return "/" + "/".join(parts)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _validation_missing_field(exc: ValidationError) -> str | None:
|
|
1043
|
+
errors = exc.errors()
|
|
1044
|
+
if not errors:
|
|
1045
|
+
return None
|
|
1046
|
+
first = errors[0]
|
|
1047
|
+
if first.get("type") != "missing":
|
|
1048
|
+
return None
|
|
1049
|
+
loc = first.get("loc")
|
|
1050
|
+
if not isinstance(loc, tuple | list) or not loc:
|
|
1051
|
+
return None
|
|
1052
|
+
return str(loc[-1])
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _validation_code(exc: ValidationError) -> CustomToolDiagnosticCode:
|
|
1056
|
+
errors = exc.errors()
|
|
1057
|
+
if not errors:
|
|
1058
|
+
return CustomToolDiagnosticCode.SOURCE_INVALID
|
|
1059
|
+
first = errors[0]
|
|
1060
|
+
loc = tuple(str(part) for part in first.get("loc", ()))
|
|
1061
|
+
message = str(first.get("msg", "")).lower()
|
|
1062
|
+
error_text = str(errors).lower()
|
|
1063
|
+
if "secret material" in error_text:
|
|
1064
|
+
return CustomToolDiagnosticCode.SECRET_MATERIAL
|
|
1065
|
+
if "runtime_kind" in loc:
|
|
1066
|
+
return CustomToolDiagnosticCode.RUNTIME_KIND_UNSUPPORTED
|
|
1067
|
+
if "produced_artifact_ids" in loc:
|
|
1068
|
+
return CustomToolDiagnosticCode.ARTIFACT_POLICY_INVALID
|
|
1069
|
+
if (
|
|
1070
|
+
"required_capabilities" in loc
|
|
1071
|
+
or "require capabilities" in error_text
|
|
1072
|
+
or "requires explicit capabilities" in error_text
|
|
1073
|
+
):
|
|
1074
|
+
return CustomToolDiagnosticCode.CAPABILITY_MISSING
|
|
1075
|
+
if "forbidden approval policy" in error_text:
|
|
1076
|
+
return CustomToolDiagnosticCode.FORBIDDEN_TOOL_COMPILED
|
|
1077
|
+
if "approval_policy" in loc or "side-effecting custom tools" in error_text:
|
|
1078
|
+
return CustomToolDiagnosticCode.APPROVAL_POLICY_INVALID
|
|
1079
|
+
if "timeout_policy" in loc:
|
|
1080
|
+
return CustomToolDiagnosticCode.TIMEOUT_POLICY_INVALID
|
|
1081
|
+
if "output_policy" in loc:
|
|
1082
|
+
return CustomToolDiagnosticCode.OUTPUT_POLICY_INVALID
|
|
1083
|
+
if "input schema" in message or "input_schema" in loc:
|
|
1084
|
+
return CustomToolDiagnosticCode.INPUT_SCHEMA_UNSUPPORTED
|
|
1085
|
+
if "output schema" in message or "output_schema" in loc:
|
|
1086
|
+
return CustomToolDiagnosticCode.OUTPUT_SCHEMA_UNSUPPORTED
|
|
1087
|
+
if "custom tool identities" in error_text:
|
|
1088
|
+
return CustomToolDiagnosticCode.DUPLICATE_TOOL
|
|
1089
|
+
if "custom tool model_tool_name" in error_text:
|
|
1090
|
+
return CustomToolDiagnosticCode.DUPLICATE_MODEL_TOOL_NAME
|
|
1091
|
+
if "custom tool implementation_id" in error_text:
|
|
1092
|
+
return CustomToolDiagnosticCode.DUPLICATE_IMPLEMENTATION_ID
|
|
1093
|
+
return CustomToolDiagnosticCode.SOURCE_INVALID
|